diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index a55310ce..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,177 +0,0 @@ -version: 2.1 - -orbs: - gh: circleci/github-cli@2.2.0 - -jobs: - flutter_linux_arm: - machine: - image: ubuntu-2204:current - resource_class: arm.medium - parameters: - version: - type: string - default: 3.1.1 - channel: - type: enum - enum: - - release - - nightly - default: release - github_run_number: - type: string - default: "0" - dry_run: - type: boolean - default: true - steps: - - checkout - - gh/setup - - - run: - name: Get current date - command: | - echo "export CURRENT_DATE=$(date +%Y-%m-%d)" >> $BASH_ENV - - - run: - name: Install dependencies - command: | - sudo apt-get update -y - sudo apt-get install -y 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 zip rpm - - - run: - name: Install Flutter - command: | - git clone https://github.com/flutter/flutter.git - cd flutter && git checkout stable && cd .. - export PATH="$PATH:`pwd`/flutter/bin" - flutter precache - flutter doctor -v - - - run: - name: Install AppImageTool - command: | - wget -O appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-aarch64.AppImage" - chmod +x appimagetool - mv appimagetool flutter/bin - - - persist_to_workspace: - root: flutter - paths: - - . - - - when: - condition: - equal: [<< parameters.channel >>, nightly] - steps: - - run: - name: Replace pubspec version and BUILD_VERSION Env (nightly) - command: | - curl -sS https://webi.sh/yq | sh - yq -i '.version |= sub("\+\d+", "+<< parameters.channel >>.")' pubspec.yaml - yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - echo 'export BUILD_VERSION="<< parameters.version >>+<< parameters.channel >>.<< parameters.github_run_number >>"' >> $BASH_ENV - - - when: - condition: - equal: [<< parameters.channel >>, release] - steps: - - run: echo 'export BUILD_VERSION="<< parameters.version >>"' >> $BASH_ENV - - - run: - name: Generate .env file - command: | - echo "SPOTIFY_SECRETS=${SPOTIFY_SECRETS}" >> .env - - - run: - name: Replace Version in files - command: | - sed -i 's|%{{APPDATA_RELEASE}}%||' linux/com.github.KRTirtho.Spotube.appdata.xml - echo "build_arch: aarch64" >> linux/packaging/rpm/make_config.yaml - - - run: - name: Build secrets - command: | - export PATH="$PATH:`pwd`/flutter/bin" - flutter config --enable-linux-desktop - flutter pub get - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns - - - run: - name: Build Flutter app - command: | - export PATH="$PATH:`pwd`/flutter/bin" - export PATH="$PATH":"$HOME/.pub-cache/bin" - dart pub global activate flutter_distributor - alias dpkg-deb="dpkg-deb --Zxz" - flutter_distributor package --platform=linux --targets=deb - flutter_distributor package --platform=linux --targets=appimage - flutter_distributor package --platform=linux --targets=rpm - - - when: - condition: - equal: [<< parameters.channel >>, nightly] - steps: - - run: make tar VERSION=nightly ARCH=arm64 PKG_ARCH=aarch64 - - - when: - condition: - equal: [<< parameters.channel >>, release] - steps: - - run: make tar VERSION=${BUILD_VERSION} ARCH=arm64 PKG_ARCH=aarch64 - - - run: - name: Move artifacts - command: | - mkdir bundle - mv build/spotube-linux-*-aarch64.tar.xz bundle/ - mv dist/**/spotube-*-linux.deb bundle/Spotube-linux-aarch64.deb - mv dist/**/spotube-*-linux.rpm bundle/Spotube-linux-aarch64.rpm - mv dist/**/spotube-*-linux.AppImage bundle/Spotube-linux-aarch64.AppImage - zip -r Spotube-linux-aarch64.zip bundle - - - store_artifacts: - path: Spotube-linux-aarch64.zip - - - when: - condition: - and: - - equal: [<< parameters.dry_run >>, false] - - equal: [<< parameters.channel >>, release] - steps: - - run: - name: Upload to release (release) - command: gh release upload v<< parameters.version >> bundle/* --clobber - - - when: - condition: - and: - - equal: [<< parameters.dry_run >>, false] - - equal: [<< parameters.channel >>, nightly] - steps: - - run: - name: Upload to release (nightly) - command: gh release upload nightly bundle/* --clobber - -parameters: - GHA_Actor: - type: string - default: "" - GHA_Action: - type: string - default: "" - GHA_Event: - type: string - default: "" - GHA_Meta: - type: string - default: "" - -workflows: - build_flutter_for_arm_workflow: - when: << pipeline.parameters.GHA_Action >> - jobs: - - flutter_linux_arm: - context: - - org-global - - GITHUB_CREDS diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..ddfd1517 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +build +dist +.dart_tool +.idea +.github +.git \ No newline at end of file diff --git a/.env.example b/.env.example index 22abd24b..35c5d563 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,17 @@ # The format: # SPOTIFY_SECRETS=clintId1:clientSecret1,clientId2:clientSecret2 -SPOTIFY_SECRETS= +SPOTIFY_SECRETS=$SPOTIFY_SECRETS # 0 or 1 # 0 = disable # 1 = enable -ENABLE_UPDATE_CHECK= +ENABLE_UPDATE_CHECK=$ENABLE_UPDATE_CHECK -LASTFM_API_KEY= -LASTFM_API_SECRET= +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 +DISABLE_SPOTIFY_IMAGES=$DISABLE_SPOTIFY_IMAGES diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index 7ca74200..b4cb4720 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,3 @@ { - "flutterSdkVersion": "3.19.1", - "flavors": {} + "flutterSdkVersion": "3.29.2" } \ No newline at end of file diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 00000000..5b0ad692 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,4 @@ +{ + "flutter": "3.29.2", + "flavors": {} +} \ No newline at end of file diff --git a/.github/Dockerfile b/.github/Dockerfile new file mode 100644 index 00000000..f6a9f538 --- /dev/null +++ b/.github/Dockerfile @@ -0,0 +1,25 @@ +ARG FLUTTER_VERSION + +FROM --platform=linux/arm64 krtirtho/flutter_distributor:${FLUTTER_VERSION} + +ARG BUILD_VERSION + +WORKDIR /app + +COPY . . + +RUN chown -R $(whoami) /app + +RUN rustup target add aarch64-unknown-linux-gnu + +RUN flutter pub get + +RUN alias dpkg-deb="dpkg-deb --Zxz" &&\ + flutter_distributor package --platform=linux --targets=deb --skip-clean + +RUN make tar VERSION=${BUILD_VERSION} ARCH=arm64 PKG_ARCH=aarch64 + +RUN mv build/spotube-linux-*-aarch64.tar.xz dist/ &&\ + mv dist/**/spotube-*-linux.deb dist/Spotube-linux-aarch64.deb + +CMD [ "sleep", "5000000" ] \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 64ee89d2..d4872798 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -7,8 +7,13 @@ labels: body: - type: checkboxes attributes: - label: Is there an existing issue for this? - description: Make sure to check if this issue is a duplicate. + label: Is there an existing issue for this? (Please read the description) + description: | + 🚨 PLEASE! Make sure to check if this issue is a duplicate. 🚨 + + Don't waste our time, we are working hard to make Spotube better for you. + + Try with multiple similar keywords, and check the closed issues too. options: - label: I have searched the existing issues required: true @@ -16,29 +21,47 @@ body: attributes: label: Current Behavior description: Write what you are experiencing currently. + placeholder: | + The app isn't working as expected. It crashes when I do this... validations: required: true - type: textarea attributes: label: Expected Behavior description: Write what you expected to happen. + placeholder: | + The app should do this when I do that... validations: required: true - type: textarea attributes: label: Steps to reproduce - description: Steps to reproduce the issue. A not well written description might delay the resolve of it. + description: Steps to reproduce the issue. A not well written description might lead to the delay in fixing the issue. placeholder: | 1. I opened the app 2. I did this 3. And that 4. Then this happened + - type: textarea + attributes: + label: Logs + description: | + If you have any logs, paste them here. Make sure to remove any sensitive information. + You can find the logs in the app's Settings > Developers > Logs page. + value: | +
+ Logs + + ``` + + ``` +
validations: required: true - type: input attributes: label: Operating System - description: The OS in which you used Spotube to face the issue. + description: The OS in which you used Spotube to face the issue. Use comma to separate multiple OS. placeholder: Android, Linux, macOS or Windows? Make sure to include the version too. validations: required: true @@ -53,7 +76,7 @@ body: description: Where did you install Spotube from? multiple: true options: - - "Website (spotube.netlify.app) or (spotube.krtirtho.dev)" + - "Website (spotube.krtirtho.dev)" - "GitHub Releases (Binary)" - "GitHub Actions (Nightly Binary)" - "Play Store (Android)" @@ -74,7 +97,10 @@ body: - type: checkboxes attributes: label: Self grab - description: If you are a developer and want to work on this issue yourself, you can check this box and wait for maintainer response. We welcome contributions! + description: | + If you are a developer and want to work on this issue yourself, you can check this box and wait for maintainer response. Any contributions are welcome! + + This project is maintained by one person. So PRs are always welcome. This is the best way to get your issue fixed faster. options: - label: I'm ready to work on this issue! - required: false \ No newline at end of file + required: false diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index e4fb55c5..245101d8 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -4,29 +4,35 @@ on: pull_request: env: - FLUTTER_VERSION: '3.16.0' + FLUTTER_VERSION: 3.29.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: + SPOTIFY_SECRETS: xxx:xxx + 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 - echo '${{ secrets.DOTENV_NIGHTLY }}' > .env dart run build_runner build --delete-conflicting-outputs - name: Lint Dart files run: | dart analyze --no-fatal-warnings - - - name: Lint translations & config files - run: | - npm install -g @prantlf/jsonlint - jsonlint -q -D --enforce-double-quotes ./lib/l10n/*.arb - jsonlint -q -D --enforce-double-quotes -T .vscode/*.json \ No newline at end of file diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 12a2f99b..f88e618c 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -4,7 +4,7 @@ on: inputs: version: description: Version to publish (x.x.x) - default: 3.1.0 + default: 4.0.0 required: true dry_run: description: Dry run @@ -12,10 +12,10 @@ on: type: boolean default: true jobs: - description: Jobs to run (flathub,aur,winget,chocolatey) + description: Jobs to run (flathub,aur,winget,chocolatey,playstore) required: true type: string - default: "flathub,aur,winget,chocolatey" + default: "flathub,aur,winget,chocolatey,playstore" jobs: flathub: @@ -55,7 +55,7 @@ jobs: - uses: dsaltares/fetch-gh-release-asset@master with: version: tags/v${{ inputs.version }} # mind the "v" prefix - file: spotube-linux-${{inputs.version}}-x86_64.tar.xz + file: spotube-linux-${{inputs.version}}-x86_64.tar.xz token: ${{ secrets.GITHUB_TOKEN }} - name: Update PKGBUILD versions @@ -66,7 +66,7 @@ jobs: - name: Release to AUR if: ${{ !inputs.dry_run }} - uses: KSXGitHub/github-actions-deploy-aur@v2.7.0 + uses: KSXGitHub/github-actions-deploy-aur@v2.7.2 with: pkgname: spotube-bin pkgbuild: aur-struct/PKGBUILD @@ -76,12 +76,12 @@ jobs: commit_message: Updated to v${{ inputs.version }} winget: - runs-on: windows-latest + runs-on: ubuntu-latest if: contains(inputs.jobs, 'winget') steps: - name: Release winget package if: ${{ !inputs.dry_run }} - uses: vedantmgoyal2009/winget-releaser@v2 + uses: vedantmgoyal9/winget-releaser@main with: version: ${{ inputs.version }} release-tag: v${{ inputs.version }} @@ -104,3 +104,34 @@ jobs: - name: Publish to Chocolatey Repository 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 }} diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 68ea2d67..05a672d4 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -2,279 +2,123 @@ name: Spotube Release Binary on: workflow_dispatch: inputs: - version: - description: Version to release (x.x.x) - default: 3.4.1 - required: true channel: type: choice - description: Release Channel - required: true options: - stable - nightly default: nightly + description: The release channel debug: - description: Debug on failed when channel is nightly - required: true type: boolean default: false + description: Debug with SSH toggle + required: false dry_run: - description: Dry run - required: true type: boolean - default: true + default: false + description: Dry run without uploading to release env: - FLUTTER_VERSION: '3.19.1' + FLUTTER_VERSION: 3.29.2 + FLUTTER_CHANNEL: master + +permissions: + contents: write jobs: - windows: - runs-on: windows-latest + 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.tar.xz + - os: ubuntu-22.04-arm + platform: linux + arch: arm64 + files: | + dist/Spotube-linux-aarch64.deb + dist/spotube-linux-*-aarch64.tar.xz + - os: ubuntu-22.04 + platform: android + arch: all + files: | + build/Spotube-android-all-arch.apk + build/Spotube-playstore-all-arch.aab + - os: windows-latest + platform: windows + arch: x86 + files: | + dist/Spotube-windows-x86_64.nupkg + dist/Spotube-windows-x86_64-setup.exe + - os: macos-latest + 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}} steps: - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2.12.0 + - uses: subosito/flutter-action@v2.18.0 with: - cache: true flutter-version: ${{ env.FLUTTER_VERSION }} - - - name: Replace pubspec version and BUILD_VERSION Env (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: | - choco install sed make yq -y - yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml - yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $env:GITHUB_ENV - - - name: BUILD_VERSION Env (stable) - if: ${{ inputs.channel == 'stable' }} - run: | - "BUILD_VERSION=${{ inputs.version }}" >> $env:GITHUB_ENV - - - name: Replace version in files - run: | - choco install sed make -y - sed -i "s/%{{SPOTUBE_VERSION}}%/${{ env.BUILD_VERSION }}/" windows/runner/Runner.rc - sed -i "s/%{{SPOTUBE_VERSION}}%/${{ env.BUILD_VERSION }}/" choco-struct/tools/VERIFICATION.txt - sed -i "s/%{{SPOTUBE_VERSION}}%/${{ env.BUILD_VERSION }}/" choco-struct/spotube.nuspec - - - name: Create Stable .env - if: ${{ inputs.channel == 'stable' }} - run: echo '${{ secrets.DOTENV_RELEASE }}' > .env - - - name: Create Nightly .env - if: ${{ inputs.channel == 'nightly' }} - run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env - - - name: Generating Secrets - run: | - flutter config --enable-windows-desktop - flutter pub get - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns - - - name: Build Windows Executable - run: | - dart pub global activate flutter_distributor - make innoinstall - flutter_distributor package --platform=windows --targets=exe --skip-clean - mv dist/**/spotube-*-windows-setup.exe dist/Spotube-windows-x86_64-setup.exe - - - name: Create Chocolatey Package and set hash - if: ${{ inputs.channel == 'stable' }} - run: | - Set-Variable -Name HASH -Value (Get-FileHash dist\Spotube-windows-x86_64-setup.exe).Hash - sed -i "s/%{{WIN_SHA256}}%/$HASH/" choco-struct/tools/VERIFICATION.txt - make choco - mv dist/spotube.*.nupkg dist/Spotube-windows-x86_64.nupkg - - - - name: Upload Artifact - uses: actions/upload-artifact@v3 - with: - if-no-files-found: error - name: Spotube-Release-Binaries - path: | - dist/Spotube-windows-x86_64.nupkg - dist/Spotube-windows-x86_64-setup.exe - - - name: Debug With SSH When fails - if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} - uses: mxschmitt/action-tmate@v3 - with: - limit-access-to-actor: true - - linux: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2.12.0 - with: + channel: ${{ env.FLUTTER_CHANNEL }} cache: true - flutter-version: ${{ env.FLUTTER_VERSION }} + git-source: https://github.com/flutter/flutter.git - - name: Get current date - id: date - run: echo "::set-output name=date::$(date +'%Y-%m-%d')" - - - name: Install Dependencies - run: | - sudo apt-get update -y - sudo apt-get install -y 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 - - - name: Install AppImage Tool - run: | - wget -O appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" - chmod +x appimagetool - mv appimagetool /usr/local/bin/ - - - name: Replace pubspec version and BUILD_VERSION Env (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: | - curl -sS https://webi.sh/yq | sh - yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml - yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV - - - name: BUILD_VERSION Env (stable) - if: ${{ inputs.channel == 'stable' }} - run: | - echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV - - - name: Create Stable .env - if: ${{ inputs.channel == 'stable' }} - run: echo '${{ secrets.DOTENV_RELEASE }}' > .env - - - name: Create Nightly .env - if: ${{ inputs.channel == 'nightly' }} - run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env - - - name: Replace Version in files - run: | - sed -i 's|%{{APPDATA_RELEASE}}%||' linux/com.github.KRTirtho.Spotube.appdata.xml - - - name: Generate Secrets - run: | - flutter config --enable-linux-desktop - flutter pub get - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns - - - name: Build Linux Packages - run: | - dart pub global activate flutter_distributor - alias dpkg-deb="dpkg-deb --Zxz" - flutter_distributor package --platform=linux --targets=deb - flutter_distributor package --platform=linux --targets=rpm - - - name: Create tar.xz (stable) - if: ${{ inputs.channel == 'stable' }} - run: make tar VERSION=${{ env.BUILD_VERSION }} ARCH=x64 PKG_ARCH=x86_64 - - - name: Create tar.xz (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: make tar VERSION=nightly ARCH=x64 PKG_ARCH=x86_64 - - - name: Move Files to dist - run: | - mv build/spotube-linux-*-x86_64.tar.xz dist/ - mv dist/**/spotube-*-linux.deb dist/Spotube-linux-x86_64.deb - mv dist/**/spotube-*-linux.rpm dist/Spotube-linux-x86_64.rpm - - - - uses: actions/upload-artifact@v3 - if: ${{ inputs.channel == 'stable' }} + - name: Setup Java + if: ${{matrix.platform == 'android'}} + uses: actions/setup-java@v4 with: - if-no-files-found: error - name: Spotube-Release-Binaries - path: | - dist/Spotube-linux-x86_64.deb - dist/Spotube-linux-x86_64.rpm - dist/spotube-linux-${{ env.BUILD_VERSION }}-x86_64.tar.xz - - - uses: actions/upload-artifact@v3 - if: ${{ inputs.channel == 'nightly' }} + distribution: "zulu" + java-version: "17" + cache: "gradle" + check-latest: true + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable with: - if-no-files-found: error - name: Spotube-Release-Binaries - path: | - dist/Spotube-linux-x86_64.deb - dist/Spotube-linux-x86_64.rpm - dist/spotube-linux-nightly-x86_64.tar.xz + toolchain: stable - - name: Debug With SSH When fails - if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} - uses: mxschmitt/action-tmate@v3 + - name: Install Xcode + if: ${{matrix.platform == 'ios'}} + uses: maxim-lobanov/setup-xcode@v1 with: - limit-access-to-actor: true + xcode-version: "16.1" - - android: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2.12.0 - with: - cache: true - flutter-version: ${{ env.FLUTTER_VERSION }} - - - name: Install Dependencies - 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 xmlstarlet - - - name: Replace pubspec version and BUILD_VERSION Env (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: | - curl -sS https://webi.sh/yq | sh - yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml - yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV - - - name: BUILD_VERSION Env (stable) - if: ${{ inputs.channel == 'stable' }} - run: | - echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV - - - name: Create Stable .env - if: ${{ inputs.channel == 'stable' }} - run: echo '${{ secrets.DOTENV_RELEASE }}' > .env - - - name: Create Nightly .env - if: ${{ inputs.channel == 'nightly' }} - run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env - - - name: Generate Secrets + - name: Install ${{matrix.platform}} dependencies run: | flutter pub get - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns + dart cli/cli.dart install-dependencies --platform=${{matrix.platform}} - 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 Apk - run: | - flutter build apk --flavor ${{ inputs.channel }} - mv build/app/outputs/flutter-apk/app-${{ inputs.channel }}-release.apk build/Spotube-android-all-arch.apk + - name: Build ${{matrix.platform}} binaries + run: dart cli/cli.dart build --arch=${{matrix.arch}} ${{matrix.platform}} + env: + CHANNEL: ${{inputs.channel}} + DOTENV: ${{secrets.DOTENV_RELEASE}} - - name: Build Playstore AppBundle - run: | - echo 'ENABLE_UPDATE_CHECK=0' >> .env - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns - export MANIFEST=android/app/src/main/AndroidManifest.xml - xmlstarlet ed -d '//meta-data[@android:name="com.google.android.gms.car.application"]' $MANIFEST > $MANIFEST.tmp - mv $MANIFEST.tmp $MANIFEST - flutter build appbundle --flavor ${{ inputs.channel }} - mv build/app/outputs/bundle/${{ inputs.channel }}Release/app-${{ inputs.channel }}-release.aab build/Spotube-playstore-all-arch.aab - - - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: if-no-files-found: error - name: Spotube-Release-Binaries - path: | - build/Spotube-android-all-arch.apk - build/Spotube-playstore-all-arch.aab + name: ${{matrix.platform}}-${{matrix.arch}} + path: ${{matrix.files}} - name: Debug With SSH When fails if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} @@ -282,139 +126,14 @@ jobs: with: limit-access-to-actor: true - macos: - - runs-on: macos-12 - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2.12.0 - with: - cache: true - flutter-version: ${{ env.FLUTTER_VERSION }} - - - name: Replace pubspec version and BUILD_VERSION Env (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: | - brew install yq - yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml - yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV - - - name: BUILD_VERSION Env (stable) - if: ${{ inputs.channel == 'stable' }} - run: | - echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV - - - name: Create Stable .env - if: ${{ inputs.channel == 'stable' }} - run: echo '${{ secrets.DOTENV_RELEASE }}' > .env - - - name: Create Nightly .env - if: ${{ inputs.channel == 'nightly' }} - run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env - - - name: Generate Secrets - run: | - dart pub global activate flutter_distributor - flutter pub get - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns - - - name: Build Macos App - run: | - flutter config --enable-macos-desktop - flutter build macos - du -sh build/macos/Build/Products/Release/spotube.app - - - name: Package Macos App - run: | - python3 -m pip install setuptools - npm install -g appdmg - mkdir -p build/${{ env.BUILD_VERSION }} - appdmg appdmg.json build/Spotube-macos-universal.dmg - flutter_distributor package --platform=macos --targets pkg --skip-clean - mv dist/**/spotube-*-macos.pkg build/Spotube-macos-universal.pkg - - - uses: actions/upload-artifact@v3 - with: - if-no-files-found: error - name: Spotube-Release-Binaries - path: | - build/Spotube-macos-universal.dmg - build/Spotube-macos-universal.pkg - - - name: Debug With SSH When fails - if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} - uses: mxschmitt/action-tmate@v3 - with: - limit-access-to-actor: true - - iOS: - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2.10.0 - with: - cache: true - flutter-version: ${{ env.FLUTTER_VERSION }} - - - name: Replace pubspec version and BUILD_VERSION Env (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: | - brew install yq - yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml - yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV - - - name: BUILD_VERSION Env (stable) - if: ${{ inputs.channel == 'stable' }} - run: | - echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV - - - name: Create Stable .env - if: ${{ inputs.channel == 'stable' }} - run: echo '${{ secrets.DOTENV_RELEASE }}' > .env - - - name: Create Nightly .env - if: ${{ inputs.channel == 'nightly' }} - run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env - - - name: Generate Secrets - run: | - flutter pub get - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns - - - name: Build iOS iPA - run: | - flutter build ios --release --no-codesign --flavor ${{ inputs.channel }} - ln -sf ./build/ios/iphoneos Payload - zip -r9 Spotube-iOS.ipa Payload/${{ inputs.channel }}.app - - - uses: actions/upload-artifact@v3 - with: - if-no-files-found: error - name: Spotube-Release-Binaries - path: | - Spotube-iOS.ipa - - - 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-latest - + runs-on: ubuntu-22.04 needs: - - windows - - linux - - android - - macos - - iOS + - build_platform steps: - - uses: actions/download-artifact@v3 + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 with: - name: Spotube-Release-Binaries path: ./Spotube-Release-Binaries - name: Install dependencies @@ -423,14 +142,19 @@ jobs: - name: Generate Checksums run: | tree . - md5sum Spotube-Release-Binaries/* >> RELEASE.md5sum - sha256sum Spotube-Release-Binaries/* >> RELEASE.sha256sum + 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 - - uses: actions/upload-artifact@v3 + - name: Extract pubspec version + run: | + echo "PUBSPEC_VERSION=$(grep -oP 'version:\s*\K[^+]+(?=\+)' pubspec.yaml)" >> $GITHUB_ENV + + - uses: actions/upload-artifact@v4 with: if-no-files-found: error - name: Spotube-Release-Binaries + name: sums path: | RELEASE.md5sum RELEASE.sha256sum @@ -440,12 +164,12 @@ jobs: uses: ncipollo/release-action@v1 with: token: ${{ secrets.GITHUB_TOKEN }} - tag: v${{ inputs.version }} # mind the "v" prefix + tag: v${{ env.PUBSPEC_VERSION }} # mind the "v" prefix omitBodyDuringUpdate: true omitNameDuringUpdate: true omitPrereleaseDuringUpdate: true allowUpdates: true - artifacts: Spotube-Release-Binaries/*,RELEASE.sha256sum,RELEASE.md5sum + artifacts: Spotube-Release-Binaries/**/*,RELEASE.sha256sum,RELEASE.md5sum - name: Upload Release Binaries (nightly) if: ${{ !inputs.dry_run && inputs.channel == 'nightly' }} @@ -457,4 +181,15 @@ jobs: omitNameDuringUpdate: true omitPrereleaseDuringUpdate: true allowUpdates: true - artifacts: Spotube-Release-Binaries/*,RELEASE.sha256sum,RELEASE.md5sum + artifacts: Spotube-Release-Binaries/**/*,RELEASE.sha256sum,RELEASE.md5sum + body: | + Build Number: ${{github.run_number}} + + Nightly release includes newest features but may contain bugs + It is preferred to use the stable version unless you know what you're doing + + - name: Debug With SSH When fails + if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} + uses: mxschmitt/action-tmate@v3 + with: + limit-access-to-actor: true diff --git a/.gitignore b/.gitignore index 96d81087..119e42e5 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,13 @@ dist appimage-build android/key.properties -.fvm/flutter_sdk **/pb_data + +tm.json + +# FVM Version Cache +.fvm/ + +android/build +android/app/.cxx diff --git a/.metadata b/.metadata index 082985ad..828f2c0a 100644 --- a/.metadata +++ b/.metadata @@ -1,11 +1,11 @@ # 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. +# This file should be version controlled and should not be manually edited. version: - revision: eb6d86ee27deecba4a83536aa20f366a6044895c - channel: stable + revision: "300451adae589accbece3490f4396f10bdf15e6e" + channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: eb6d86ee27deecba4a83536aa20f366a6044895c - base_revision: eb6d86ee27deecba4a83536aa20f366a6044895c - - platform: macos - create_revision: eb6d86ee27deecba4a83536aa20f366a6044895c - base_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + create_revision: 300451adae589accbece3490f4396f10bdf15e6e + base_revision: 300451adae589accbece3490f4396f10bdf15e6e + - platform: windows + create_revision: 300451adae589accbece3490f4396f10bdf15e6e + base_revision: 300451adae589accbece3490f4396f10bdf15e6e # User provided section diff --git a/.vscode/launch.json b/.vscode/launch.json index 7a1e8b9b..deabf1d3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -30,6 +30,17 @@ "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": [] diff --git a/.vscode/settings.json b/.vscode/settings.json index 0e6a4294..8ae9c74f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,13 +2,24 @@ "cmake.configureOnOpen": false, "cSpell.words": [ "acousticness", + "ambiguate", + "Amoled", + "Buildless", "danceability", + "fuzzywuzzy", + "gapless", "instrumentalness", + "isrc", "Mpris", + "RGBO", "riverpod", "Scrobblenaut", + "shadcn", + "skeletonizer", + "songlink", "speechiness", "Spotube", + "titlebar", "winget" ], "editor.formatOnSave": true, @@ -16,5 +27,7 @@ "explorer.fileNesting.patterns": { "pubspec.yaml": "pubspec.lock,analysis_options.yaml,.packages,.flutter-plugins,.flutter-plugins-dependencies,flutter_launcher_icons*.yaml,flutter_native_splash*.yaml", "README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md", - } + "*.dart": "${capture}.g.dart,${capture}.freezed.dart" + }, + "dart.flutterSdkPath": ".fvm/versions/3.29.2" } \ No newline at end of file diff --git a/.vscode/snippets.code-snippets b/.vscode/snippets.code-snippets new file mode 100644 index 00000000..9a18929b --- /dev/null +++ b/.vscode/snippets.code-snippets @@ -0,0 +1,170 @@ +{ + "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/CHANGELOG.md b/CHANGELOG.md index ddbd4fe1..bf906a76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,638 +1,821 @@ # Changelog -All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [4.0.2](https://github.com/krtirtho/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) + +### 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 +- lastfm form broken in other locales #2447 +- spotify login broken due to new totp requirement #2494 +- spotify authentication 429 errors + +### Features + +- **local_library**: add support for x-flac, opus and x-wav +- **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) + +## Changes + +### Bug Fixes + +- SafeArea for global bottom items not working +- SafeArea not working for bottom floating widgets +- youtube video unplayable issue and use more ytClients +- **ios**: downloads not working due to permission errors (#2180) +- remove automaticallyImplyLeading from root tabs +- **android**: back button not working and safe area issues +- duplicates in recent section +- 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 +- piped api not working + +### Features + +- **android**: home widget support (#2148) +- rewrite entire app in shadcn-ui replacing material eww +- grid/list customizable playbutton view +- flag to hide spotify generated images with patterns +- show placeholder images where there is no item or on empty page +- pause playback when no internet connection +- 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) + +## Changes + +### Bug Fixes + +- UI glitch when loading more user artists and albums +- 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) + +## Changes + +### Bug Fixes + +- update youtube_explode_dart to 2.2.3 to fix no playback (#1980) + +### Features + +- **macos**: enable same window webview support + +## [3.8.2](https://github.com/krtirtho/spotube/compare/v3.8.1...v3.8.2) (2024-09-30) + +## Changes + +### Bug Fixes + +- 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) + +## Changes + +### Bug Fixes + +- **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 +- 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 +- **android**: clears queue upon swiping away notification +- **player**: shuffle button state resets after closing page #1657 +- getting started page login page exception #1800 +- **mobile**: queue doesn't persist +- local tracks takes time to load +- start radio not working #1629 + +### Features + +- **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) + +### Features + +- translations: make state page's hard coded strings translatable (#1719) +- discord: add listening activity type +- discord: album art, playing time and play pause support (#1765) +- linux: Use XDG_STATE_HOME to storage logs (#1675) +- discord rpc for macOS, windows-arm64 and linux-arm64 (#1713) +- desktop: implement webview based login +- stats: add lazy loading support + +### Bug Fixes + +- translations: fix Russian translations (#1696) +- ios: permission exception +- linux: tray icon wrong name for flatpak +- windows: app crashes when no internet +- windows: local tracks plays but disabled playback controls +- go to track album shows up for local tracks +- local track metadata timeout +- windows: window stretching #1553 +- android: app getting killed from background +- linux: OS Media control not working for Flatpak #1627 +- incorrect datatype used for MPRIS position property #1521 +- Too many artists for a track causing overflows +- playlist share button does not work #1639 +- unescape html escape values #1300 +- lyrics page doesn't scroll to top after song ends #885 +- changed source doesn't get saved and uses the wrong once again +- null exception in album page navigated from /home +- 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) + +### Bug Fixes + +- alternative sources not showing up for SongLink matched results ([37d002d](https://github.com/krtirtho/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)) +- browse anonymously button takes to wrong route ([73c5b30](https://github.com/krtirtho/spotube/commit/73c5b30b63a4c82bb7ad5b52bc10c5594566a800)) +- **desktop:** titlebar drag to move not working ([5f280a1](https://github.com/krtirtho/spotube/commit/5f280a19f4d5f8882ae2ff60c6cc595ded7a5a1d)) +- **desktop:** window is not centered ([47f98b9](https://github.com/krtirtho/spotube/commit/47f98b98aafab9b426733ed44cab2be8a646a98e)) +- **ios:** download not working [#1575](https://github.com/krtirtho/spotube/issues/1575) ([6591ec0](https://github.com/krtirtho/spotube/commit/6591ec0e1b441dd8fd535eace19a58c7749389ca)) +- **linux:** application window not visible after launch ([8fc44ed](https://github.com/krtirtho/spotube/commit/8fc44ed6550e8b2b804991ff82df08afb1c94ca8)) +- local track not showing up in queue ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) +- use weak match for Jiosaavn fallback to improve matching ([6cb2986](https://github.com/krtirtho/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)) +- **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)) + +## [3.7.0](https://github.com/krtirtho/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)) +- personalized stats based on local music history ([#1522](https://github.com/krtirtho/spotube/issues/1522)) ([82307bc](https://github.com/krtirtho/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)) +- **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) +- **translations:** add Basque translation ([#1493](https://github.com/krtirtho/spotube/issues/1493)) ([dbc1c45](https://github.com/krtirtho/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) +- **translations:** add georgian language ([#1450](https://github.com/krtirtho/spotube/issues/1450)) ([1e7f0e1](https://github.com/krtirtho/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) +- **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) +- upgrade to Flutter 3.22.0 ([71341ec](https://github.com/krtirtho/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)) +- **linux:** tray icon not showing [#541](https://github.com/krtirtho/spotube/issues/541) ([7ac7917](https://github.com/krtirtho/spotube/commit/7ac791757abb30f40374c169c4211916287bb3f3)) +- local track not showing up in queue ([d82261c](https://github.com/krtirtho/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)) +- **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)) +- **search:** load more button not working [#1417](https://github.com/krtirtho/spotube/issues/1417) ([7e07c2e](https://github.com/krtirtho/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)) +- 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)) +- **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) +- 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) +- **windows:** installer tries to install in current directory ([c3c9fc5](https://github.com/krtirtho/spotube/commit/c3c9fc544c68b3d897dd7241a61cab7a199b4539)) + +## [3.6.0](https://github.com/krtirtho/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)) +- add user profile page ([39e97ee](https://github.com/krtirtho/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)) +- improved caching based on riverpod ([#1343](https://github.com/krtirtho/spotube/issues/1343)) ([6673e5a](https://github.com/krtirtho/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)) +- **lyrics:** add LRCLIB lyrics provider as fallback ([5afe823](https://github.com/krtirtho/spotube/commit/5afe823abdb198340b55d138d8173d886a811632)) +- search history support [#1236](https://github.com/krtirtho/spotube/issues/1236) ([82b1cfa](https://github.com/krtirtho/spotube/commit/82b1cfa0d775e3958c666280943a893c9113d468)) +- **translations:** Add Czech translation ([#1401](https://github.com/krtirtho/spotube/issues/1401)) ([5a6b800](https://github.com/krtirtho/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) + +### Bug Fixes + +- instance of Artist bug [#1362](https://github.com/krtirtho/spotube/issues/1362) ([c8dd802](https://github.com/krtirtho/spotube/commit/c8dd8025ec96bd78ed77cae35f1429aa48c16fde)) +- **playback:** sponsor block skips and stutters in same position ([0d080b7](https://github.com/krtirtho/spotube/commit/0d080b77b72529c0be5ebc27ace1c52307511f73)) ## [3.5.0](https://github.com/krtirtho/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)) -* add getting started page ([96a2a1f](https://github.com/krtirtho/spotube/commit/96a2a1f5a622cb3c580041417d5023e37fa69716)) -* Add iOS background play support ([#1166](https://github.com/krtirtho/spotube/issues/1166)) ([095587e](https://github.com/krtirtho/spotube/commit/095587ee84f7d867c69fcf4b09ed608d63478e1e)) -* add songlink based track matching for youtube and open song link button ([9095a8c](https://github.com/krtirtho/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)) -* start radio support ([4defeef](https://github.com/krtirtho/spotube/commit/4defeefe7e5947aa00a2afb2a06577ec141cdc52)) -* **translations:** add Korean translation ([#1275](https://github.com/krtirtho/spotube/issues/1275)) ([fdea930](https://github.com/krtirtho/spotube/commit/fdea9307bbfb8f3f62cfb795bfb3ca58c38c33d9)) -* **translations:** Added Vietnamese ([#1135](https://github.com/krtirtho/spotube/issues/1135)) ([019ba86](https://github.com/krtirtho/spotube/commit/019ba865e20a8b54ea3490c01e47158eaf3a4c8d)) -* **windows:** Install Visual C++ 2015-2022 Redistributable if missing when installing ([ba69496](https://github.com/krtirtho/spotube/commit/ba69496dcc9a1b7f6ea4e104e71764a854d27f1f)) - +- add endless playback support [#285](https://github.com/krtirtho/spotube/issues/285) ([9dfd49c](https://github.com/krtirtho/spotube/commit/9dfd49ca04f0e915e333e205b17ac70456873f6e)) +- add getting started page ([96a2a1f](https://github.com/krtirtho/spotube/commit/96a2a1f5a622cb3c580041417d5023e37fa69716)) +- Add iOS background play support ([#1166](https://github.com/krtirtho/spotube/issues/1166)) ([095587e](https://github.com/krtirtho/spotube/commit/095587ee84f7d867c69fcf4b09ed608d63478e1e)) +- add songlink based track matching for youtube and open song link button ([9095a8c](https://github.com/krtirtho/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)) +- start radio support ([4defeef](https://github.com/krtirtho/spotube/commit/4defeefe7e5947aa00a2afb2a06577ec141cdc52)) +- **translations:** add Korean translation ([#1275](https://github.com/krtirtho/spotube/issues/1275)) ([fdea930](https://github.com/krtirtho/spotube/commit/fdea9307bbfb8f3f62cfb795bfb3ca58c38c33d9)) +- **translations:** Added Vietnamese ([#1135](https://github.com/krtirtho/spotube/issues/1135)) ([019ba86](https://github.com/krtirtho/spotube/commit/019ba865e20a8b54ea3490c01e47158eaf3a4c8d)) +- **windows:** Install Visual C++ 2015-2022 Redistributable if missing when installing ([ba69496](https://github.com/krtirtho/spotube/commit/ba69496dcc9a1b7f6ea4e104e71764a854d27f1f)) ### Bug Fixes -* album images are small in certain places ([ca76a39](https://github.com/krtirtho/spotube/commit/ca76a39910b1a5af91aa7882a0d33c9d71db58a2)) -* album, artist page not loading [#1282](https://github.com/krtirtho/spotube/issues/1282) ([a9a1d4c](https://github.com/krtirtho/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) -* **android:** only ask battery optimization once [#1252](https://github.com/krtirtho/spotube/issues/1252) ([e516afb](https://github.com/krtirtho/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)) -* **android:** system back button in player page exits the app ([3294f65](https://github.com/krtirtho/spotube/commit/3294f657fe8a03b18d9be8974968b6508465963d)) -* cleanTitle removing feat and ft from words instead of whole words ([8612345](https://github.com/krtirtho/spotube/commit/86123456f2ff577921cf62cffca180427dfe1dd5)) -* friends list not scrollable with mouse drag ([ab08c82](https://github.com/krtirtho/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)) -* non-banger songs breaking the queue if sources not found ([90f7c53](https://github.com/krtirtho/spotube/commit/90f7c531cdc8640afdbabf5a0592159715ea1e6f)) -* track loading when not found in Youtube ([e964f61](https://github.com/krtirtho/spotube/commit/e964f61d38cb303e3d3fd60c866414f57207181c)) -* **translations:** Update app_nl.arb ([#1168](https://github.com/krtirtho/spotube/issues/1168)) ([8167963](https://github.com/krtirtho/spotube/commit/8167963212eeb5dfb0b4fb2eadf81d466659a9f1)) +- album images are small in certain places ([ca76a39](https://github.com/krtirtho/spotube/commit/ca76a39910b1a5af91aa7882a0d33c9d71db58a2)) +- album, artist page not loading [#1282](https://github.com/krtirtho/spotube/issues/1282) ([a9a1d4c](https://github.com/krtirtho/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) +- **android:** only ask battery optimization once [#1252](https://github.com/krtirtho/spotube/issues/1252) ([e516afb](https://github.com/krtirtho/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)) +- **android:** system back button in player page exits the app ([3294f65](https://github.com/krtirtho/spotube/commit/3294f657fe8a03b18d9be8974968b6508465963d)) +- cleanTitle removing feat and ft from words instead of whole words ([8612345](https://github.com/krtirtho/spotube/commit/86123456f2ff577921cf62cffca180427dfe1dd5)) +- friends list not scrollable with mouse drag ([ab08c82](https://github.com/krtirtho/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)) +- non-banger songs breaking the queue if sources not found ([90f7c53](https://github.com/krtirtho/spotube/commit/90f7c531cdc8640afdbabf5a0592159715ea1e6f)) +- track loading when not found in Youtube ([e964f61](https://github.com/krtirtho/spotube/commit/e964f61d38cb303e3d3fd60c866414f57207181c)) +- **translations:** Update app_nl.arb ([#1168](https://github.com/krtirtho/spotube/issues/1168)) ([8167963](https://github.com/krtirtho/spotube/commit/8167963212eeb5dfb0b4fb2eadf81d466659a9f1)) ## [3.4.1](https://personal.github.com/krtirtho/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)) -* add spotify friends activity ([#1130](https://personal.github.com/krtirtho/spotube/issues/1130)) ([7983932](https://personal.github.com/krtirtho/spotube/commit/79839329b0970acccb0c566a31eee508adbc8557)) -* **deep-link:** add track opening page ([988a975](https://personal.github.com/krtirtho/spotube/commit/988a975bf1a675df0cfc7b17776bcec74c67f1f2)) -* haptic feedback on long press and reordering actions ([6242200](https://personal.github.com/krtirtho/spotube/commit/624220090572eb643dce37ca8ffd85d2b3f5c9df)) -* improve youtube/piped matching by suffixing "- Topic" ([8184555](https://personal.github.com/krtirtho/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) - +- add create playlist button in add playlist dialog ([2168a64](https://personal.github.com/krtirtho/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)) +- **deep-link:** add track opening page ([988a975](https://personal.github.com/krtirtho/spotube/commit/988a975bf1a675df0cfc7b17776bcec74c67f1f2)) +- haptic feedback on long press and reordering actions ([6242200](https://personal.github.com/krtirtho/spotube/commit/624220090572eb643dce37ca8ffd85d2b3f5c9df)) +- improve youtube/piped matching by suffixing "- Topic" ([8184555](https://personal.github.com/krtirtho/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) ### 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)) -* 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)) -* **android:** download failing for permission issues [#1015](https://personal.github.com/krtirtho/spotube/issues/1015) ([5509cae](https://personal.github.com/krtirtho/spotube/commit/5509cae91c8b1f5cb9fac179060f477397a4a27f)) -* artist page error [#1018](https://personal.github.com/krtirtho/spotube/issues/1018) ([8cd650b](https://personal.github.com/krtirtho/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)) -* 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)) -* 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)) -* **linux:** crash after login ([0dfd401](https://personal.github.com/krtirtho/spotube/commit/0dfd40153714b7a4b83ac30f0c56830bc0c05ffd)) -* **macos:** backbutton and window button overlap and unused empty space on home ([b9417ca](https://personal.github.com/krtirtho/spotube/commit/b9417ca3575992673357230dab49e0124dd576b1)) -* **macos:** download folder unchangeable ([9d74cf5](https://personal.github.com/krtirtho/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)) -* **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)) -* 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)) -* 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)) -* 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)) -* track index not showing after 200 ([a752cf4](https://personal.github.com/krtirtho/spotube/commit/a752cf4c978d1b05851aabb6c84c7862de551320)) -* track pad horizontal scrolling not working ([59e0e6b](https://personal.github.com/krtirtho/spotube/commit/59e0e6bb659b70831f6e0ae064100381c57f149c)) +- alternative searched sources doesn't play [#1059](https://personal.github.com/krtirtho/spotube/issues/1059) ([a8e9b82](https://personal.github.com/krtirtho/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)) +- **android:** download failing for permission issues [#1015](https://personal.github.com/krtirtho/spotube/issues/1015) ([5509cae](https://personal.github.com/krtirtho/spotube/commit/5509cae91c8b1f5cb9fac179060f477397a4a27f)) +- artist page error [#1018](https://personal.github.com/krtirtho/spotube/issues/1018) ([8cd650b](https://personal.github.com/krtirtho/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)) +- 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)) +- 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)) +- **linux:** crash after login ([0dfd401](https://personal.github.com/krtirtho/spotube/commit/0dfd40153714b7a4b83ac30f0c56830bc0c05ffd)) +- **macos:** backbutton and window button overlap and unused empty space on home ([b9417ca](https://personal.github.com/krtirtho/spotube/commit/b9417ca3575992673357230dab49e0124dd576b1)) +- **macos:** download folder unchangeable ([9d74cf5](https://personal.github.com/krtirtho/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)) +- **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)) +- 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)) +- 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)) +- 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)) +- track index not showing after 200 ([a752cf4](https://personal.github.com/krtirtho/spotube/commit/a752cf4c978d1b05851aabb6c84c7862de551320)) +- track pad horizontal scrolling not working ([59e0e6b](https://personal.github.com/krtirtho/spotube/commit/59e0e6bb659b70831f6e0ae064100381c57f149c)) ## [3.4.0](https://github.com/KRTirtho/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)) -* **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) -* 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)) -* improve loading animations ([b92583d](https://github.com/KRTirtho/spotube/commit/b92583d0df7b8dee0d121cd2bb666b14c77d8c86)) -* toggle for discord rpc ([24a2294](https://github.com/KRTirtho/spotube/commit/24a2294512bb0c4aff77bc8dcad9b4de3e8b45c6)) -* **translations:** add Dutch Language ([#969](https://github.com/KRTirtho/spotube/issues/969)) ([3ad7ba6](https://github.com/KRTirtho/spotube/commit/3ad7ba66b56e93e69d2181d47029b7549ed225fc)) - +- Add Go to Album option in track option [#917](https://github.com/KRTirtho/spotube/issues/917) ([b0beeca](https://github.com/KRTirtho/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) +- 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)) +- improve loading animations ([b92583d](https://github.com/KRTirtho/spotube/commit/b92583d0df7b8dee0d121cd2bb666b14c77d8c86)) +- toggle for discord rpc ([24a2294](https://github.com/KRTirtho/spotube/commit/24a2294512bb0c4aff77bc8dcad9b4de3e8b45c6)) +- **translations:** add Dutch Language ([#969](https://github.com/KRTirtho/spotube/issues/969)) ([3ad7ba6](https://github.com/KRTirtho/spotube/commit/3ad7ba66b56e93e69d2181d47029b7549ed225fc)) ### Bug Fixes -* add safe area in home ([9ee6067](https://github.com/KRTirtho/spotube/commit/9ee60677f6d50df7468e12dc6653ecedefa2494f)) -* amoled mode and color scheme can't be changed ([840e014](https://github.com/KRTirtho/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)) -* genre border issues ([2fb16e6](https://github.com/KRTirtho/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)) -* 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)) -* Playlist refresh not working [#915](https://github.com/KRTirtho/spotube/issues/915) ([5f1df5a](https://github.com/KRTirtho/spotube/commit/5f1df5a87d8fb7980b52cf57b7b6bedea57a1269)) -* track view header title overflow and player view drag glitch ([b04d884](https://github.com/KRTirtho/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)) +- add safe area in home ([9ee6067](https://github.com/KRTirtho/spotube/commit/9ee60677f6d50df7468e12dc6653ecedefa2494f)) +- amoled mode and color scheme can't be changed ([840e014](https://github.com/KRTirtho/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)) +- genre border issues ([2fb16e6](https://github.com/KRTirtho/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)) +- 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)) +- Playlist refresh not working [#915](https://github.com/KRTirtho/spotube/issues/915) ([5f1df5a](https://github.com/KRTirtho/spotube/commit/5f1df5a87d8fb7980b52cf57b7b6bedea57a1269)) +- track view header title overflow and player view drag glitch ([b04d884](https://github.com/KRTirtho/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)) ## [3.3.0](https://github.com/KRTirtho/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)) -* **android:** better quick scroll/drag to scroll implementation ([2e2c44f](https://github.com/KRTirtho/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)) -* **mini_player:** show/hide lyrics [#851](https://github.com/KRTirtho/spotube/issues/851) ([dcbb156](https://github.com/KRTirtho/spotube/commit/dcbb1568337969841acc0abe0e7185ee5e4c3590)) -* paginated playlist and album page ([28a5d6b](https://github.com/KRTirtho/spotube/commit/28a5d6bb3820ab0bd4007664f73d685f6e1d2c90)) -* **translations:** add Turkish translations ([0c22469](https://github.com/KRTirtho/spotube/commit/0c22469503f32dbbf1a5d31419c1b76c699fa966)) - +- Add JioSaavn as audio source ([#881](https://github.com/KRTirtho/spotube/issues/881)) ([14069cd](https://github.com/KRTirtho/spotube/commit/14069cd4fe08597c8d9aa0810270fb4c386c1d55)) +- **android:** better quick scroll/drag to scroll implementation ([2e2c44f](https://github.com/KRTirtho/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)) +- **mini_player:** show/hide lyrics [#851](https://github.com/KRTirtho/spotube/issues/851) ([dcbb156](https://github.com/KRTirtho/spotube/commit/dcbb1568337969841acc0abe0e7185ee5e4c3590)) +- paginated playlist and album page ([28a5d6b](https://github.com/KRTirtho/spotube/commit/28a5d6bb3820ab0bd4007664f73d685f6e1d2c90)) +- **translations:** add Turkish translations ([0c22469](https://github.com/KRTirtho/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)) -* 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)) -* Add to Playlist Dialog memory leak [#817](https://github.com/KRTirtho/spotube/issues/817) ([fed36ec](https://github.com/KRTirtho/spotube/commit/fed36ecdd81e8a0f8358693eff0a6233dea32e5d)) -* **album_card:** show loading state during adding track to queue/play ([5633367](https://github.com/KRTirtho/spotube/commit/5633367397812148f6d712d06e97a4f84033f968)) -* alternative track source safearea overflow [#876](https://github.com/KRTirtho/spotube/issues/876) ([7b72a90](https://github.com/KRTirtho/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)) -* changed settings are not persisting after force stop [#821](https://github.com/KRTirtho/spotube/issues/821) ([e29a38d](https://github.com/KRTirtho/spotube/commit/e29a38dfa43ddf7a38046d1d40424f01dbe62261)) -* check for unsynced lyrics and error handling for timed lyrics query ([1d77556](https://github.com/KRTirtho/spotube/commit/1d77556157d158600f29cf2ea5f26c567607dec7)) -* **genres:** lag while scrolling ([dc980b0](https://github.com/KRTirtho/spotube/commit/dc980b024edad3132e72cbb2f0087297a4b76469)) -* infinite list disappearing for a moment everytime new page is fetched ([1334a62](https://github.com/KRTirtho/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)) -* Navigating to settings, redirects to home page [#812](https://github.com/KRTirtho/spotube/issues/812) ([da04f06](https://github.com/KRTirtho/spotube/commit/da04f068f9b7effff8d50cb5714d93ea80c22b7f)) -* new releases section flickering on scroll glitch ([ee94b7c](https://github.com/KRTirtho/spotube/commit/ee94b7cbb24e0f0bc22a6d49c830d4055aa02895)) -* **playbutton_card:** annoying animation ([574406d](https://github.com/KRTirtho/spotube/commit/574406dd5fc410914b27e7fce374323696845012)) -* scrobbling not working for first track or single track ([0a6b54d](https://github.com/KRTirtho/spotube/commit/0a6b54da367345b73fe6e954f1d9368d9f9ead71)) -* settings page scrollbar position ([ee82290](https://github.com/KRTirtho/spotube/commit/ee8229020b3b03fc074b316db4b322af13b807bd)) -* shuffle doesn't move active track to top ([4956bf3](https://github.com/KRTirtho/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)) -* trim login field padding ([286ef83](https://github.com/KRTirtho/spotube/commit/286ef83e8ec516db70019398d9e3e724437a4172)) -* use CustomScrollView for personalized page ([7d05c40](https://github.com/KRTirtho/spotube/commit/7d05c40dc0d04208b059f2483c1e4de199c8b51d)) -* user_playlists layout, track tile index, ([487c2ed](https://github.com/KRTirtho/spotube/commit/487c2ed6bdc4af33006ba52532eb4eaaa261dceb)) -* **windows:** media control not working [#641](https://github.com/KRTirtho/spotube/issues/641) ([7818574](https://github.com/KRTirtho/spotube/commit/7818574356d0fb8ff567e1f6a83fd0b6f2ee7c8a)) +- "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)) +- 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)) +- Add to Playlist Dialog memory leak [#817](https://github.com/KRTirtho/spotube/issues/817) ([fed36ec](https://github.com/KRTirtho/spotube/commit/fed36ecdd81e8a0f8358693eff0a6233dea32e5d)) +- **album_card:** show loading state during adding track to queue/play ([5633367](https://github.com/KRTirtho/spotube/commit/5633367397812148f6d712d06e97a4f84033f968)) +- alternative track source safearea overflow [#876](https://github.com/KRTirtho/spotube/issues/876) ([7b72a90](https://github.com/KRTirtho/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)) +- changed settings are not persisting after force stop [#821](https://github.com/KRTirtho/spotube/issues/821) ([e29a38d](https://github.com/KRTirtho/spotube/commit/e29a38dfa43ddf7a38046d1d40424f01dbe62261)) +- check for unsynced lyrics and error handling for timed lyrics query ([1d77556](https://github.com/KRTirtho/spotube/commit/1d77556157d158600f29cf2ea5f26c567607dec7)) +- **genres:** lag while scrolling ([dc980b0](https://github.com/KRTirtho/spotube/commit/dc980b024edad3132e72cbb2f0087297a4b76469)) +- infinite list disappearing for a moment everytime new page is fetched ([1334a62](https://github.com/KRTirtho/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)) +- Navigating to settings, redirects to home page [#812](https://github.com/KRTirtho/spotube/issues/812) ([da04f06](https://github.com/KRTirtho/spotube/commit/da04f068f9b7effff8d50cb5714d93ea80c22b7f)) +- new releases section flickering on scroll glitch ([ee94b7c](https://github.com/KRTirtho/spotube/commit/ee94b7cbb24e0f0bc22a6d49c830d4055aa02895)) +- **playbutton_card:** annoying animation ([574406d](https://github.com/KRTirtho/spotube/commit/574406dd5fc410914b27e7fce374323696845012)) +- scrobbling not working for first track or single track ([0a6b54d](https://github.com/KRTirtho/spotube/commit/0a6b54da367345b73fe6e954f1d9368d9f9ead71)) +- settings page scrollbar position ([ee82290](https://github.com/KRTirtho/spotube/commit/ee8229020b3b03fc074b316db4b322af13b807bd)) +- shuffle doesn't move active track to top ([4956bf3](https://github.com/KRTirtho/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)) +- trim login field padding ([286ef83](https://github.com/KRTirtho/spotube/commit/286ef83e8ec516db70019398d9e3e724437a4172)) +- use CustomScrollView for personalized page ([7d05c40](https://github.com/KRTirtho/spotube/commit/7d05c40dc0d04208b059f2483c1e4de199c8b51d)) +- user_playlists layout, track tile index, ([487c2ed](https://github.com/KRTirtho/spotube/commit/487c2ed6bdc4af33006ba52532eb4eaaa261dceb)) +- **windows:** media control not working [#641](https://github.com/KRTirtho/spotube/issues/641) ([7818574](https://github.com/KRTirtho/spotube/commit/7818574356d0fb8ff567e1f6a83fd0b6f2ee7c8a)) ## [3.2.0](https://github.com/KRTirtho/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)) -* add restore default settings button ([94c3866](https://github.com/KRTirtho/spotube/commit/94c386638f2e5a42d21c8f157835443333ee6d5c)) -* configurable audio normalization switch ([c325911](https://github.com/KRTirtho/spotube/commit/c325911c0d87758a203a52df02179c1513bad3fd)) -* customizable stream/download file formats ([#757](https://github.com/KRTirtho/spotube/issues/757)) ([e54762b](https://github.com/KRTirtho/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)) -* LastFM scrobbling support ([#761](https://github.com/KRTirtho/spotube/issues/761)) ([f5bd907](https://github.com/KRTirtho/spotube/commit/f5bd90731d9abc19d684c8bcb231eff399e73023)) -* loading indicator for genre and personalized pages ([ffe8d9c](https://github.com/KRTirtho/spotube/commit/ffe8d9ca6da25cb3e6fd2c781d5ed3a7b919510e)) -* manual offline detection ([854ab89](https://github.com/KRTirtho/spotube/commit/854ab8910dffb2837c011d3439173a1f0ebe9c6c)) -* show error dialog on failed to login ([101c325](https://github.com/KRTirtho/spotube/commit/101c32523d3be8c05527261f6f63f939d388ad79)) -* sliding up player support ([083319f](https://github.com/KRTirtho/spotube/commit/083319fd2445ab179e3dcda0a6aeaca6f13dda29)) -* swipe to open player view ([#765](https://github.com/KRTirtho/spotube/issues/765)) ([9aee056](https://github.com/KRTirtho/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)) -* **translations:** add Arabic Translations ([#740](https://github.com/KRTirtho/spotube/issues/740)) ([38493f9](https://github.com/KRTirtho/spotube/commit/38493f9dd75303890857a626c0b276ee1ab75bb2)) -* **translations:** add Farsi Translations ([#760](https://github.com/KRTirtho/spotube/issues/760)) ([fe42cfe](https://github.com/KRTirtho/spotube/commit/fe42cfe8430035d9b67dd158fb7b835ee4071497)) - +- 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)) +- add restore default settings button ([94c3866](https://github.com/KRTirtho/spotube/commit/94c386638f2e5a42d21c8f157835443333ee6d5c)) +- configurable audio normalization switch ([c325911](https://github.com/KRTirtho/spotube/commit/c325911c0d87758a203a52df02179c1513bad3fd)) +- customizable stream/download file formats ([#757](https://github.com/KRTirtho/spotube/issues/757)) ([e54762b](https://github.com/KRTirtho/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)) +- LastFM scrobbling support ([#761](https://github.com/KRTirtho/spotube/issues/761)) ([f5bd907](https://github.com/KRTirtho/spotube/commit/f5bd90731d9abc19d684c8bcb231eff399e73023)) +- loading indicator for genre and personalized pages ([ffe8d9c](https://github.com/KRTirtho/spotube/commit/ffe8d9ca6da25cb3e6fd2c781d5ed3a7b919510e)) +- manual offline detection ([854ab89](https://github.com/KRTirtho/spotube/commit/854ab8910dffb2837c011d3439173a1f0ebe9c6c)) +- show error dialog on failed to login ([101c325](https://github.com/KRTirtho/spotube/commit/101c32523d3be8c05527261f6f63f939d388ad79)) +- sliding up player support ([083319f](https://github.com/KRTirtho/spotube/commit/083319fd2445ab179e3dcda0a6aeaca6f13dda29)) +- swipe to open player view ([#765](https://github.com/KRTirtho/spotube/issues/765)) ([9aee056](https://github.com/KRTirtho/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)) +- **translations:** add Arabic Translations ([#740](https://github.com/KRTirtho/spotube/issues/740)) ([38493f9](https://github.com/KRTirtho/spotube/commit/38493f9dd75303890857a626c0b276ee1ab75bb2)) +- **translations:** add Farsi Translations ([#760](https://github.com/KRTirtho/spotube/issues/760)) ([fe42cfe](https://github.com/KRTirtho/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)) -* add xdg-user-dirs as deps ([f3e331e](https://github.com/KRTirtho/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)) -* **android:** audio doesn't resume on interruption end ([15d466a](https://github.com/KRTirtho/spotube/commit/15d466a04538ec70c3a0c132f2baaaf8690f8d4e)) -* **android:** system navigator back doesn't close player ([20d7092](https://github.com/KRTirtho/spotube/commit/20d70927c909347e84ffa8e456f8fab88d49d179)) -* get rid of overflow errors & status bar dark color ([5bb8231](https://github.com/KRTirtho/spotube/commit/5bb8231782287faf75c778fadb3a03ac774d14f0)) -* keyboard shortcuts changing route but not update sidebar ([2d93441](https://github.com/KRTirtho/spotube/commit/2d934411887bd104d8265236df5bf595c5ad2278)) -* last track repeats ([ed6ca00](https://github.com/KRTirtho/spotube/commit/ed6ca006ce237ed8d509cde9ed47cd6ea3396b63)) -* minor glitches ([e5d0aaf](https://github.com/KRTirtho/spotube/commit/e5d0aaf80d22b2291b6f7e7c5e18dd99ae1a7a82)) -* not fetching all followed artists ([#759](https://github.com/KRTirtho/spotube/issues/759)) ([c09a572](https://github.com/KRTirtho/spotube/commit/c09a5729251d8df820442d55477455f78c19c52e)) -* use audio_service_mpris plugin ([e29cc25](https://github.com/KRTirtho/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)) +- add libmpv1 for ubuntu-based systems ([#739](https://github.com/KRTirtho/spotube/issues/739)) ([5115e04](https://github.com/KRTirtho/spotube/commit/5115e041e78c20fce798a80f1d844bfc60746958)) +- add xdg-user-dirs as deps ([f3e331e](https://github.com/KRTirtho/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)) +- **android:** audio doesn't resume on interruption end ([15d466a](https://github.com/KRTirtho/spotube/commit/15d466a04538ec70c3a0c132f2baaaf8690f8d4e)) +- **android:** system navigator back doesn't close player ([20d7092](https://github.com/KRTirtho/spotube/commit/20d70927c909347e84ffa8e456f8fab88d49d179)) +- get rid of overflow errors & status bar dark color ([5bb8231](https://github.com/KRTirtho/spotube/commit/5bb8231782287faf75c778fadb3a03ac774d14f0)) +- keyboard shortcuts changing route but not update sidebar ([2d93441](https://github.com/KRTirtho/spotube/commit/2d934411887bd104d8265236df5bf595c5ad2278)) +- last track repeats ([ed6ca00](https://github.com/KRTirtho/spotube/commit/ed6ca006ce237ed8d509cde9ed47cd6ea3396b63)) +- minor glitches ([e5d0aaf](https://github.com/KRTirtho/spotube/commit/e5d0aaf80d22b2291b6f7e7c5e18dd99ae1a7a82)) +- not fetching all followed artists ([#759](https://github.com/KRTirtho/spotube/issues/759)) ([c09a572](https://github.com/KRTirtho/spotube/commit/c09a5729251d8df820442d55477455f78c19c52e)) +- use audio_service_mpris plugin ([e29cc25](https://github.com/KRTirtho/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)) ## [3.1.2](https://github.com/KRTirtho/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)) -* right click to open track option ([1540999](https://github.com/KRTirtho/spotube/commit/1540999f50d7ba78d9706d73127483b98d800d86)) -* search loading animation ([b9d5c70](https://github.com/KRTirtho/spotube/commit/b9d5c70301dd33ec26332e5e9a456ce5bfe73da0)) -* show loading indicator on play track ([d12ea48](https://github.com/KRTirtho/spotube/commit/d12ea48b97596205d6309012d561ce83e5cbc9c1)) - +- **player_queue:** filtering track support ([d4f99ec](https://github.com/KRTirtho/spotube/commit/d4f99ec89927ea78f070707509ff3222ec402942)) +- right click to open track option ([1540999](https://github.com/KRTirtho/spotube/commit/1540999f50d7ba78d9706d73127483b98d800d86)) +- search loading animation ([b9d5c70](https://github.com/KRTirtho/spotube/commit/b9d5c70301dd33ec26332e5e9a456ce5bfe73da0)) +- show loading indicator on play track ([d12ea48](https://github.com/KRTirtho/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)) -* 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)) -* liked tracks card play not working ([d3e1cef](https://github.com/KRTirtho/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)) -* rewind breaks track progress bar ([#695](https://github.com/KRTirtho/spotube/issues/695)) ([e321743](https://github.com/KRTirtho/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)) +- add missing dependency in debian package ([#704](https://github.com/KRTirtho/spotube/issues/704)) ([c987ea7](https://github.com/KRTirtho/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)) +- liked tracks card play not working ([d3e1cef](https://github.com/KRTirtho/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)) +- rewind breaks track progress bar ([#695](https://github.com/KRTirtho/spotube/issues/695)) ([e321743](https://github.com/KRTirtho/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)) ## [3.1.1](https://github.com/KRTirtho/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)) -* jump to specific time on lyric click ([#590](https://github.com/KRTirtho/spotube/issues/590)) ([a14fb9e](https://github.com/KRTirtho/spotube/commit/a14fb9ec389822e5ffa0c537e162b87cbba34e6c)) -* paginated user albums ([d239d64](https://github.com/KRTirtho/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) -* **translations:** added Portuguese (Brazil) translation ([#634](https://github.com/KRTirtho/spotube/issues/634)) ([76f30a0](https://github.com/KRTirtho/spotube/commit/76f30a0f20f2b09680d27525cde3d1c9617fad5a)) - +- 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)) +- jump to specific time on lyric click ([#590](https://github.com/KRTirtho/spotube/issues/590)) ([a14fb9e](https://github.com/KRTirtho/spotube/commit/a14fb9ec389822e5ffa0c537e162b87cbba34e6c)) +- paginated user albums ([d239d64](https://github.com/KRTirtho/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) +- **translations:** added Portuguese (Brazil) translation ([#634](https://github.com/KRTirtho/spotube/issues/634)) ([76f30a0](https://github.com/KRTirtho/spotube/commit/76f30a0f20f2b09680d27525cde3d1c9617fad5a)) ### Bug Fixes -* always fetching SponsorBlock if no segments found & download failing ([6ced0a0](https://github.com/KRTirtho/spotube/commit/6ced0a0fad06f9f431636ca0fe5dae83eafe33ce)) -* debian bookworm invalid dependencies ([633415d](https://github.com/KRTirtho/spotube/commit/633415dd3e702a38c5a7e7d7b3b1c2713d9c9cc9)) -* disable android auto for playstore version :"( ([0f0d240](https://github.com/KRTirtho/spotube/commit/0f0d240c04d77db6f7c127d59ba8b331d5534469)) -* infinite route push glitch ([e90eceb](https://github.com/KRTirtho/spotube/commit/e90eceb285a84028df690c25a687ff9b5168bba8)) -* jump to track going to wrong track ([190df17](https://github.com/KRTirtho/spotube/commit/190df17adcf4c01cb2bcebfdec47908828b33816)) -* last track of queue never plays & repeat playlist never works ([c3c09f5](https://github.com/KRTirtho/spotube/commit/c3c09f5b76c9547a306d15cd3768dacc1622876d)) -* lyrics page text contrast ([179d536](https://github.com/KRTirtho/spotube/commit/179d536ccc10a5e63f11a63680a6e61c2d1314c8)) -* replace connectivity_plus with internet_connection_checker ([f23e871](https://github.com/KRTirtho/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)) -* sorting by date crashes app ([#551](https://github.com/KRTirtho/spotube/issues/551)) ([48e90a4](https://github.com/KRTirtho/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)) +- always fetching SponsorBlock if no segments found & download failing ([6ced0a0](https://github.com/KRTirtho/spotube/commit/6ced0a0fad06f9f431636ca0fe5dae83eafe33ce)) +- debian bookworm invalid dependencies ([633415d](https://github.com/KRTirtho/spotube/commit/633415dd3e702a38c5a7e7d7b3b1c2713d9c9cc9)) +- disable android auto for playstore version :"( ([0f0d240](https://github.com/KRTirtho/spotube/commit/0f0d240c04d77db6f7c127d59ba8b331d5534469)) +- infinite route push glitch ([e90eceb](https://github.com/KRTirtho/spotube/commit/e90eceb285a84028df690c25a687ff9b5168bba8)) +- jump to track going to wrong track ([190df17](https://github.com/KRTirtho/spotube/commit/190df17adcf4c01cb2bcebfdec47908828b33816)) +- last track of queue never plays & repeat playlist never works ([c3c09f5](https://github.com/KRTirtho/spotube/commit/c3c09f5b76c9547a306d15cd3768dacc1622876d)) +- lyrics page text contrast ([179d536](https://github.com/KRTirtho/spotube/commit/179d536ccc10a5e63f11a63680a6e61c2d1314c8)) +- replace connectivity_plus with internet_connection_checker ([f23e871](https://github.com/KRTirtho/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)) +- sorting by date crashes app ([#551](https://github.com/KRTirtho/spotube/issues/551)) ([48e90a4](https://github.com/KRTirtho/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)) ## [3.1.0](https://github.com/KRTirtho/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)) -* better track matching on youtube API ([904a0d3](https://github.com/KRTirtho/spotube/commit/904a0d3e15a8e4a76d0842e978809d2838439f86)) -* blazingly™ fast download manager ([#619](https://github.com/KRTirtho/spotube/issues/619)) ([38dc4be](https://github.com/KRTirtho/spotube/commit/38dc4beb44827a20044afd120d7c32f097938660)) -* paginated user playlists ([e7c6813](https://github.com/KRTirtho/spotube/commit/e7c6813ccb2afcda9a8b044570a1c0a27785a59f)) -* show error dialog on piped API 500 error ([c69f81e](https://github.com/KRTirtho/spotube/commit/c69f81ec6f01f0f67ad06446e890aa1351516626)) -* **translation:** add catalan translations ([#621](https://github.com/KRTirtho/spotube/issues/621)) ([c94e5ba](https://github.com/KRTirtho/spotube/commit/c94e5ba4301ed0cb760daff56c56d2701b35131f)) -* **translations:** add polish translation ([#631](https://github.com/KRTirtho/spotube/issues/631)) ([f90e9be](https://github.com/KRTirtho/spotube/commit/f90e9bee3104a3812c2f775dd16cabbb56f668cb)) -* web compatibility ([cf7b849](https://github.com/KRTirtho/spotube/commit/cf7b849cddca3260d9c3a6a064418b0ba2d63270)) - +- add android auto media session control support ([0f5748a](https://github.com/KRTirtho/spotube/commit/0f5748a24b4b5a32862f7b2f28151e2d42bcce33)) +- better track matching on youtube API ([904a0d3](https://github.com/KRTirtho/spotube/commit/904a0d3e15a8e4a76d0842e978809d2838439f86)) +- blazingly™ fast download manager ([#619](https://github.com/KRTirtho/spotube/issues/619)) ([38dc4be](https://github.com/KRTirtho/spotube/commit/38dc4beb44827a20044afd120d7c32f097938660)) +- paginated user playlists ([e7c6813](https://github.com/KRTirtho/spotube/commit/e7c6813ccb2afcda9a8b044570a1c0a27785a59f)) +- show error dialog on piped API 500 error ([c69f81e](https://github.com/KRTirtho/spotube/commit/c69f81ec6f01f0f67ad06446e890aa1351516626)) +- **translation:** add catalan translations ([#621](https://github.com/KRTirtho/spotube/issues/621)) ([c94e5ba](https://github.com/KRTirtho/spotube/commit/c94e5ba4301ed0cb760daff56c56d2701b35131f)) +- **translations:** add polish translation ([#631](https://github.com/KRTirtho/spotube/issues/631)) ([f90e9be](https://github.com/KRTirtho/spotube/commit/f90e9bee3104a3812c2f775dd16cabbb56f668cb)) +- web compatibility ([cf7b849](https://github.com/KRTirtho/spotube/commit/cf7b849cddca3260d9c3a6a064418b0ba2d63270)) ### Bug Fixes -* always showing play in playlist/album views ([8521cc5](https://github.com/KRTirtho/spotube/commit/8521cc5c88730caa9db74da6c04b679bf29ed56d)) -* **android:** android 13 local tracks not showing up ([e3f4344](https://github.com/KRTirtho/spotube/commit/e3f4344ae9c1ec93860d4c5d1b8de1a803b29569)) -* default to youtube API by default ([5a8a1e4](https://github.com/KRTirtho/spotube/commit/5a8a1e41e93fb74756b8c88f6325b8b46d7af131)) -* generate playlist page max width ([4adf695](https://github.com/KRTirtho/spotube/commit/4adf6951d9ee78ac4b198d541dada28dc00ca0cb)) -* tracks doesn't change when ended ([aa4ac86](https://github.com/KRTirtho/spotube/commit/aa4ac8641a7dceb4626ab675ba376b24a3480d30)) -* windows media controls not working ([ae5edd1](https://github.com/KRTirtho/spotube/commit/ae5edd17ef24f2a38ec1cc9c9623868b6ee9e352)) +- always showing play in playlist/album views ([8521cc5](https://github.com/KRTirtho/spotube/commit/8521cc5c88730caa9db74da6c04b679bf29ed56d)) +- **android:** android 13 local tracks not showing up ([e3f4344](https://github.com/KRTirtho/spotube/commit/e3f4344ae9c1ec93860d4c5d1b8de1a803b29569)) +- default to youtube API by default ([5a8a1e4](https://github.com/KRTirtho/spotube/commit/5a8a1e41e93fb74756b8c88f6325b8b46d7af131)) +- generate playlist page max width ([4adf695](https://github.com/KRTirtho/spotube/commit/4adf6951d9ee78ac4b198d541dada28dc00ca0cb)) +- tracks doesn't change when ended ([aa4ac86](https://github.com/KRTirtho/spotube/commit/aa4ac8641a7dceb4626ab675ba376b24a3480d30)) +- windows media controls not working ([ae5edd1](https://github.com/KRTirtho/spotube/commit/ae5edd17ef24f2a38ec1cc9c9623868b6ee9e352)) ## [3.0.1](https://github.com/KRTirtho/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)) -* **translations:** add spanish translations ([#585](https://github.com/KRTirtho/spotube/issues/585)) ([042d7a4](https://github.com/KRTirtho/spotube/commit/042d7a4a10c78dd93a56a2f32d18a0fb74dbe697)) -* **translations:** add Simplified Chinese translation. ([#556](https://github.com/KRTirtho/spotube/issues/556)) ([26dbd52](https://github.com/KRTirtho/spotube/commit/26dbd523737d868114a47e82acd412cdae622b7c)) - +- Force High Refresh Rate on some Android devices ([#607](https://github.com/KRTirtho/spotube/issues/607)) ([6dff099](https://github.com/KRTirtho/spotube/commit/6dff0996bdfee603acf242b1316f8793d625267c)) +- **translations:** add spanish translations ([#585](https://github.com/KRTirtho/spotube/issues/585)) ([042d7a4](https://github.com/KRTirtho/spotube/commit/042d7a4a10c78dd93a56a2f32d18a0fb74dbe697)) +- **translations:** add Simplified Chinese translation. ([#556](https://github.com/KRTirtho/spotube/issues/556)) ([26dbd52](https://github.com/KRTirtho/spotube/commit/26dbd523737d868114a47e82acd412cdae622b7c)) ### Bug Fixes -* alternative track source textfield safe area ([b8c6d7e](https://github.com/KRTirtho/spotube/commit/b8c6d7eb6ae1c54bdc83a455850dfca0f27bd881)) -* avoid sponsor block for first few seconds to not break the stream ([d8cf2ae](https://github.com/KRTirtho/spotube/commit/d8cf2ae1315dc3848fe1ac12286faafe90fdbed7)) -* cache segments casting error ([dfd60bd](https://github.com/KRTirtho/spotube/commit/dfd60bd4cc0fe8fe90e0cbfd26331df505cde2aa)) -* duration is always zero in PlayerView ([4885dca](https://github.com/KRTirtho/spotube/commit/4885dca04f06658391d1063e6c5a009547391a6f)) -* flags not showing up and html in descriptions ([5a563ef](https://github.com/KRTirtho/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)) -* login dialog stays after login, mention sp_gaid in tutorial ([b492840](https://github.com/KRTirtho/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)) -* 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)) -* track not skipping to next even when source is available ([0b7affd](https://github.com/KRTirtho/spotube/commit/0b7affdc058c028982266d5c93215697301846bd)) +- alternative track source textfield safe area ([b8c6d7e](https://github.com/KRTirtho/spotube/commit/b8c6d7eb6ae1c54bdc83a455850dfca0f27bd881)) +- avoid sponsor block for first few seconds to not break the stream ([d8cf2ae](https://github.com/KRTirtho/spotube/commit/d8cf2ae1315dc3848fe1ac12286faafe90fdbed7)) +- cache segments casting error ([dfd60bd](https://github.com/KRTirtho/spotube/commit/dfd60bd4cc0fe8fe90e0cbfd26331df505cde2aa)) +- duration is always zero in PlayerView ([4885dca](https://github.com/KRTirtho/spotube/commit/4885dca04f06658391d1063e6c5a009547391a6f)) +- flags not showing up and html in descriptions ([5a563ef](https://github.com/KRTirtho/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)) +- login dialog stays after login, mention sp_gaid in tutorial ([b492840](https://github.com/KRTirtho/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)) +- 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)) +- track not skipping to next even when source is available ([0b7affd](https://github.com/KRTirtho/spotube/commit/0b7affdc058c028982266d5c93215697301846bd)) ## [3.0.0](https://github.com/KRTirtho/spotube/compare/v2.7.1...v3.0.0) (2023-07-02) - ### Features -* adaptive controllers ([c8b7de0](https://github.com/KRTirtho/spotube/commit/c8b7de087917ec3037c015d5b55693cb3dbdecca)) -* adaptive popup and bottom sheet list widget ([ddc1c5f](https://github.com/KRTirtho/spotube/commit/ddc1c5f373a4d72cc231c35dd70c3d577b84f7f5)) -* add generated to playlist(s) ([c91d8c8](https://github.com/KRTirtho/spotube/commit/c91d8c8efa8b526c64881fa829992b8c250e7c89)) -* add german locale ([ba3f428](https://github.com/KRTirtho/spotube/commit/ba3f4281f1a6bc7ddf38775e9d40dad863ed3692)) -* add piped search mode ([17a25a5](https://github.com/KRTirtho/spotube/commit/17a25a501e0d5e2512d8de0921fd602ea906d30f)) -* add sleep timer support ([4a75f3d](https://github.com/KRTirtho/spotube/commit/4a75f3dbd1e7e6f68899de001df70e809533f142)) -* adjust lyric page blurriness and player playbutton ([54d5907](https://github.com/KRTirtho/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)) -* **android:** add splash screen ([c232fcc](https://github.com/KRTirtho/spotube/commit/c232fcc6dd1479ed33a8baa9887de2702a8ea22e)) -* **android:** disable battery optimization for better playback ([fe5b429](https://github.com/KRTirtho/spotube/commit/fe5b429ddacc576fc9fdb5e66718782cda163b27)) -* artist card redesign ([92a418c](https://github.com/KRTirtho/spotube/commit/92a418c8a8a9df99e27407b628e5e3cc9ccb4115)) -* Better download manager with download progress ([6752adc](https://github.com/KRTirtho/spotube/commit/6752adc9398818f51b69fced226b4b8410fb9e9b)) -* better language picker, adaptive select tile and settings section contrast ([6430a25](https://github.com/KRTirtho/spotube/commit/6430a2587075aa24483ab26ce6f0f6b2b630e139)) -* cache encryption for sensitive data ([b110d83](https://github.com/KRTirtho/spotube/commit/b110d834561ac53129ac9ec80238c014c84832ec)) -* color scheme picker dialog vertical list view instead of wrap ([bb60b01](https://github.com/KRTirtho/spotube/commit/bb60b01ef2f2ba3da8f6fe3a19add168b2ee8a4e)) -* compact and adaptive playbutton card design ([eeb8cab](https://github.com/KRTirtho/spotube/commit/eeb8cabf491d5242bd434b3c71c39363f24bdcf9)) -* compact button tabbar ([67380f6](https://github.com/KRTirtho/spotube/commit/67380f68765f18e4dcd3d60117083c7e9c6761c2)) -* create a basic installer script ([1763a36](https://github.com/KRTirtho/spotube/commit/1763a36a262178306df61d4588c17ff795a32790)) -* curved navigation bar ([776edf8](https://github.com/KRTirtho/spotube/commit/776edf84afcf99f96cf6e337b0c84ed89034ca8e)) -* custom error toast ([96f04c1](https://github.com/KRTirtho/spotube/commit/96f04c17565c0ab7f115d5c1f167f6660a69480d)) -* custom playlist generator ([f4b0d13](https://github.com/KRTirtho/spotube/commit/f4b0d134ca724b75bf65b885bce4dba206f1e090)) -* desktop mini player support ([471812d](https://github.com/KRTirtho/spotube/commit/471812d789eb2c861268ab8451d50104ac2fbe2e)) -* **desktop:** close button for minimize notification ([1688f99](https://github.com/KRTirtho/spotube/commit/1688f99096af940ead65e67832c6f061a6f635ac)) -* **desktop:** show minimized to system tray notification ([296f96c](https://github.com/KRTirtho/spotube/commit/296f96cf17cf21ff09b406cc24952ff60da52d5e)) -* disable/enable smtc on demand ([7fa50e5](https://github.com/KRTirtho/spotube/commit/7fa50e5c5ee9ca3ba95dca55f8a4831047f17570)) -* download button on each track ([925fa86](https://github.com/KRTirtho/spotube/commit/925fa86271fa10ff77b8137ba8d09b8067d0e819)) -* enable caching of queue ([ec11af5](https://github.com/KRTirtho/spotube/commit/ec11af53a16c435fbea3d0c81910dca371be9ce7)) -* heart button animation ([8432dc6](https://github.com/KRTirtho/spotube/commit/8432dc6286fbdfda52bbeb39c6d4ababa05881bc)) -* improved track item API and UI ([617aa89](https://github.com/KRTirtho/spotube/commit/617aa89409ce29eb3c197aee5c1189d763a3913c)) -* **installer:** get latest version from Github API ([957c085](https://github.com/KRTirtho/spotube/commit/957c085e1243ec0af5bc45d38691c74e2ba91ad8)) -* **local_tracks:** delete local track ([#484](https://github.com/KRTirtho/spotube/issues/484)) ([52835b2](https://github.com/KRTirtho/spotube/commit/52835b2ce2212925f80e1a1595c0ca30e6860a8d)) -* locale category/genre title ([88137f0](https://github.com/KRTirtho/spotube/commit/88137f01b27150b306327a01e67ec8a35a60e82e)) -* **locale:** add bengali translations for search page ([a1cdbad](https://github.com/KRTirtho/spotube/commit/a1cdbad18782a74b43f0625facfe3e35c516bf43)) -* **locale:** localize search, library, lyrics, artist with both Bengali and English ([11fe9ec](https://github.com/KRTirtho/spotube/commit/11fe9ec74462441b67a0ab0df73824f36dd15e2d)) -* **locale:** player, playlist view, track tile bengali and english translations ([c55133d](https://github.com/KRTirtho/spotube/commit/c55133dc8bba307823e5b67a30cfab03c923cb7f)) -* localize settings, about, login, player queue with Bengali and English translations ([a5c36bb](https://github.com/KRTirtho/spotube/commit/a5c36bbb20cc69d609bfb5ab973c7e288c1ea9de)) -* logs page in settings ([b78e7f5](https://github.com/KRTirtho/spotube/commit/b78e7f57a05db344aae59206cbb0f43b3ee199a9)) -* macos title bar spacing and lyrics page margin separate ([a0b3771](https://github.com/KRTirtho/spotube/commit/a0b377104f9822561d3b46dbc6551bb561842480)) -* make snackbar floating ([9dbb817](https://github.com/KRTirtho/spotube/commit/9dbb8171a6d6b81120ca7ccd74577e5c890ff930)) -* merge floating player with nav bar and nav bar translucent bg ([a90261e](https://github.com/KRTirtho/spotube/commit/a90261ed199f6cff9e8d0fe24934f8f1d8e9ed98)) -* **mini_player:** remove window shadow ([6259014](https://github.com/KRTirtho/spotube/commit/625901482ada4b441b838f640c0ab7167119b321)) -* **mini_player:** show/hide UI on hover toggle ([2e8b647](https://github.com/KRTirtho/spotube/commit/2e8b647a51f87840c2bd39f0a1dc25ddc91528fc)) -* new sidebar widget and translucent bottom player ([4ba1e70](https://github.com/KRTirtho/spotube/commit/4ba1e70636b4ba43697663128fc5422b1d0b2a2f)) -* newly released albums of user followed artist ([33cb794](https://github.com/KRTirtho/spotube/commit/33cb7947d63d0a2692a004f87a2ccd5777bf054e)) -* optimize image load + genre page and reduce page size of loaded categories ([7131efa](https://github.com/KRTirtho/spotube/commit/7131efa07fdbcf17965fc59ff635a6198b0e5e25)) -* persistent volume percentage ([3724bd5](https://github.com/KRTirtho/spotube/commit/3724bd5a10eef7a099d6f596fd038e6fea228359)) -* personal playlist recommendations ([ae820a2](https://github.com/KRTirtho/spotube/commit/ae820a22f291082c49554d621c25cc62212a6708)) -* piped instance picker on settings ([bed0d3b](https://github.com/KRTirtho/spotube/commit/bed0d3bd70438df413633ee03fd258a2ca4a1688)) -* platform specific title bar buttons ([6267720](https://github.com/KRTirtho/spotube/commit/62677209a23172162defb7a8e542d981569eba08)) -* **playback:** integrate android, ios, macos with JustAudio ([d487fe5](https://github.com/KRTirtho/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)) -* player details dialog and separate location of lyrics button in player page ([ce38233](https://github.com/KRTirtho/spotube/commit/ce38233de8f4775018a1d01e951b1635776fe743)) -* **player:** add playlist related methods to audio player ([f1080e1](https://github.com/KRTirtho/spotube/commit/f1080e1675aee1208d05658adfabfbed04ff45b6)) -* **player:** animated gradient background ([49b5d0e](https://github.com/KRTirtho/spotube/commit/49b5d0e6948d80abb8ee09203e0d655d68377245)) -* **player:** custom playlist implementation for media_kit to replace unpredictable playlist of mpv ([eaf65b6](https://github.com/KRTirtho/spotube/commit/eaf65b6db208aaad745821d4d42afc05f51cee7c)) -* **player:** proper coloring of elements ([b2c4ea1](https://github.com/KRTirtho/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)) -* playlist create support for generated playlist ([91c72f9](https://github.com/KRTirtho/spotube/commit/91c72f9ec9556f301c5d129fc82e19e791a02fbe)) -* playlist generation all parameters support ([9877d5f](https://github.com/KRTirtho/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)) -* **queue:** add track(s) for playing next ([#460](https://github.com/KRTirtho/spotube/issues/460)) ([cac8ea6](https://github.com/KRTirtho/spotube/commit/cac8ea638812f5d9cb4305144b6351141a2cf407)) -* **queue:** reorder tracks support ([441b43b](https://github.com/KRTirtho/spotube/commit/441b43bef6b92fd7df6c4e1bef39d67b4a76cd22)) -* re-designed playlist/album page ([0cedc7a](https://github.com/KRTirtho/spotube/commit/0cedc7a4187771efce8152003f890e242116c78c)) -* re-introduce youtube API along with piped ([b54ee96](https://github.com/KRTirtho/spotube/commit/b54ee96233b29d7517eba66e3f8dd9270c2790df)) -* reactive volume slider and slicker bottom bar with lowered height ([9d14517](https://github.com/KRTirtho/spotube/commit/9d14517202d5c9d993a947808bf0c6520ed54ea3)) -* remove SponsorBlock in favor of YT Music and remove pocketbase backend track support ([fb780da](https://github.com/KRTirtho/spotube/commit/fb780da327a213d7a82cbc3b567ece858dc2f0e8)) -* repeat button all 3 mode and disable player controls when track is fetching ([1418378](https://github.com/KRTirtho/spotube/commit/14183781dd3f1e16c121e78ad637a326de7b5dcf)) -* replace YouTube API with piped API ([1ecc36d](https://github.com/KRTirtho/spotube/commit/1ecc36da57af61fd9c2ca928589088cd4325f605)) -* responsive playlist generate page and scrollable multi autocomplete ([d57aad5](https://github.com/KRTirtho/spotube/commit/d57aad5612f7622dcd638ea8c0ec4d96f741de2b)) -* search alternative track source ([dfea195](https://github.com/KRTirtho/spotube/commit/dfea195ec178de733717cfe3226cede7521ee2d3)) -* setup localization (l10n) and language switcher, add sidebar and navbar locale ([f12d812](https://github.com/KRTirtho/spotube/commit/f12d81259f9e7005e681a7ca9867291d9228a8b1)) -* show album release year ([#387](https://github.com/KRTirtho/spotube/issues/387)) and fix layout of artist's album ([6a6ddf6](https://github.com/KRTirtho/spotube/commit/6a6ddf6e1f6dc72b794cae49adf8348da272babd)) -* show country code piped instance list ([60328a6](https://github.com/KRTirtho/spotube/commit/60328a6bafcbff1b7d0ee5099825f0e3d545b60f)) -* show loading when track metadata is being fetched, android, ios, macos enable shuffling ([bf59570](https://github.com/KRTirtho/spotube/commit/bf59570251720a80efe0aa6be481899864da5079)) -* sort tracks by newest and oldest dates ([b4713e3](https://github.com/KRTirtho/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)) -* track populate sibling support ([3aeb026](https://github.com/KRTirtho/spotube/commit/3aeb026776716b6e2eb89c8406a4996a86c7ca60)) -* **translation:** add hindi and french translations using ChatGPT ([6d836bd](https://github.com/KRTirtho/spotube/commit/6d836bdb658c180ca8e2c71e7e290fafa3520727)) -* **translation:** add Japanase locale ([4b52a71](https://github.com/KRTirtho/spotube/commit/4b52a71c0914bda6c831d8f637a5934f7bcf8fcb)) -* use system color scheme ([862c4b8](https://github.com/KRTirtho/spotube/commit/862c4b8faf2c751d803e373e29981a116bf08ed5)) -* volume slider in player page ([7abe2c1](https://github.com/KRTirtho/spotube/commit/7abe2c10735bc38c644487139557a731d25e80e6)) -* windows OS media control panel support ([f0b426a](https://github.com/KRTirtho/spotube/commit/f0b426ae89f2e01f4a9c8757ef4e0b4a21b50c7b)) - +- adaptive controllers ([c8b7de0](https://github.com/KRTirtho/spotube/commit/c8b7de087917ec3037c015d5b55693cb3dbdecca)) +- adaptive popup and bottom sheet list widget ([ddc1c5f](https://github.com/KRTirtho/spotube/commit/ddc1c5f373a4d72cc231c35dd70c3d577b84f7f5)) +- add generated to playlist(s) ([c91d8c8](https://github.com/KRTirtho/spotube/commit/c91d8c8efa8b526c64881fa829992b8c250e7c89)) +- add german locale ([ba3f428](https://github.com/KRTirtho/spotube/commit/ba3f4281f1a6bc7ddf38775e9d40dad863ed3692)) +- add piped search mode ([17a25a5](https://github.com/KRTirtho/spotube/commit/17a25a501e0d5e2512d8de0921fd602ea906d30f)) +- add sleep timer support ([4a75f3d](https://github.com/KRTirtho/spotube/commit/4a75f3dbd1e7e6f68899de001df70e809533f142)) +- adjust lyric page blurriness and player playbutton ([54d5907](https://github.com/KRTirtho/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)) +- **android:** add splash screen ([c232fcc](https://github.com/KRTirtho/spotube/commit/c232fcc6dd1479ed33a8baa9887de2702a8ea22e)) +- **android:** disable battery optimization for better playback ([fe5b429](https://github.com/KRTirtho/spotube/commit/fe5b429ddacc576fc9fdb5e66718782cda163b27)) +- artist card redesign ([92a418c](https://github.com/KRTirtho/spotube/commit/92a418c8a8a9df99e27407b628e5e3cc9ccb4115)) +- Better download manager with download progress ([6752adc](https://github.com/KRTirtho/spotube/commit/6752adc9398818f51b69fced226b4b8410fb9e9b)) +- better language picker, adaptive select tile and settings section contrast ([6430a25](https://github.com/KRTirtho/spotube/commit/6430a2587075aa24483ab26ce6f0f6b2b630e139)) +- cache encryption for sensitive data ([b110d83](https://github.com/KRTirtho/spotube/commit/b110d834561ac53129ac9ec80238c014c84832ec)) +- color scheme picker dialog vertical list view instead of wrap ([bb60b01](https://github.com/KRTirtho/spotube/commit/bb60b01ef2f2ba3da8f6fe3a19add168b2ee8a4e)) +- compact and adaptive playbutton card design ([eeb8cab](https://github.com/KRTirtho/spotube/commit/eeb8cabf491d5242bd434b3c71c39363f24bdcf9)) +- compact button tabbar ([67380f6](https://github.com/KRTirtho/spotube/commit/67380f68765f18e4dcd3d60117083c7e9c6761c2)) +- create a basic installer script ([1763a36](https://github.com/KRTirtho/spotube/commit/1763a36a262178306df61d4588c17ff795a32790)) +- curved navigation bar ([776edf8](https://github.com/KRTirtho/spotube/commit/776edf84afcf99f96cf6e337b0c84ed89034ca8e)) +- custom error toast ([96f04c1](https://github.com/KRTirtho/spotube/commit/96f04c17565c0ab7f115d5c1f167f6660a69480d)) +- custom playlist generator ([f4b0d13](https://github.com/KRTirtho/spotube/commit/f4b0d134ca724b75bf65b885bce4dba206f1e090)) +- desktop mini player support ([471812d](https://github.com/KRTirtho/spotube/commit/471812d789eb2c861268ab8451d50104ac2fbe2e)) +- **desktop:** close button for minimize notification ([1688f99](https://github.com/KRTirtho/spotube/commit/1688f99096af940ead65e67832c6f061a6f635ac)) +- **desktop:** show minimized to system tray notification ([296f96c](https://github.com/KRTirtho/spotube/commit/296f96cf17cf21ff09b406cc24952ff60da52d5e)) +- disable/enable smtc on demand ([7fa50e5](https://github.com/KRTirtho/spotube/commit/7fa50e5c5ee9ca3ba95dca55f8a4831047f17570)) +- download button on each track ([925fa86](https://github.com/KRTirtho/spotube/commit/925fa86271fa10ff77b8137ba8d09b8067d0e819)) +- enable caching of queue ([ec11af5](https://github.com/KRTirtho/spotube/commit/ec11af53a16c435fbea3d0c81910dca371be9ce7)) +- heart button animation ([8432dc6](https://github.com/KRTirtho/spotube/commit/8432dc6286fbdfda52bbeb39c6d4ababa05881bc)) +- improved track item API and UI ([617aa89](https://github.com/KRTirtho/spotube/commit/617aa89409ce29eb3c197aee5c1189d763a3913c)) +- **installer:** get latest version from Github API ([957c085](https://github.com/KRTirtho/spotube/commit/957c085e1243ec0af5bc45d38691c74e2ba91ad8)) +- **local_tracks:** delete local track ([#484](https://github.com/KRTirtho/spotube/issues/484)) ([52835b2](https://github.com/KRTirtho/spotube/commit/52835b2ce2212925f80e1a1595c0ca30e6860a8d)) +- locale category/genre title ([88137f0](https://github.com/KRTirtho/spotube/commit/88137f01b27150b306327a01e67ec8a35a60e82e)) +- **locale:** add bengali translations for search page ([a1cdbad](https://github.com/KRTirtho/spotube/commit/a1cdbad18782a74b43f0625facfe3e35c516bf43)) +- **locale:** localize search, library, lyrics, artist with both Bengali and English ([11fe9ec](https://github.com/KRTirtho/spotube/commit/11fe9ec74462441b67a0ab0df73824f36dd15e2d)) +- **locale:** player, playlist view, track tile bengali and english translations ([c55133d](https://github.com/KRTirtho/spotube/commit/c55133dc8bba307823e5b67a30cfab03c923cb7f)) +- localize settings, about, login, player queue with Bengali and English translations ([a5c36bb](https://github.com/KRTirtho/spotube/commit/a5c36bbb20cc69d609bfb5ab973c7e288c1ea9de)) +- logs page in settings ([b78e7f5](https://github.com/KRTirtho/spotube/commit/b78e7f57a05db344aae59206cbb0f43b3ee199a9)) +- macos title bar spacing and lyrics page margin separate ([a0b3771](https://github.com/KRTirtho/spotube/commit/a0b377104f9822561d3b46dbc6551bb561842480)) +- make snackbar floating ([9dbb817](https://github.com/KRTirtho/spotube/commit/9dbb8171a6d6b81120ca7ccd74577e5c890ff930)) +- merge floating player with nav bar and nav bar translucent bg ([a90261e](https://github.com/KRTirtho/spotube/commit/a90261ed199f6cff9e8d0fe24934f8f1d8e9ed98)) +- **mini_player:** remove window shadow ([6259014](https://github.com/KRTirtho/spotube/commit/625901482ada4b441b838f640c0ab7167119b321)) +- **mini_player:** show/hide UI on hover toggle ([2e8b647](https://github.com/KRTirtho/spotube/commit/2e8b647a51f87840c2bd39f0a1dc25ddc91528fc)) +- new sidebar widget and translucent bottom player ([4ba1e70](https://github.com/KRTirtho/spotube/commit/4ba1e70636b4ba43697663128fc5422b1d0b2a2f)) +- newly released albums of user followed artist ([33cb794](https://github.com/KRTirtho/spotube/commit/33cb7947d63d0a2692a004f87a2ccd5777bf054e)) +- optimize image load + genre page and reduce page size of loaded categories ([7131efa](https://github.com/KRTirtho/spotube/commit/7131efa07fdbcf17965fc59ff635a6198b0e5e25)) +- persistent volume percentage ([3724bd5](https://github.com/KRTirtho/spotube/commit/3724bd5a10eef7a099d6f596fd038e6fea228359)) +- personal playlist recommendations ([ae820a2](https://github.com/KRTirtho/spotube/commit/ae820a22f291082c49554d621c25cc62212a6708)) +- piped instance picker on settings ([bed0d3b](https://github.com/KRTirtho/spotube/commit/bed0d3bd70438df413633ee03fd258a2ca4a1688)) +- platform specific title bar buttons ([6267720](https://github.com/KRTirtho/spotube/commit/62677209a23172162defb7a8e542d981569eba08)) +- **playback:** integrate android, ios, macos with JustAudio ([d487fe5](https://github.com/KRTirtho/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)) +- player details dialog and separate location of lyrics button in player page ([ce38233](https://github.com/KRTirtho/spotube/commit/ce38233de8f4775018a1d01e951b1635776fe743)) +- **player:** add playlist related methods to audio player ([f1080e1](https://github.com/KRTirtho/spotube/commit/f1080e1675aee1208d05658adfabfbed04ff45b6)) +- **player:** animated gradient background ([49b5d0e](https://github.com/KRTirtho/spotube/commit/49b5d0e6948d80abb8ee09203e0d655d68377245)) +- **player:** custom playlist implementation for media_kit to replace unpredictable playlist of mpv ([eaf65b6](https://github.com/KRTirtho/spotube/commit/eaf65b6db208aaad745821d4d42afc05f51cee7c)) +- **player:** proper coloring of elements ([b2c4ea1](https://github.com/KRTirtho/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)) +- playlist create support for generated playlist ([91c72f9](https://github.com/KRTirtho/spotube/commit/91c72f9ec9556f301c5d129fc82e19e791a02fbe)) +- playlist generation all parameters support ([9877d5f](https://github.com/KRTirtho/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)) +- **queue:** add track(s) for playing next ([#460](https://github.com/KRTirtho/spotube/issues/460)) ([cac8ea6](https://github.com/KRTirtho/spotube/commit/cac8ea638812f5d9cb4305144b6351141a2cf407)) +- **queue:** reorder tracks support ([441b43b](https://github.com/KRTirtho/spotube/commit/441b43bef6b92fd7df6c4e1bef39d67b4a76cd22)) +- re-designed playlist/album page ([0cedc7a](https://github.com/KRTirtho/spotube/commit/0cedc7a4187771efce8152003f890e242116c78c)) +- re-introduce youtube API along with piped ([b54ee96](https://github.com/KRTirtho/spotube/commit/b54ee96233b29d7517eba66e3f8dd9270c2790df)) +- reactive volume slider and slicker bottom bar with lowered height ([9d14517](https://github.com/KRTirtho/spotube/commit/9d14517202d5c9d993a947808bf0c6520ed54ea3)) +- remove SponsorBlock in favor of YT Music and remove pocketbase backend track support ([fb780da](https://github.com/KRTirtho/spotube/commit/fb780da327a213d7a82cbc3b567ece858dc2f0e8)) +- repeat button all 3 mode and disable player controls when track is fetching ([1418378](https://github.com/KRTirtho/spotube/commit/14183781dd3f1e16c121e78ad637a326de7b5dcf)) +- replace YouTube API with piped API ([1ecc36d](https://github.com/KRTirtho/spotube/commit/1ecc36da57af61fd9c2ca928589088cd4325f605)) +- responsive playlist generate page and scrollable multi autocomplete ([d57aad5](https://github.com/KRTirtho/spotube/commit/d57aad5612f7622dcd638ea8c0ec4d96f741de2b)) +- search alternative track source ([dfea195](https://github.com/KRTirtho/spotube/commit/dfea195ec178de733717cfe3226cede7521ee2d3)) +- setup localization (l10n) and language switcher, add sidebar and navbar locale ([f12d812](https://github.com/KRTirtho/spotube/commit/f12d81259f9e7005e681a7ca9867291d9228a8b1)) +- show album release year ([#387](https://github.com/KRTirtho/spotube/issues/387)) and fix layout of artist's album ([6a6ddf6](https://github.com/KRTirtho/spotube/commit/6a6ddf6e1f6dc72b794cae49adf8348da272babd)) +- show country code piped instance list ([60328a6](https://github.com/KRTirtho/spotube/commit/60328a6bafcbff1b7d0ee5099825f0e3d545b60f)) +- show loading when track metadata is being fetched, android, ios, macos enable shuffling ([bf59570](https://github.com/KRTirtho/spotube/commit/bf59570251720a80efe0aa6be481899864da5079)) +- sort tracks by newest and oldest dates ([b4713e3](https://github.com/KRTirtho/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)) +- track populate sibling support ([3aeb026](https://github.com/KRTirtho/spotube/commit/3aeb026776716b6e2eb89c8406a4996a86c7ca60)) +- **translation:** add hindi and french translations using ChatGPT ([6d836bd](https://github.com/KRTirtho/spotube/commit/6d836bdb658c180ca8e2c71e7e290fafa3520727)) +- **translation:** add Japanase locale ([4b52a71](https://github.com/KRTirtho/spotube/commit/4b52a71c0914bda6c831d8f637a5934f7bcf8fcb)) +- use system color scheme ([862c4b8](https://github.com/KRTirtho/spotube/commit/862c4b8faf2c751d803e373e29981a116bf08ed5)) +- volume slider in player page ([7abe2c1](https://github.com/KRTirtho/spotube/commit/7abe2c10735bc38c644487139557a731d25e80e6)) +- windows OS media control panel support ([f0b426a](https://github.com/KRTirtho/spotube/commit/f0b426ae89f2e01f4a9c8757ef4e0b4a21b50c7b)) ### Bug Fixes -* add to playlist dialog not showing playlist name ([8944581](https://github.com/KRTirtho/spotube/commit/8944581c09eec0162220e7ff684205484fafb599)) -* album sync not working ([74906f3](https://github.com/KRTirtho/spotube/commit/74906f393250934c36530a73ad7312f59f8627ed)) -* alternative track source not playing new source ([a9b5a71](https://github.com/KRTirtho/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)) -* **artist:** follower count shows as float when < 1000 ([#482](https://github.com/KRTirtho/spotube/issues/482)) ([fd1846e](https://github.com/KRTirtho/spotube/commit/fd1846eecf9632e59e4b70fb70e97c556b6374f5)) -* bottom navbar first item icon color not changing on primary color change ([6eb4244](https://github.com/KRTirtho/spotube/commit/6eb4244f3244a96fe6858261534cc03eb3de803c)) -* cached currently playing track infinite loading ([9401718](https://github.com/KRTirtho/spotube/commit/94017189c6b9bf55ec62cbf29cd6b0e9fffca42a)) -* cached queue tracks expired stream ([ed29ab5](https://github.com/KRTirtho/spotube/commit/ed29ab5137416d9fb2e7e9fe840f56ef52df6f61)) -* collection currently playing state persist on restart ([1c89e3e](https://github.com/KRTirtho/spotube/commit/1c89e3efb0f05c648fc1c8e09039e62333de18d1)) -* color not syncing and add new screenshot ([6205501](https://github.com/KRTirtho/spotube/commit/62055018feade0b895663a0bfc5f85f265ae2154)) -* content going below bottom player or nav bar ([1bdce9f](https://github.com/KRTirtho/spotube/commit/1bdce9fe964de88a667bb160846c11dc70b77c00)) -* disable background_downloader due to android build failures ([7d23bee](https://github.com/KRTirtho/spotube/commit/7d23beec5ef07c4d649185a69e7a2b9697dc6953)) -* disable play when loading track and buffering event ([30c933c](https://github.com/KRTirtho/spotube/commit/30c933cdf3d4524be164e171094afdd27b0252b7)) -* error log ([e3d8239](https://github.com/KRTirtho/spotube/commit/e3d8239b9f5700bfb17c4758d95ba1db1f0e718a)) -* excessive repaints caused by Player progress bar ([09b24cf](https://github.com/KRTirtho/spotube/commit/09b24cf1fd1644c549f85904545db54b39cc2431)) -* failed download no error icon ([1266a3f](https://github.com/KRTirtho/spotube/commit/1266a3f1607de11e793a294071850996527d494a)) -* **home:** bottom player transparency ([20c424c](https://github.com/KRTirtho/spotube/commit/20c424c77fe273a693213ebf88d50e4025bc8608)) -* language changer not working ([7b7b1f2](https://github.com/KRTirtho/spotube/commit/7b7b1f2647591b7cd4cc7841526716c6a2877e55)) -* less frequent position updates ([0a49b56](https://github.com/KRTirtho/spotube/commit/0a49b56566abd00cf7703e4207cfa90f93c381fd)) -* linux mpris not showing up and overall media notification service ([1abcad1](https://github.com/KRTirtho/spotube/commit/1abcad1de510c209a34196f2de17045af4dd3bc2)) -* local tracks getting fetched on first load ([73c012c](https://github.com/KRTirtho/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)) -* lyrics page blur in player and cut off text when line too big ([6b4584e](https://github.com/KRTirtho/spotube/commit/6b4584e91bd4f4aee0c56e48a7aec7015c7c418b)) -* macos build by removing media_kit native event loop ([62fc773](https://github.com/KRTirtho/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)) -* **macos,ios:** use regular shared prefs ([1b5bfec](https://github.com/KRTirtho/spotube/commit/1b5bfec27fbcfe9faabff64d46296bdeebe00161)) -* memoize child of animated widget and make player bg animation faster ([fcb5c8f](https://github.com/KRTirtho/spotube/commit/fcb5c8f8dabd0d4e3033f80ea3e5d006243cdfb5)) -* mini player not working in release mode ([28ff321](https://github.com/KRTirtho/spotube/commit/28ff3216efee81184798eedfbb10ba66395bbf36)) -* **mkPlayer:** remove method and wrong active index on modifying playlist ([3bafa7b](https://github.com/KRTirtho/spotube/commit/3bafa7b80c963fa52b90ed4cb1393fb121cac713)) -* mobile audio notification not working ([8f9303b](https://github.com/KRTirtho/spotube/commit/8f9303bc0fddb9d179303a1f0eb76dd5b02410e7)) -* multiple instance of theme ([4ec0424](https://github.com/KRTirtho/spotube/commit/4ec04240a5bde6af5c920a61ab6260e7a93bfc54)) -* navigation to settings not working ([ce10aa1](https://github.com/KRTirtho/spotube/commit/ce10aa1fe2c95d4738835687f613930cf7829f3a)) -* no progress update when track changed ([6ae8964](https://github.com/KRTirtho/spotube/commit/6ae896441a787fce1bc6e5eb5379856dc2f4e96d)) -* null exception on proxy playlist and audio player ([a455a89](https://github.com/KRTirtho/spotube/commit/a455a89c5861fd455f6950c7b68beae24bdcc6ed)) -* overflowing clickable artists links ([4077fac](https://github.com/KRTirtho/spotube/commit/4077fac39fb667b87e959e53d2dcaceefb63cd2d)) -* personalized playlists not loading ([caa3408](https://github.com/KRTirtho/spotube/commit/caa340803fdf3859fe5a8a996abae1502ef2e4e7)) -* playback not moving to next track after a track ends ([27e8acb](https://github.com/KRTirtho/spotube/commit/27e8acbfe75a37c0a8fa69a444fdd86e92dbe4f0)) -* **player:** gradient bg not taking full height ([62ad86e](https://github.com/KRTirtho/spotube/commit/62ad86e88d74b5114af78138b221314192e5a801)) -* **player:** playback element placement ([5e47faa](https://github.com/KRTirtho/spotube/commit/5e47faa6060d7a8aa0d143060e812dc06b8dd790)) -* **player:** queue button not showing when not logged in ([6c2d655](https://github.com/KRTirtho/spotube/commit/6c2d65587b0e6e167be1d0b086df103c7e72d4b2)) -* **player:** volume slider, prefetching of media_kit and stuttering on sponsorblock skip ([1f32554](https://github.com/KRTirtho/spotube/commit/1f3255481f058c50968561db88172e56b58494f4)) -* playlist generate slider shape ([2b35c04](https://github.com/KRTirtho/spotube/commit/2b35c044adb15a97a58692e7880694a251899732)) -* pop sheet list not scrollable ([cca5625](https://github.com/KRTirtho/spotube/commit/cca5625df7e432da8581a4504306baad154deb48)) -* re-enable add to queue and play next support, favorite button query exceptions ([e529c79](https://github.com/KRTirtho/spotube/commit/e529c79c4f0cd964b7d89e010d3fe51378ea7222)) -* re-enable download manager ([ea45c4f](https://github.com/KRTirtho/spotube/commit/ea45c4f42ae89b8991e470e84a5290b3be3b0f36)) -* remove unnecessary broadcast stream conversions ([bf04962](https://github.com/KRTirtho/spotube/commit/bf04962e90ea5345a2bc0d4793999f7db712cab2)) -* remove useBreakpoints as it clogs up memory with unnecessary state updates ([e1c0f5c](https://github.com/KRTirtho/spotube/commit/e1c0f5cf1e4cece2c4aa235bfbf8511ad7b1fe59)) -* replace download multiple pops and add translations ([4a21249](https://github.com/KRTirtho/spotube/commit/4a21249ee386426b0974451542a93e84f532fb3f)) -* screen breakpoints and persist lyrics delay across screens ([df79638](https://github.com/KRTirtho/spotube/commit/df79638fb622a55aaa2b36c9a1425c2d9c4a8e52)) -* sidebar task counter badge and bottom player play button progress color ([af278d8](https://github.com/KRTirtho/spotube/commit/af278d8feaa08c14528627766bce6d724b846954)) -* status bar color of playlist/album page ([65fa3cb](https://github.com/KRTirtho/spotube/commit/65fa3cb624c240360de5a06778a1f72ad10bbe2d)) -* system color scheme not persisting on restart when system color scheme changed ([e04515d](https://github.com/KRTirtho/spotube/commit/e04515d8e213b4c7f85d11385959a33b042bd9b1)) -* track collection view status bar not transparent ([9251121](https://github.com/KRTirtho/spotube/commit/9251121ba0154599975e33819a43719477c644f8)) -* track doesn't play after change ([17e5ab6](https://github.com/KRTirtho/spotube/commit/17e5ab611cc417cce7c17cafc7045f5aa2eb970e)) -* track stops at last second ([f554f6d](https://github.com/KRTirtho/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)) -* track_table_view table headers ([d88d287](https://github.com/KRTirtho/spotube/commit/d88d287fc586ec33351de9f3b4359f189054868b)) -* track_tile active and blacklist color, playbutton card action positioning ([3f5a1b9](https://github.com/KRTirtho/spotube/commit/3f5a1b9587efe9b7b2c69008345867933f79ec67)) -* use id based source getters instead of index ([a074463](https://github.com/KRTirtho/spotube/commit/a0744630ba2dc713babdb8db6500f9dd0f1e6096)) +- add to playlist dialog not showing playlist name ([8944581](https://github.com/KRTirtho/spotube/commit/8944581c09eec0162220e7ff684205484fafb599)) +- album sync not working ([74906f3](https://github.com/KRTirtho/spotube/commit/74906f393250934c36530a73ad7312f59f8627ed)) +- alternative track source not playing new source ([a9b5a71](https://github.com/KRTirtho/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)) +- **artist:** follower count shows as float when < 1000 ([#482](https://github.com/KRTirtho/spotube/issues/482)) ([fd1846e](https://github.com/KRTirtho/spotube/commit/fd1846eecf9632e59e4b70fb70e97c556b6374f5)) +- bottom navbar first item icon color not changing on primary color change ([6eb4244](https://github.com/KRTirtho/spotube/commit/6eb4244f3244a96fe6858261534cc03eb3de803c)) +- cached currently playing track infinite loading ([9401718](https://github.com/KRTirtho/spotube/commit/94017189c6b9bf55ec62cbf29cd6b0e9fffca42a)) +- cached queue tracks expired stream ([ed29ab5](https://github.com/KRTirtho/spotube/commit/ed29ab5137416d9fb2e7e9fe840f56ef52df6f61)) +- collection currently playing state persist on restart ([1c89e3e](https://github.com/KRTirtho/spotube/commit/1c89e3efb0f05c648fc1c8e09039e62333de18d1)) +- color not syncing and add new screenshot ([6205501](https://github.com/KRTirtho/spotube/commit/62055018feade0b895663a0bfc5f85f265ae2154)) +- content going below bottom player or nav bar ([1bdce9f](https://github.com/KRTirtho/spotube/commit/1bdce9fe964de88a667bb160846c11dc70b77c00)) +- disable background_downloader due to android build failures ([7d23bee](https://github.com/KRTirtho/spotube/commit/7d23beec5ef07c4d649185a69e7a2b9697dc6953)) +- disable play when loading track and buffering event ([30c933c](https://github.com/KRTirtho/spotube/commit/30c933cdf3d4524be164e171094afdd27b0252b7)) +- error log ([e3d8239](https://github.com/KRTirtho/spotube/commit/e3d8239b9f5700bfb17c4758d95ba1db1f0e718a)) +- excessive repaints caused by Player progress bar ([09b24cf](https://github.com/KRTirtho/spotube/commit/09b24cf1fd1644c549f85904545db54b39cc2431)) +- failed download no error icon ([1266a3f](https://github.com/KRTirtho/spotube/commit/1266a3f1607de11e793a294071850996527d494a)) +- **home:** bottom player transparency ([20c424c](https://github.com/KRTirtho/spotube/commit/20c424c77fe273a693213ebf88d50e4025bc8608)) +- language changer not working ([7b7b1f2](https://github.com/KRTirtho/spotube/commit/7b7b1f2647591b7cd4cc7841526716c6a2877e55)) +- less frequent position updates ([0a49b56](https://github.com/KRTirtho/spotube/commit/0a49b56566abd00cf7703e4207cfa90f93c381fd)) +- linux mpris not showing up and overall media notification service ([1abcad1](https://github.com/KRTirtho/spotube/commit/1abcad1de510c209a34196f2de17045af4dd3bc2)) +- local tracks getting fetched on first load ([73c012c](https://github.com/KRTirtho/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)) +- lyrics page blur in player and cut off text when line too big ([6b4584e](https://github.com/KRTirtho/spotube/commit/6b4584e91bd4f4aee0c56e48a7aec7015c7c418b)) +- macos build by removing media_kit native event loop ([62fc773](https://github.com/KRTirtho/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)) +- **macos,ios:** use regular shared prefs ([1b5bfec](https://github.com/KRTirtho/spotube/commit/1b5bfec27fbcfe9faabff64d46296bdeebe00161)) +- memoize child of animated widget and make player bg animation faster ([fcb5c8f](https://github.com/KRTirtho/spotube/commit/fcb5c8f8dabd0d4e3033f80ea3e5d006243cdfb5)) +- mini player not working in release mode ([28ff321](https://github.com/KRTirtho/spotube/commit/28ff3216efee81184798eedfbb10ba66395bbf36)) +- **mkPlayer:** remove method and wrong active index on modifying playlist ([3bafa7b](https://github.com/KRTirtho/spotube/commit/3bafa7b80c963fa52b90ed4cb1393fb121cac713)) +- mobile audio notification not working ([8f9303b](https://github.com/KRTirtho/spotube/commit/8f9303bc0fddb9d179303a1f0eb76dd5b02410e7)) +- multiple instance of theme ([4ec0424](https://github.com/KRTirtho/spotube/commit/4ec04240a5bde6af5c920a61ab6260e7a93bfc54)) +- navigation to settings not working ([ce10aa1](https://github.com/KRTirtho/spotube/commit/ce10aa1fe2c95d4738835687f613930cf7829f3a)) +- no progress update when track changed ([6ae8964](https://github.com/KRTirtho/spotube/commit/6ae896441a787fce1bc6e5eb5379856dc2f4e96d)) +- null exception on proxy playlist and audio player ([a455a89](https://github.com/KRTirtho/spotube/commit/a455a89c5861fd455f6950c7b68beae24bdcc6ed)) +- overflowing clickable artists links ([4077fac](https://github.com/KRTirtho/spotube/commit/4077fac39fb667b87e959e53d2dcaceefb63cd2d)) +- personalized playlists not loading ([caa3408](https://github.com/KRTirtho/spotube/commit/caa340803fdf3859fe5a8a996abae1502ef2e4e7)) +- playback not moving to next track after a track ends ([27e8acb](https://github.com/KRTirtho/spotube/commit/27e8acbfe75a37c0a8fa69a444fdd86e92dbe4f0)) +- **player:** gradient bg not taking full height ([62ad86e](https://github.com/KRTirtho/spotube/commit/62ad86e88d74b5114af78138b221314192e5a801)) +- **player:** playback element placement ([5e47faa](https://github.com/KRTirtho/spotube/commit/5e47faa6060d7a8aa0d143060e812dc06b8dd790)) +- **player:** queue button not showing when not logged in ([6c2d655](https://github.com/KRTirtho/spotube/commit/6c2d65587b0e6e167be1d0b086df103c7e72d4b2)) +- **player:** volume slider, prefetching of media_kit and stuttering on sponsorblock skip ([1f32554](https://github.com/KRTirtho/spotube/commit/1f3255481f058c50968561db88172e56b58494f4)) +- playlist generate slider shape ([2b35c04](https://github.com/KRTirtho/spotube/commit/2b35c044adb15a97a58692e7880694a251899732)) +- pop sheet list not scrollable ([cca5625](https://github.com/KRTirtho/spotube/commit/cca5625df7e432da8581a4504306baad154deb48)) +- re-enable add to queue and play next support, favorite button query exceptions ([e529c79](https://github.com/KRTirtho/spotube/commit/e529c79c4f0cd964b7d89e010d3fe51378ea7222)) +- re-enable download manager ([ea45c4f](https://github.com/KRTirtho/spotube/commit/ea45c4f42ae89b8991e470e84a5290b3be3b0f36)) +- remove unnecessary broadcast stream conversions ([bf04962](https://github.com/KRTirtho/spotube/commit/bf04962e90ea5345a2bc0d4793999f7db712cab2)) +- remove useBreakpoints as it clogs up memory with unnecessary state updates ([e1c0f5c](https://github.com/KRTirtho/spotube/commit/e1c0f5cf1e4cece2c4aa235bfbf8511ad7b1fe59)) +- replace download multiple pops and add translations ([4a21249](https://github.com/KRTirtho/spotube/commit/4a21249ee386426b0974451542a93e84f532fb3f)) +- screen breakpoints and persist lyrics delay across screens ([df79638](https://github.com/KRTirtho/spotube/commit/df79638fb622a55aaa2b36c9a1425c2d9c4a8e52)) +- sidebar task counter badge and bottom player play button progress color ([af278d8](https://github.com/KRTirtho/spotube/commit/af278d8feaa08c14528627766bce6d724b846954)) +- status bar color of playlist/album page ([65fa3cb](https://github.com/KRTirtho/spotube/commit/65fa3cb624c240360de5a06778a1f72ad10bbe2d)) +- system color scheme not persisting on restart when system color scheme changed ([e04515d](https://github.com/KRTirtho/spotube/commit/e04515d8e213b4c7f85d11385959a33b042bd9b1)) +- track collection view status bar not transparent ([9251121](https://github.com/KRTirtho/spotube/commit/9251121ba0154599975e33819a43719477c644f8)) +- track doesn't play after change ([17e5ab6](https://github.com/KRTirtho/spotube/commit/17e5ab611cc417cce7c17cafc7045f5aa2eb970e)) +- track stops at last second ([f554f6d](https://github.com/KRTirtho/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)) +- track_table_view table headers ([d88d287](https://github.com/KRTirtho/spotube/commit/d88d287fc586ec33351de9f3b4359f189054868b)) +- track_tile active and blacklist color, playbutton card action positioning ([3f5a1b9](https://github.com/KRTirtho/spotube/commit/3f5a1b9587efe9b7b2c69008345867933f79ec67)) +- use id based source getters instead of index ([a074463](https://github.com/KRTirtho/spotube/commit/a0744630ba2dc713babdb8db6500f9dd0f1e6096)) ### [2.7.1](https://github.com/KRTirtho/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)) -* **android:** audio notification stuck in play state ([448c9b3](https://github.com/KRTirtho/spotube/commit/448c9b39f407668ad92a695afe3c9741baeca20d)) -* **macos:** crashing on startup ([c46b428](https://github.com/KRTirtho/spotube/commit/c46b4284b1d46a614cbcebc8c2f2e52714921b9b)) -* spotify query hooks overriding default query params ([ec9a02e](https://github.com/KRTirtho/spotube/commit/ec9a02e8b8d988e15ed58027054d2a9090d98873)) +- fallback for lyrics when anonymous ([f160ec7](https://github.com/KRTirtho/spotube/commit/f160ec767d9941d33f83aba1752b28df629d0e10)) +- **android:** audio notification stuck in play state ([448c9b3](https://github.com/KRTirtho/spotube/commit/448c9b39f407668ad92a695afe3c9741baeca20d)) +- **macos:** crashing on startup ([c46b428](https://github.com/KRTirtho/spotube/commit/c46b4284b1d46a614cbcebc8c2f2e52714921b9b)) +- spotify query hooks overriding default query params ([ec9a02e](https://github.com/KRTirtho/spotube/commit/ec9a02e8b8d988e15ed58027054d2a9090d98873)) ## [2.7.0](https://github.com/KRTirtho/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)) -* basic command line argument support ([025c1ae](https://github.com/KRTirtho/spotube/commit/025c1ae20461c2ac9124b3ef41e21ff01f100498)) -* black list artist or track ([947c143](https://github.com/KRTirtho/spotube/commit/947c14353e15227400a6310673f3b850b2ff024f)) -* bring pre download on desktop, disable pre download for long videos ([1d82bb0](https://github.com/KRTirtho/spotube/commit/1d82bb098717c7321d3e338f071c7661987fc3be)) -* category/genre filter ([1dfec05](https://github.com/KRTirtho/spotube/commit/1dfec05eec7ee60cc9f6a3a97af37aef112063f1)) -* centralized icon collection with new icon set and nav bar labels hidden ([e7acb9e](https://github.com/KRTirtho/spotube/commit/e7acb9ed5cb02826b8da559818f1fccfcf7f143c)) -* compact search bar for genres and user_local_tracks page ([c343ccc](https://github.com/KRTirtho/spotube/commit/c343ccc2932868e3c1205d8cc625a9dfe9d78707)) -* compatibility with fl-query nextPage method change ([7617439](https://github.com/KRTirtho/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)) -* failsafe pocketbase requests, removal of unneeded preferences options & vertical playbutton actions ([d68d150](https://github.com/KRTirtho/spotube/commit/d68d150d3f42260f889d86927378c2f746bb6993)) -* **home:** personalized section ([9080441](https://github.com/KRTirtho/spotube/commit/9080441b875ceb91260bbad79291365a98d5be95)) -* individual shuffle and repeat/loop button of player ([f79223c](https://github.com/KRTirtho/spotube/commit/f79223cd41c61d9836d25e7bc2811c6515ba00c8)) -* **lyrics:** use official spotify API for fetching lyrics and add zoom controls ([10d0660](https://github.com/KRTirtho/spotube/commit/10d0660972f008df0d11c280b681ce3b78f05d0b)) -* **mobile:** pull to refresh support in all refreshable list views ([9f959ce](https://github.com/KRTirtho/spotube/commit/9f959ce77cd95cfc34d01af1f5cf53dd4206b6a6)) -* new logo and compact search in playlist/album in mobile ([dc96cb3](https://github.com/KRTirtho/spotube/commit/dc96cb38cea8dc13738083f4850d22792d071019)) -* search/filter tracks inside playlist or album ([a06cd0d](https://github.com/KRTirtho/spotube/commit/a06cd0da84cc03a2a7cadbc80d70556cb0cf9310)) -* show snackbar on adding playlist or tracks to queue ([6bc1d32](https://github.com/KRTirtho/spotube/commit/6bc1d32a88ae516f77d149b83bcd536d2c888513)) -* **theme:** use material3 monet for colors and remove background color preference ([60ede5f](https://github.com/KRTirtho/spotube/commit/60ede5f92b732691d53850290d9667435298a857)) -* use catcher to handle exceptions ([84d94b0](https://github.com/KRTirtho/spotube/commit/84d94b05bc269a1676a261df2b12e508e10e4c0e)) -* use typed assets instead of hard coded paths ([59561ab](https://github.com/KRTirtho/spotube/commit/59561abdc2540576fc95b34b3b55def63567000a)) -* user local tracks searchbar ([e7f3f4e](https://github.com/KRTirtho/spotube/commit/e7f3f4eae49fe27a52fc3866fa4f6f2efb2aa479)) -* **user-library:** filtering support for user albums and user artists ([0b58155](https://github.com/KRTirtho/spotube/commit/0b58155d434f2de6359be77d7beee4484dbb7b2a)) -* **user-library:** search for user playlists ([af4d56f](https://github.com/KRTirtho/spotube/commit/af4d56fd41e57cbe6d87883e87e6b4469aaba52f)) - +- add or remove track, playlist or album to queue support ([b8f3493](https://github.com/KRTirtho/spotube/commit/b8f3493138a9acd91d19efe67cfd1c0c7c269ae6)) +- basic command line argument support ([025c1ae](https://github.com/KRTirtho/spotube/commit/025c1ae20461c2ac9124b3ef41e21ff01f100498)) +- black list artist or track ([947c143](https://github.com/KRTirtho/spotube/commit/947c14353e15227400a6310673f3b850b2ff024f)) +- bring pre download on desktop, disable pre download for long videos ([1d82bb0](https://github.com/KRTirtho/spotube/commit/1d82bb098717c7321d3e338f071c7661987fc3be)) +- category/genre filter ([1dfec05](https://github.com/KRTirtho/spotube/commit/1dfec05eec7ee60cc9f6a3a97af37aef112063f1)) +- centralized icon collection with new icon set and nav bar labels hidden ([e7acb9e](https://github.com/KRTirtho/spotube/commit/e7acb9ed5cb02826b8da559818f1fccfcf7f143c)) +- compact search bar for genres and user_local_tracks page ([c343ccc](https://github.com/KRTirtho/spotube/commit/c343ccc2932868e3c1205d8cc625a9dfe9d78707)) +- compatibility with fl-query nextPage method change ([7617439](https://github.com/KRTirtho/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)) +- failsafe pocketbase requests, removal of unneeded preferences options & vertical playbutton actions ([d68d150](https://github.com/KRTirtho/spotube/commit/d68d150d3f42260f889d86927378c2f746bb6993)) +- **home:** personalized section ([9080441](https://github.com/KRTirtho/spotube/commit/9080441b875ceb91260bbad79291365a98d5be95)) +- individual shuffle and repeat/loop button of player ([f79223c](https://github.com/KRTirtho/spotube/commit/f79223cd41c61d9836d25e7bc2811c6515ba00c8)) +- **lyrics:** use official spotify API for fetching lyrics and add zoom controls ([10d0660](https://github.com/KRTirtho/spotube/commit/10d0660972f008df0d11c280b681ce3b78f05d0b)) +- **mobile:** pull to refresh support in all refreshable list views ([9f959ce](https://github.com/KRTirtho/spotube/commit/9f959ce77cd95cfc34d01af1f5cf53dd4206b6a6)) +- new logo and compact search in playlist/album in mobile ([dc96cb3](https://github.com/KRTirtho/spotube/commit/dc96cb38cea8dc13738083f4850d22792d071019)) +- search/filter tracks inside playlist or album ([a06cd0d](https://github.com/KRTirtho/spotube/commit/a06cd0da84cc03a2a7cadbc80d70556cb0cf9310)) +- show snackbar on adding playlist or tracks to queue ([6bc1d32](https://github.com/KRTirtho/spotube/commit/6bc1d32a88ae516f77d149b83bcd536d2c888513)) +- **theme:** use material3 monet for colors and remove background color preference ([60ede5f](https://github.com/KRTirtho/spotube/commit/60ede5f92b732691d53850290d9667435298a857)) +- use catcher to handle exceptions ([84d94b0](https://github.com/KRTirtho/spotube/commit/84d94b05bc269a1676a261df2b12e508e10e4c0e)) +- use typed assets instead of hard coded paths ([59561ab](https://github.com/KRTirtho/spotube/commit/59561abdc2540576fc95b34b3b55def63567000a)) +- user local tracks searchbar ([e7f3f4e](https://github.com/KRTirtho/spotube/commit/e7f3f4eae49fe27a52fc3866fa4f6f2efb2aa479)) +- **user-library:** filtering support for user albums and user artists ([0b58155](https://github.com/KRTirtho/spotube/commit/0b58155d434f2de6359be77d7beee4484dbb7b2a)) +- **user-library:** search for user playlists ([af4d56f](https://github.com/KRTirtho/spotube/commit/af4d56fd41e57cbe6d87883e87e6b4469aaba52f)) ### Bug Fixes -* **about:** license text hidden in the bottom of smaller screen devices ([e158dd0](https://github.com/KRTirtho/spotube/commit/e158dd0cec5657e495b538e86c412b06974a9f49)) -* **about:** wrong link of License ([a4a7f1a](https://github.com/KRTirtho/spotube/commit/a4a7f1a74f9df82927403ca93aec508a13315ae8)) -* genre and sidebar user logo not loading ([710f172](https://github.com/KRTirtho/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)) -* lyrics not changing on track change ([c809d2d](https://github.com/KRTirtho/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)) -* macOS logo placement ([c6a5d5f](https://github.com/KRTirtho/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)) -* **play_overlay:** show progress indicator on song loading ([7803a48](https://github.com/KRTirtho/spotube/commit/7803a48237c91f2a57bcc86fbd30ad879142c8ff)) -* **playback:** not skipping track's sponsorblock segments ([60a5847](https://github.com/KRTirtho/spotube/commit/60a5847ae68836bbbeef748254c674c81fa5c3ea)) -* playbutton card play state not changing ([ee46d09](https://github.com/KRTirtho/spotube/commit/ee46d0970be9e227793494a41e25c0c469847cd0)) -* **playbutton_card:** play and add to queue needs 2 clicks work ([bdd7098](https://github.com/KRTirtho/spotube/commit/bdd70984e6670813e508786e74cd2ea4a1fe1d53)) -* **playbutton_card:** play and non play state correction ([b327ffb](https://github.com/KRTirtho/spotube/commit/b327ffb1084b43e5c78e13994f65fb30b3a7e67e)) -* **playbutton_card:** title text overflow ([39ee0a9](https://github.com/KRTirtho/spotube/commit/39ee0a92a8f3d74d243db206fe034330f75c0588)) -* **playbutton:** playing state is not updating when playlist is actually playing ([9bad8c9](https://github.com/KRTirtho/spotube/commit/9bad8c9eb88f7c91091a669b642b92474df0f128)) -* **player_queue:** large clear button and macos exception ([0e43504](https://github.com/KRTirtho/spotube/commit/0e43504e18d2315fb1b7975b67bd2c596cbfb1bc)) -* **playlist_queue:** load method not preserving the active track before filtering blacklisted tracks ([42b3e11](https://github.com/KRTirtho/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)) -* search track play button isn't working ([0751f5e](https://github.com/KRTirtho/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)) -* **search:** has to submit twice for search results ([f5dc76a](https://github.com/KRTirtho/spotube/commit/f5dc76a98f55f0f032a6fe4208465899f932355a)) -* titlebar maximize+restore button not working and less responsive title bar buttons ([8a6ba3b](https://github.com/KRTirtho/spotube/commit/8a6ba3b35f0b6b42cf60920e945ac2065c886ecb)) -* **track_collection_view:** hide search bar when sliver is collapsed ([3d6d244](https://github.com/KRTirtho/spotube/commit/3d6d2444beed153a2b6663d6153684b2974f4152)) -* **track_tile:** cannot see track index above 99 ([78b3273](https://github.com/KRTirtho/spotube/commit/78b3273e441cdaa6d4a410ddfe29837dc1aa7000)) -* **track_tile:** track action popup not showing on narrow screens ([0c54f2d](https://github.com/KRTirtho/spotube/commit/0c54f2dcd4474b63db4c517b0e7332cbd3ab51e9)) -* **ui:** scaffold exception in fluent_ui ([8ce2192](https://github.com/KRTirtho/spotube/commit/8ce2192e5cb08e3a8be5ead510ab35b274bef2ef)) -* use chosen market for new release ([c6bf9b6](https://github.com/KRTirtho/spotube/commit/c6bf9b67995161a8bf7c3782188d01e8859c18e9)) +- **about:** license text hidden in the bottom of smaller screen devices ([e158dd0](https://github.com/KRTirtho/spotube/commit/e158dd0cec5657e495b538e86c412b06974a9f49)) +- **about:** wrong link of License ([a4a7f1a](https://github.com/KRTirtho/spotube/commit/a4a7f1a74f9df82927403ca93aec508a13315ae8)) +- genre and sidebar user logo not loading ([710f172](https://github.com/KRTirtho/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)) +- lyrics not changing on track change ([c809d2d](https://github.com/KRTirtho/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)) +- macOS logo placement ([c6a5d5f](https://github.com/KRTirtho/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)) +- **play_overlay:** show progress indicator on song loading ([7803a48](https://github.com/KRTirtho/spotube/commit/7803a48237c91f2a57bcc86fbd30ad879142c8ff)) +- **playback:** not skipping track's sponsorblock segments ([60a5847](https://github.com/KRTirtho/spotube/commit/60a5847ae68836bbbeef748254c674c81fa5c3ea)) +- playbutton card play state not changing ([ee46d09](https://github.com/KRTirtho/spotube/commit/ee46d0970be9e227793494a41e25c0c469847cd0)) +- **playbutton_card:** play and add to queue needs 2 clicks work ([bdd7098](https://github.com/KRTirtho/spotube/commit/bdd70984e6670813e508786e74cd2ea4a1fe1d53)) +- **playbutton_card:** play and non play state correction ([b327ffb](https://github.com/KRTirtho/spotube/commit/b327ffb1084b43e5c78e13994f65fb30b3a7e67e)) +- **playbutton_card:** title text overflow ([39ee0a9](https://github.com/KRTirtho/spotube/commit/39ee0a92a8f3d74d243db206fe034330f75c0588)) +- **playbutton:** playing state is not updating when playlist is actually playing ([9bad8c9](https://github.com/KRTirtho/spotube/commit/9bad8c9eb88f7c91091a669b642b92474df0f128)) +- **player_queue:** large clear button and macos exception ([0e43504](https://github.com/KRTirtho/spotube/commit/0e43504e18d2315fb1b7975b67bd2c596cbfb1bc)) +- **playlist_queue:** load method not preserving the active track before filtering blacklisted tracks ([42b3e11](https://github.com/KRTirtho/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)) +- search track play button isn't working ([0751f5e](https://github.com/KRTirtho/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)) +- **search:** has to submit twice for search results ([f5dc76a](https://github.com/KRTirtho/spotube/commit/f5dc76a98f55f0f032a6fe4208465899f932355a)) +- titlebar maximize+restore button not working and less responsive title bar buttons ([8a6ba3b](https://github.com/KRTirtho/spotube/commit/8a6ba3b35f0b6b42cf60920e945ac2065c886ecb)) +- **track_collection_view:** hide search bar when sliver is collapsed ([3d6d244](https://github.com/KRTirtho/spotube/commit/3d6d2444beed153a2b6663d6153684b2974f4152)) +- **track_tile:** cannot see track index above 99 ([78b3273](https://github.com/KRTirtho/spotube/commit/78b3273e441cdaa6d4a410ddfe29837dc1aa7000)) +- **track_tile:** track action popup not showing on narrow screens ([0c54f2d](https://github.com/KRTirtho/spotube/commit/0c54f2dcd4474b63db4c517b0e7332cbd3ab51e9)) +- **ui:** scaffold exception in fluent_ui ([8ce2192](https://github.com/KRTirtho/spotube/commit/8ce2192e5cb08e3a8be5ead510ab35b274bef2ef)) +- use chosen market for new release ([c6bf9b6](https://github.com/KRTirtho/spotube/commit/c6bf9b67995161a8bf7c3782188d01e8859c18e9)) ## [2.6.0](https://github.com/KRTirtho/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)) -* added shuffle button in playlist and album section ([1fad95f](https://github.com/KRTirtho/spotube/commit/1fad95f6e370606a9faf6f2bb9738dc360e23918)) -* **android-playback:** option to download track bytes and play instead of Streaming ([dcc8ba5](https://github.com/KRTirtho/spotube/commit/dcc8ba5a54286b252d53c9c14918971bf7bea8cc)) -* change default platform option and platform specific back button ([36c5e02](https://github.com/KRTirtho/spotube/commit/36c5e02f18374100f61cc3f2957c27bfc0d8511f)) -* dialog logo for macos, settings more width for country picker ([5e96913](https://github.com/KRTirtho/spotube/commit/5e96913ba34230e7a15d62060d5dae28d80e3630)) -* initial platform_ui integration ([9eee573](https://github.com/KRTirtho/spotube/commit/9eee573ce928aa6c03dcb50bf1521350d2de32cc)) -* libadwaita theming, track tile and PlayButtonCard play button icon fix ([e795e23](https://github.com/KRTirtho/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)) -* new refined about page, update checker only check for same update channel ([4cadfa9](https://github.com/KRTirtho/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)) -* platform bottom navigation bar add ([ff14469](https://github.com/KRTirtho/spotube/commit/ff1446982f0260c6fe231970aa6ed61c273fdf07)) -* platform slider and progress indicator integration ([46b00ba](https://github.com/KRTirtho/spotube/commit/46b00bafdf71a4add61cf50168a32198c3293181)) -* platform title bar buttons add ([54048cb](https://github.com/KRTirtho/spotube/commit/54048cbfc37d9a40c020eb81ab67b9dd5428f0d7)) -* **playback:** change current track youtube source panel and tooltips for player icon buttons ([4b21cc8](https://github.com/KRTirtho/spotube/commit/4b21cc829954fb079d1a0081b9147377063da3ec)) -* Player and Playbutton theme respect to platform ([512446d](https://github.com/KRTirtho/spotube/commit/512446dcab72aa1d7bce18e5a5793f6be8f30fcb)) -* player queue and sibling tracks platform decoration ([39a7794](https://github.com/KRTirtho/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)) -* rename files to snake_case and reorganize folder structure ([7c25e1c](https://github.com/KRTirtho/spotube/commit/7c25e1cc8a35eb8aee2268799c05f299204fa3f5)) -* replace all types of buttons with platform buttons ([69739b4](https://github.com/KRTirtho/spotube/commit/69739b457296a6b209aa6f73beb378ae1f089ac5)) -* rpm packaging support ([067e9ac](https://github.com/KRTirtho/spotube/commit/067e9ac53ee85775c9ec35457fac9e064e72e4c0)) -* **search:** infinite scroll for tracks, artists, playlists and albums ([e6761a6](https://github.com/KRTirtho/spotube/commit/e6761a6f8eadf4ab260723253a8e00121b6365b5)) -* set platform to default platform on start up ([472da6b](https://github.com/KRTirtho/spotube/commit/472da6b8b1c3e06b666da58351f3feafbfe6c98a)) -* shuffle keep playing track at top, linux title bar drag no working ([1223cf2](https://github.com/KRTirtho/spotube/commit/1223cf2629c6615b0c48e9e6742b68341f33c7f8)) -* sidebar download count and proper progress color in playbutton ([a10bc5b](https://github.com/KRTirtho/spotube/commit/a10bc5b8d89207ac86872dd25f3258e47c2141a5)) -* static shimmer for track tile, playbutton card and track tile ([3ed8b0f](https://github.com/KRTirtho/spotube/commit/3ed8b0fda2cb8145a2bc6c8f8c6af82db6a40547)) -* tablet mode navigation bar & windows semi transparent bg, ([3282370](https://github.com/KRTirtho/spotube/commit/3282370f74f323c00116fa8626fd1440ee9d4922)) -* **title_bar:** platform specific title bar ([e659e3c](https://github.com/KRTirtho/spotube/commit/e659e3c56fb02ad8a1c5114bf80074f0324cc4f1)) -* titlebar complete compatibility, platform specific login, library tabbar in titlebar ([b3c27d1](https://github.com/KRTirtho/spotube/commit/b3c27d1fca233ea079803fe49134abc528376df3)) -* use platform checkbox ([2211505](https://github.com/KRTirtho/spotube/commit/2211505d713cef752d07cafb9791a49e8095eee2)) -* window blur effect add ([b0db5e7](https://github.com/KRTirtho/spotube/commit/b0db5e7d8246f98835e7ce6656c8aa7620e46bec)) - +- add selected tracks to playlists, optimistic playlist remove track ([3386dac](https://github.com/KRTirtho/spotube/commit/3386dac78ee49b9e3504f5c05bf1e7b362a2e8a2)) +- added shuffle button in playlist and album section ([1fad95f](https://github.com/KRTirtho/spotube/commit/1fad95f6e370606a9faf6f2bb9738dc360e23918)) +- **android-playback:** option to download track bytes and play instead of Streaming ([dcc8ba5](https://github.com/KRTirtho/spotube/commit/dcc8ba5a54286b252d53c9c14918971bf7bea8cc)) +- change default platform option and platform specific back button ([36c5e02](https://github.com/KRTirtho/spotube/commit/36c5e02f18374100f61cc3f2957c27bfc0d8511f)) +- dialog logo for macos, settings more width for country picker ([5e96913](https://github.com/KRTirtho/spotube/commit/5e96913ba34230e7a15d62060d5dae28d80e3630)) +- initial platform_ui integration ([9eee573](https://github.com/KRTirtho/spotube/commit/9eee573ce928aa6c03dcb50bf1521350d2de32cc)) +- libadwaita theming, track tile and PlayButtonCard play button icon fix ([e795e23](https://github.com/KRTirtho/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)) +- new refined about page, update checker only check for same update channel ([4cadfa9](https://github.com/KRTirtho/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)) +- platform bottom navigation bar add ([ff14469](https://github.com/KRTirtho/spotube/commit/ff1446982f0260c6fe231970aa6ed61c273fdf07)) +- platform slider and progress indicator integration ([46b00ba](https://github.com/KRTirtho/spotube/commit/46b00bafdf71a4add61cf50168a32198c3293181)) +- platform title bar buttons add ([54048cb](https://github.com/KRTirtho/spotube/commit/54048cbfc37d9a40c020eb81ab67b9dd5428f0d7)) +- **playback:** change current track youtube source panel and tooltips for player icon buttons ([4b21cc8](https://github.com/KRTirtho/spotube/commit/4b21cc829954fb079d1a0081b9147377063da3ec)) +- Player and Playbutton theme respect to platform ([512446d](https://github.com/KRTirtho/spotube/commit/512446dcab72aa1d7bce18e5a5793f6be8f30fcb)) +- player queue and sibling tracks platform decoration ([39a7794](https://github.com/KRTirtho/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)) +- rename files to snake_case and reorganize folder structure ([7c25e1c](https://github.com/KRTirtho/spotube/commit/7c25e1cc8a35eb8aee2268799c05f299204fa3f5)) +- replace all types of buttons with platform buttons ([69739b4](https://github.com/KRTirtho/spotube/commit/69739b457296a6b209aa6f73beb378ae1f089ac5)) +- rpm packaging support ([067e9ac](https://github.com/KRTirtho/spotube/commit/067e9ac53ee85775c9ec35457fac9e064e72e4c0)) +- **search:** infinite scroll for tracks, artists, playlists and albums ([e6761a6](https://github.com/KRTirtho/spotube/commit/e6761a6f8eadf4ab260723253a8e00121b6365b5)) +- set platform to default platform on start up ([472da6b](https://github.com/KRTirtho/spotube/commit/472da6b8b1c3e06b666da58351f3feafbfe6c98a)) +- shuffle keep playing track at top, linux title bar drag no working ([1223cf2](https://github.com/KRTirtho/spotube/commit/1223cf2629c6615b0c48e9e6742b68341f33c7f8)) +- sidebar download count and proper progress color in playbutton ([a10bc5b](https://github.com/KRTirtho/spotube/commit/a10bc5b8d89207ac86872dd25f3258e47c2141a5)) +- static shimmer for track tile, playbutton card and track tile ([3ed8b0f](https://github.com/KRTirtho/spotube/commit/3ed8b0fda2cb8145a2bc6c8f8c6af82db6a40547)) +- tablet mode navigation bar & windows semi transparent bg, ([3282370](https://github.com/KRTirtho/spotube/commit/3282370f74f323c00116fa8626fd1440ee9d4922)) +- **title_bar:** platform specific title bar ([e659e3c](https://github.com/KRTirtho/spotube/commit/e659e3c56fb02ad8a1c5114bf80074f0324cc4f1)) +- titlebar complete compatibility, platform specific login, library tabbar in titlebar ([b3c27d1](https://github.com/KRTirtho/spotube/commit/b3c27d1fca233ea079803fe49134abc528376df3)) +- use platform checkbox ([2211505](https://github.com/KRTirtho/spotube/commit/2211505d713cef752d07cafb9791a49e8095eee2)) +- window blur effect add ([b0db5e7](https://github.com/KRTirtho/spotube/commit/b0db5e7d8246f98835e7ce6656c8aa7620e46bec)) ### Bug Fixes -* **ArtistCard:** linux shadow ([c186881](https://github.com/KRTirtho/spotube/commit/c1868817e5abb8a4152646f00a0395933fee7823)) -* **auth:** refresh access token timer not working ([b3ac5ca](https://github.com/KRTirtho/spotube/commit/b3ac5ca3bbb6d5af154f4b5d715d1f19ca2f46e2)) -* bottom navigation bar settings tile not active when selected ([43557e4](https://github.com/KRTirtho/spotube/commit/43557e40df269757c2d5236a455308ea6478d95a)) -* dialog logo in android, lyrics visible timer adjust button ([3c6803b](https://github.com/KRTirtho/spotube/commit/3c6803bb3fac8eee9166764089724194a48509c6)) -* heart button showing when not logged in, wrong login redirect ([4dc26af](https://github.com/KRTirtho/spotube/commit/4dc26af23d12f76cbfdfbf4e37b0c11fcc484d3f)) -* horizontal infinite lists doesn't fill the screen ([69995be](https://github.com/KRTirtho/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)) -* **login:** not working in android in Brazil or Ukraine regions ([0b79a11](https://github.com/KRTirtho/spotube/commit/0b79a1181c37cf06fbfa3bfb3854cfd47097016e)) -* **macos:** black text in dark mode ([fb9c0e4](https://github.com/KRTirtho/spotube/commit/fb9c0e44be93997fc852bf0260e8a8608000c023)) -* **macos:** white text color in dark mode, text field white background ([e086b52](https://github.com/KRTirtho/spotube/commit/e086b520e745e65771136cbfa842ae0693c44872)) -* **mobile:** SafeArea bugs and back button color ([a8330ef](https://github.com/KRTirtho/spotube/commit/a8330ef2e1112012bbae19ee6a5c27a26c5fb719)) -* null exception in themes ([9465d92](https://github.com/KRTirtho/spotube/commit/9465d92fa032b8598a0752767dcec9af2541d222)) -* platform_ui local path ([00d0d38](https://github.com/KRTirtho/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)) -* remove windows background ([6942964](https://github.com/KRTirtho/spotube/commit/694296418787c460bb3fa63ab30f3b0eed9184dc)) -* search field ios dark icon , lyrics tabbar ios background color ([be56ad4](https://github.com/KRTirtho/spotube/commit/be56ad44773ebcd14777d80b61e26875698dc18a)) -* settings Title alignment and play button card ripple effect in other platforms ([3b6bf27](https://github.com/KRTirtho/spotube/commit/3b6bf27a984f5d4836143638396ed4b467c0eae7)) -* shuffle play logic ([65cad07](https://github.com/KRTirtho/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)) -* tooltips of menu and adaptive pop up menu ([261aaf1](https://github.com/KRTirtho/spotube/commit/261aaf191c51bc12b28c602ee160d53d3eacf3a5)) -* update download dialog blocking the UI ([3925f74](https://github.com/KRTirtho/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)) -* **windows:** windows global title bar ([bd18f19](https://github.com/KRTirtho/spotube/commit/bd18f198217538f0089d5a1c4288dd97f982661b)) +- **ArtistCard:** linux shadow ([c186881](https://github.com/KRTirtho/spotube/commit/c1868817e5abb8a4152646f00a0395933fee7823)) +- **auth:** refresh access token timer not working ([b3ac5ca](https://github.com/KRTirtho/spotube/commit/b3ac5ca3bbb6d5af154f4b5d715d1f19ca2f46e2)) +- bottom navigation bar settings tile not active when selected ([43557e4](https://github.com/KRTirtho/spotube/commit/43557e40df269757c2d5236a455308ea6478d95a)) +- dialog logo in android, lyrics visible timer adjust button ([3c6803b](https://github.com/KRTirtho/spotube/commit/3c6803bb3fac8eee9166764089724194a48509c6)) +- heart button showing when not logged in, wrong login redirect ([4dc26af](https://github.com/KRTirtho/spotube/commit/4dc26af23d12f76cbfdfbf4e37b0c11fcc484d3f)) +- horizontal infinite lists doesn't fill the screen ([69995be](https://github.com/KRTirtho/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)) +- **login:** not working in android in Brazil or Ukraine regions ([0b79a11](https://github.com/KRTirtho/spotube/commit/0b79a1181c37cf06fbfa3bfb3854cfd47097016e)) +- **macos:** black text in dark mode ([fb9c0e4](https://github.com/KRTirtho/spotube/commit/fb9c0e44be93997fc852bf0260e8a8608000c023)) +- **macos:** white text color in dark mode, text field white background ([e086b52](https://github.com/KRTirtho/spotube/commit/e086b520e745e65771136cbfa842ae0693c44872)) +- **mobile:** SafeArea bugs and back button color ([a8330ef](https://github.com/KRTirtho/spotube/commit/a8330ef2e1112012bbae19ee6a5c27a26c5fb719)) +- null exception in themes ([9465d92](https://github.com/KRTirtho/spotube/commit/9465d92fa032b8598a0752767dcec9af2541d222)) +- platform_ui local path ([00d0d38](https://github.com/KRTirtho/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)) +- remove windows background ([6942964](https://github.com/KRTirtho/spotube/commit/694296418787c460bb3fa63ab30f3b0eed9184dc)) +- search field ios dark icon , lyrics tabbar ios background color ([be56ad4](https://github.com/KRTirtho/spotube/commit/be56ad44773ebcd14777d80b61e26875698dc18a)) +- settings Title alignment and play button card ripple effect in other platforms ([3b6bf27](https://github.com/KRTirtho/spotube/commit/3b6bf27a984f5d4836143638396ed4b467c0eae7)) +- shuffle play logic ([65cad07](https://github.com/KRTirtho/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)) +- tooltips of menu and adaptive pop up menu ([261aaf1](https://github.com/KRTirtho/spotube/commit/261aaf191c51bc12b28c602ee160d53d3eacf3a5)) +- update download dialog blocking the UI ([3925f74](https://github.com/KRTirtho/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)) +- **windows:** windows global title bar ([bd18f19](https://github.com/KRTirtho/spotube/commit/bd18f198217538f0089d5a1c4288dd97f982661b)) ## [2.5.0](https://github.com/KRTirtho/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)) -* **auth:** new authentication flow using cookies and webview in android ([756b910](https://github.com/KRTirtho/spotube/commit/756b91007ee747c10ed10aa7060af49b555a2eaf)) -* **downloader:** replace /skip all choice for downloaded tracks ([88d7ce5](https://github.com/KRTirtho/spotube/commit/88d7ce55a59f673d60cd9e85ab062bcb1b7dcbc3)) -* implemented go_route shell/nested route ([3e498a4](https://github.com/KRTirtho/spotube/commit/3e498a4827a1118e0b23faec7cf114272f7838d4)) -* **keyboard shortcuts:** play/pause on space, seek position on left/right ([2734454](https://github.com/KRTirtho/spotube/commit/2734454717bbfb5d0621c6ea72fa755ef4fc8602)) -* **keyboard-shortcuts:** home sidebar tab navigation and close app ([8f258e7](https://github.com/KRTirtho/spotube/commit/8f258e709ada418dbeef8d272af370b1741afd9c)) -* smoother list using fl_query and waypoint ([c77b0e1](https://github.com/KRTirtho/spotube/commit/c77b0e198b215180d863747e35998a17aff92720)) -* sort tracks in playlist, album and local tracks ([cb4bd25](https://github.com/KRTirtho/spotube/commit/cb4bd25df154455d225c426cfeaaea36ac09e9b7)) -* use of smaller sized images in `TrackTile` ([0ca97b4](https://github.com/KRTirtho/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)) - +- animated transition of root PageWindowTitleBar ([ff35e06](https://github.com/KRTirtho/spotube/commit/ff35e06a6605fc7ec762e716fb7bdf6f7eb45732)) +- **auth:** new authentication flow using cookies and webview in android ([756b910](https://github.com/KRTirtho/spotube/commit/756b91007ee747c10ed10aa7060af49b555a2eaf)) +- **downloader:** replace /skip all choice for downloaded tracks ([88d7ce5](https://github.com/KRTirtho/spotube/commit/88d7ce55a59f673d60cd9e85ab062bcb1b7dcbc3)) +- implemented go_route shell/nested route ([3e498a4](https://github.com/KRTirtho/spotube/commit/3e498a4827a1118e0b23faec7cf114272f7838d4)) +- **keyboard shortcuts:** play/pause on space, seek position on left/right ([2734454](https://github.com/KRTirtho/spotube/commit/2734454717bbfb5d0621c6ea72fa755ef4fc8602)) +- **keyboard-shortcuts:** home sidebar tab navigation and close app ([8f258e7](https://github.com/KRTirtho/spotube/commit/8f258e709ada418dbeef8d272af370b1741afd9c)) +- smoother list using fl_query and waypoint ([c77b0e1](https://github.com/KRTirtho/spotube/commit/c77b0e198b215180d863747e35998a17aff92720)) +- sort tracks in playlist, album and local tracks ([cb4bd25](https://github.com/KRTirtho/spotube/commit/cb4bd25df154455d225c426cfeaaea36ac09e9b7)) +- use of smaller sized images in `TrackTile` ([0ca97b4](https://github.com/KRTirtho/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)) ### Bug Fixes -* **android:** file_picker and permission_handler failure for sdk < 33 ([139d4dc](https://github.com/KRTirtho/spotube/commit/139d4dc033d9aaa1d6882bf0f53e96a3b1e87c95)) -* cached local track is fetched from network ([abf4a57](https://github.com/KRTirtho/spotube/commit/abf4a5763a2faeedeb93d54e66c1f2482295b326)) -* categories not showing for oauth exception ([4df917e](https://github.com/KRTirtho/spotube/commit/4df917e65ee20cbcf42394cc141b1cdcdd6cc914)) -* **desktop:** maximized window size is stored and window maximized state doesn't persist ([91d5d10](https://github.com/KRTirtho/spotube/commit/91d5d1003b09530ff3bc9a0aa93e382e943977e0)) -* local audio doesn't get refreshed after getting permission ([618c6da](https://github.com/KRTirtho/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)) -* PlayerOverlay not hiding when not playing and unneeded bottom space in TrackTableView ([0ebac05](https://github.com/KRTirtho/spotube/commit/0ebac05a4be8e8f744a6c672d3bb9807d6f02e10)) -* **web:** not building due to metadata_god ffi ([1191bf2](https://github.com/KRTirtho/spotube/commit/1191bf232d0797aaae7eff2f5d570acd49ce61bd)) +- **android:** file_picker and permission_handler failure for sdk < 33 ([139d4dc](https://github.com/KRTirtho/spotube/commit/139d4dc033d9aaa1d6882bf0f53e96a3b1e87c95)) +- cached local track is fetched from network ([abf4a57](https://github.com/KRTirtho/spotube/commit/abf4a5763a2faeedeb93d54e66c1f2482295b326)) +- categories not showing for oauth exception ([4df917e](https://github.com/KRTirtho/spotube/commit/4df917e65ee20cbcf42394cc141b1cdcdd6cc914)) +- **desktop:** maximized window size is stored and window maximized state doesn't persist ([91d5d10](https://github.com/KRTirtho/spotube/commit/91d5d1003b09530ff3bc9a0aa93e382e943977e0)) +- local audio doesn't get refreshed after getting permission ([618c6da](https://github.com/KRTirtho/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)) +- PlayerOverlay not hiding when not playing and unneeded bottom space in TrackTableView ([0ebac05](https://github.com/KRTirtho/spotube/commit/0ebac05a4be8e8f744a6c672d3bb9807d6f02e10)) +- **web:** not building due to metadata_god ffi ([1191bf2](https://github.com/KRTirtho/spotube/commit/1191bf232d0797aaae7eff2f5d570acd49ce61bd)) ## [2.4.1](https://github.com/KRTirtho/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)) -* remove macos bounds for reading and writing audio metadata ([16064f6](https://github.com/KRTirtho/spotube/commit/16064f68e882b091401ace4b895e387f46635800)) -* **search:** horizontal swipe scroll support for Desktop platform ([d5ff927](https://github.com/KRTirtho/spotube/commit/d5ff927c7273b6e72c5d775ee777f2cbd0d6d05c)) - +- add macos audio metadata tags support ([5866b0f](https://github.com/KRTirtho/spotube/commit/5866b0fcd661cf32060bb1485ea81634fbb9b90a)) +- remove macos bounds for reading and writing audio metadata ([16064f6](https://github.com/KRTirtho/spotube/commit/16064f68e882b091401ace4b895e387f46635800)) +- **search:** horizontal swipe scroll support for Desktop platform ([d5ff927](https://github.com/KRTirtho/spotube/commit/d5ff927c7273b6e72c5d775ee777f2cbd0d6d05c)) ### Bug Fixes -* **artist-page:** SpotubeMarqueeText used in ArtistCard crashes the app ([4279541](https://github.com/KRTirtho/spotube/commit/427954150ab65b250e79fc844fc864abff5b6972)) -* **layout:** Fix adaptive UI not working correctly by providing a overriding option ([8c7adde](https://github.com/KRTirtho/spotube/commit/8c7adde890105e0267b71994b7928277f84553e5)) -* **local-track:** throwing exception when downloadLocation is empty ([1a3556d](https://github.com/KRTirtho/spotube/commit/1a3556d39e8473cadb6143192c48465dc6485599)) +- **artist-page:** SpotubeMarqueeText used in ArtistCard crashes the app ([4279541](https://github.com/KRTirtho/spotube/commit/427954150ab65b250e79fc844fc864abff5b6972)) +- **layout:** Fix adaptive UI not working correctly by providing a overriding option ([8c7adde](https://github.com/KRTirtho/spotube/commit/8c7adde890105e0267b71994b7928277f84553e5)) +- **local-track:** throwing exception when downloadLocation is empty ([1a3556d](https://github.com/KRTirtho/spotube/commit/1a3556d39e8473cadb6143192c48465dc6485599)) ## [2.4.0](https://github.com/KRTirtho/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)) -* add download multi tracks support for mobile platform ([0476bf7](https://github.com/KRTirtho/spotube/commit/0476bf7ceece034a927d1df6099d8b33036f8a9b)) -* add download queue for desktop & initial playlist download support ([08f913e](https://github.com/KRTirtho/spotube/commit/08f913e9761d0f5c447af9dfb6eedb44b675498c)) -* add download tab on library ([8d77b69](https://github.com/KRTirtho/spotube/commit/8d77b6900a81aab020e19397e788964b0ac499ff)) -* add web support although nothing works just as expected ([2818ed5](https://github.com/KRTirtho/spotube/commit/2818ed5c9dadb9185a52762599c1dd0acd81e6bf)) -* **broken:** Broken Warning! Initial Local Audio Player ([c3bf511](https://github.com/KRTirtho/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)) -* **local-tracks:** complete support for local tracks ([e206f16](https://github.com/KRTirtho/spotube/commit/e206f16723ac989ad58006c1b3c90c6691d8cab3)) -* **mpris:** MPRIS metadata are now updated in realtime ([d9addcd](https://github.com/KRTirtho/spotube/commit/d9addcda8e9562803bd73016148fab22560ee050)) -* **playback:** add repeat track support [#166](https://github.com/KRTirtho/spotube/issues/166) ([cae9993](https://github.com/KRTirtho/spotube/commit/cae99934299bd197c68f626d6c10158d449770b9)) -* **synced-lyrics:** animated active text size ([531fae6](https://github.com/KRTirtho/spotube/commit/531fae64f94b21551a7a0da363a9ab0d44f5d3b1)) -* **ui:** adaptive TrackTile actions & Setting ListTile ([615d5ce](https://github.com/KRTirtho/spotube/commit/615d5ce901eb0512e84a120b7309c9053238ee36)) - +- Ability to change download location added ([816707c](https://github.com/KRTirtho/spotube/commit/816707c643f8d60d25bc08fd4c8005daa2ba9e63)) +- add download multi tracks support for mobile platform ([0476bf7](https://github.com/KRTirtho/spotube/commit/0476bf7ceece034a927d1df6099d8b33036f8a9b)) +- add download queue for desktop & initial playlist download support ([08f913e](https://github.com/KRTirtho/spotube/commit/08f913e9761d0f5c447af9dfb6eedb44b675498c)) +- add download tab on library ([8d77b69](https://github.com/KRTirtho/spotube/commit/8d77b6900a81aab020e19397e788964b0ac499ff)) +- add web support although nothing works just as expected ([2818ed5](https://github.com/KRTirtho/spotube/commit/2818ed5c9dadb9185a52762599c1dd0acd81e6bf)) +- **broken:** Broken Warning! Initial Local Audio Player ([c3bf511](https://github.com/KRTirtho/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)) +- **local-tracks:** complete support for local tracks ([e206f16](https://github.com/KRTirtho/spotube/commit/e206f16723ac989ad58006c1b3c90c6691d8cab3)) +- **mpris:** MPRIS metadata are now updated in realtime ([d9addcd](https://github.com/KRTirtho/spotube/commit/d9addcda8e9562803bd73016148fab22560ee050)) +- **playback:** add repeat track support [#166](https://github.com/KRTirtho/spotube/issues/166) ([cae9993](https://github.com/KRTirtho/spotube/commit/cae99934299bd197c68f626d6c10158d449770b9)) +- **synced-lyrics:** animated active text size ([531fae6](https://github.com/KRTirtho/spotube/commit/531fae64f94b21551a7a0da363a9ab0d44f5d3b1)) +- **ui:** adaptive TrackTile actions & Setting ListTile ([615d5ce](https://github.com/KRTirtho/spotube/commit/615d5ce901eb0512e84a120b7309c9053238ee36)) ### Bug Fixes -* **adaptive-list-tile:** dialog content not updating when content has changed ([a1d4230](https://github.com/KRTirtho/spotube/commit/a1d423090c854ebe319a0fa03fd6e5c4007b1387)) -* album & playlist card, player view and album view play button logic ([55852bd](https://github.com/KRTirtho/spotube/commit/55852bd15bc709d61fbba8cbea01ceca791d154c)) -* **docs:** indentions ([4a291d5](https://github.com/KRTirtho/spotube/commit/4a291d5f20dabe68f3ed64071624dcbed8327329)) -* **downloader:** downloaded track is corrupted for tagging ([2ab1fba](https://github.com/KRTirtho/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)) -* dropped flutter_downloader deps due to slow download speed and UserDownloads not showing for anonymous ([307a8e2](https://github.com/KRTirtho/spotube/commit/307a8e21df1e39123a1dca4c1b063eab50359581)) -* flutter_downloader manifest configuration breaking android support ([f3a0f78](https://github.com/KRTirtho/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)) -* **performance:** always running marquee text causes high GPU usage [#175](https://github.com/KRTirtho/spotube/issues/175) and UserArtist overflow on smaller displays ([a23ce61](https://github.com/KRTirtho/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)) -* **player-overlay:** flickering when a track is changed or navigated to another page ([e48b67c](https://github.com/KRTirtho/spotube/commit/e48b67cd47ae54ad9268aead268e444836a67b0d)) -* **sidebar:** user image url ([747efc6](https://github.com/KRTirtho/spotube/commit/747efc6ee66bc6c7c917cc02bd134968a0781701)) -* **synced-lyrics:** active lyrics contrast ratio ([aba1ba9](https://github.com/KRTirtho/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)) +- **adaptive-list-tile:** dialog content not updating when content has changed ([a1d4230](https://github.com/KRTirtho/spotube/commit/a1d423090c854ebe319a0fa03fd6e5c4007b1387)) +- album & playlist card, player view and album view play button logic ([55852bd](https://github.com/KRTirtho/spotube/commit/55852bd15bc709d61fbba8cbea01ceca791d154c)) +- **docs:** indentions ([4a291d5](https://github.com/KRTirtho/spotube/commit/4a291d5f20dabe68f3ed64071624dcbed8327329)) +- **downloader:** downloaded track is corrupted for tagging ([2ab1fba](https://github.com/KRTirtho/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)) +- dropped flutter_downloader deps due to slow download speed and UserDownloads not showing for anonymous ([307a8e2](https://github.com/KRTirtho/spotube/commit/307a8e21df1e39123a1dca4c1b063eab50359581)) +- flutter_downloader manifest configuration breaking android support ([f3a0f78](https://github.com/KRTirtho/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)) +- **performance:** always running marquee text causes high GPU usage [#175](https://github.com/KRTirtho/spotube/issues/175) and UserArtist overflow on smaller displays ([a23ce61](https://github.com/KRTirtho/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)) +- **player-overlay:** flickering when a track is changed or navigated to another page ([e48b67c](https://github.com/KRTirtho/spotube/commit/e48b67cd47ae54ad9268aead268e444836a67b0d)) +- **sidebar:** user image url ([747efc6](https://github.com/KRTirtho/spotube/commit/747efc6ee66bc6c7c917cc02bd134968a0781701)) +- **synced-lyrics:** active lyrics contrast ratio ([aba1ba9](https://github.com/KRTirtho/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)) # v2.3.0 ### New + - 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 @@ -644,8 +827,8 @@ All notable changes to this project will be documented in this file. See [standa - 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) - ### 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 @@ -654,6 +837,7 @@ All notable changes to this project will be documented in this file. See [standa - Track match Cache support for previously played tracks. This dramatically reduces track change latency & load on the YouTube search engine too ### Bug Fixes + - API rate limits inside TrackTile for multiple Follow queries at once - Player doesn't stop when Application is exits or closed - First Track of Playlist doesn't load sometimes @@ -663,9 +847,11 @@ All notable changes to this project will be documented in this file. See [standa # v2.2.1 ### Improved + - Page transitions defaulted to material you design -### Bug fixes +### Bug fixes + - Mini Player flickering on random state updates - Track More Options not showing when not logged in - Wrong link to Client ID & Client Secret tutorial in Login page @@ -674,6 +860,7 @@ All notable changes to this project will be documented in this file. See [standa # v2.2.0 ### New + - Update checker - Share options for playlists & track - Android Skip to Next/Previous track from notification/lockscreen (https://github.com/KRTirtho/spotube/issues/91) @@ -686,10 +873,13 @@ All notable changes to this project will be documented in this file. See [standa - M1 Mac support via MacOS Universal Binary (untested) (https://github.com/KRTirtho/spotube/pull/87) ### Improved -- Authentication is now persistent (no more re-login) + +- Authentication is now persistent (no more re-login) - Settings Page. Shows application details in About Dialog - Playlist Create Dialog Scrollable + ### Bug fixes + - private playlists of current user aren't shown fix (https://github.com/KRTirtho/spotube/issues/92) - refresh token error causing re-login (culprit: internal lib spotify-dart) - Typo in Login instructions URL @@ -697,6 +887,7 @@ All notable changes to this project will be documented in this file. See [standa # v2.1.0 ### New + - Synced Lyrics (with fallback genius lyrics) - Playlist create/delete - Add/Remove tracks to own playlists @@ -705,6 +896,7 @@ All notable changes to this project will be documented in this file. See [standa - Customize Marketplace location ### Improved + - Spotify track to youtube track algorithm - Genius lyrics matching algorithm - Download track. Checks if already exists & replaces on user command @@ -712,14 +904,16 @@ All notable changes to this project will be documented in this file. See [standa - Bigger Title display (replaced word-break with Marquee Text for better visibility) (https://github.com/KRTirtho/spotube/pull/47) ### Bug fixes + - Sequential playlist playback not working with latest webkit2gtk (https://github.com/KRTirtho/spotube/issues/46) - Theme modification state doesn't persist (https://github.com/KRTirtho/spotube/issues/54) - Wrong URI path for "Login with Spotify" tutorial (https://github.com/KRTirtho/spotube/issues/69) -- Card shadow showing in the background of TitleBar & Searchbar +- Card shadow showing in the background of TitleBar & Searchbar -# v2.0.0 +# v2.0.0 ### New + - Android Support https://github.com/KRTirtho/spotube/issues/24 - Responsive UI (Mobile, Tablet) - Anonymous/Guest Account @@ -732,14 +926,16 @@ All notable changes to this project will be documented in this file. See [standa - Android NavigationPanel controls (OS media controls of Android) ### Improved + - 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 - Simpler logic, faster calculations & better caching (flutter_hooks) - shared state management - uses riverpod & hooks combination ### Bug fixes + - Can't play any song in macos https://github.com/KRTirtho/spotube/issues/23 - Downloaded tracks can't be played as they're WebAudio (.weba) instead of MP3 - delay while changing Playlist/Single tracks @@ -747,24 +943,28 @@ All notable changes to this project will be documented in this file. See [standa # v1.2.0 ### 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 + ### Improved + - Macos support - Genius (Lyrics Provider) access_token can be saved in the Login page too - Better theme for dropdown-buttons ### Bug fixes + - broken authentication IPC on Mac OS (https://github.com/KRTirtho/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) - windows titlebar buttons appears on Mac OS - genius access_token not loading on initial app start - # v1.1.0 ### New + - MacOS support https://github.com/KRTirtho/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 @@ -780,27 +980,33 @@ All notable changes to this project will be documented in this file. See [standa - Click to open artist-profile/album everywhere in the application ### Improved + - UserLibrary album & artist tab - PlaylistView simplified layout with `ListView` instead of `TableView` - Control Theme from settings manually - `PageWindowTitleBar` now acts as `appBar` ### 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 +- `url_launcher`'s unstable `canLaunch` method blocks OAuth login in certain \*nix OSs - Refresh token gets revoked & doesn't get renewed automatically + # v1.0.1 ### Improved + - Placeholder avatar for User section powered by dicebear.com ### Bug fixes + - No fallback/placeholder image causing undefined behavior (#2) - Unsafe access to empty List with List.first/List.last # v1.0.0 ### New + - 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 @@ -808,15 +1014,17 @@ All notable changes to this project will be documented in this file. See [standa - inno_setup based windows/win32 GUI installer (dbf8a34) ### Improved + - Lower RAM & CPU usage. 2x less RAM usage & 20% less CPU usage - Faster playback & smooth track change with proper shuffling support - Automatic Dark mode support (system) - 54% smaller bundle size (after compression) -- Available through package managers in Linux (Debian, Arch, Flatpak & AppImage) +- Available through package managers in Linux (Debian, Arch, Flatpak & AppImage) # v0.0.3 ### New + - Automated installer for Windows (now doesn't require manual mpv-player install) - Playback caching - Retry button for ManualLyricDialog @@ -824,23 +1032,28 @@ All notable changes to this project will be documented in this file. See [standa - Redirect to youtube video by clicking on the title of the track ### Improved + - 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 # v0.0.2 ### New + - Lyric Seek - Support for images in playlist cards - Infinite Query/Pagination support for Home & Genre pages - Settings for configuring local configuration ### Improved + - Home Page Layout. Fixes the jiggering of Playlist Links on hover ### 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) - Volume level wasn't cached even after changing volume @@ -849,8 +1062,9 @@ All notable changes to this project will be documented in this file. See [standa Spotube v0.0.1 - initial release of the open source software for playing Spotify music using Youtube public API ### New + - Local playback handling - Playback Queue - Save to Liked Tracks/Playlists - Bypass API rate limitation on basic usage using personal developer Apps for spotify API -- Youtube search & get handled using scrape-yt \ No newline at end of file +- Youtube search & get handled using scrape-yt diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index 13996cea..d4746a1a 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -25,14 +25,14 @@ All types of contributions are encouraged and valued. See the [Table of Contents - [Before Submitting an Enhancement](#before-submitting-an-enhancement) - [How Do I Submit a Good Enhancement Suggestion?](#how-do-i-submit-a-good-enhancement-suggestion) - [Your First Code Contribution](#your-first-code-contribution) - - [Submit translations](#submit-translations) + - [Submit Translations](#submit-translations) ## 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). By participating, you are expected to uphold this code. Please report unacceptable behavior -to <>. +to krtirtho@gmail.com. ## I Have a Question @@ -123,16 +123,16 @@ Do the following: - Install Development dependencies in linux - Debian (>=12/Bookworm)/Ubuntu ```bash - $ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev + $ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev avahi-daemon avahi-discover avahi-utils libnss-mdns mdns-scan libwebkit2gtk-4.1-0 libwebkit2gtk-4.1-dev libsoup-3.0-0 libsoup-3.0-dev ``` - Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04) - Arch/Manjaro ```bash - yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify + yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan webkit2gtk-4.1 libsoup3 ``` - Fedora ```bash - dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel + dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel avahi mdns-scan nss-mdns webkit2gtk4.1 webkit2gtk4.1-devel libsoup3 libsoup3-devel ``` - Clone the Repo - Create a `.env` in root of the project following the `.env.example` template diff --git a/LICENSE b/LICENSE index 0bce62d7..11aea461 100644 --- a/LICENSE +++ b/LICENSE @@ -1,12 +1,12 @@ BSD-4-Clause License -Copyright (c) 2023 Kingkor Roy Tirtho. All rights reserved. +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. + 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. + 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/Makefile b/Makefile index 1add46b0..48626312 100644 --- a/Makefile +++ b/Makefile @@ -42,4 +42,14 @@ apk: mv build/app/outputs/apk/release/app-release.apk build/Spotube-android-all-arch.apk gensums: - sh -c scripts/gensums.sh \ No newline at end of file + 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 \ No newline at end of file diff --git a/README.md b/README.md index de00054f..26bb1d71 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,51 @@ +# 🚨 Spotube is banned from using "Spotify™ API" 🚨 + +### The developer of Spotube has received a cease and desist letter from Spotify USA Inc. and Spotify AB, asserting a legal threat concerning the distribution and development of any application that utilizes Spotify’s data API in conjunction with content from YouTube® to facilitate ad-free playback of music tracks. The letter contends that this specific use of the Spotify™ APIs contravenes the Spotify™ Agreements and may also infringe upon the rights of music rights holders. + +### Consequently, as the official maintainer of Spotube, I will immediately cease all forms of official distribution and development of Spotube that continue to employ the aforementioned 'Spotify™ APIs' + +### Their exact reasoning: (any) "uses of Spotify’s data API in connection with content from YouTube to provide ad-free playback of music tracks. The use of the Spotify APIs in this manner violates the Spotify Agreements and may also violate the rights of music rights holders." + +## So what's now? + +> In short, we are cooked (legally) + +For now, I've to: + +1. Stop distributing/developing Spotube/any app that uses "Spotify™ APIs" + + That means, I can no longer distribute Spotube through the website, GitHub, any app store and immediately have to take down the versions that uses Spotify™ APIs. + +1. Stop using their logo/image/name/intellectual property in a manner that "seems infringement" +1. Forever desist from aiding or assisting any other person or entity in the activities described above + +--- + +**For the users of Spotube:** + +Don't worry, Spotube is banned only from (or assisting other) using those APIs. As long as the app isn't using them or no way helps anyone else to use them, it's ok. + +In future, I'll try to rewrite Spotube to ensure it operates within the bounds of copyright law and platform policies. And give ways for the users to extend the app to their use cases. Work is already in progress to implement this! So expect some big updates soon! + +But for eternity, you can't download versions of Spotube that still uses "Spotify™ APIs" from official means (website/Github/app stores). Those will be taken down. + +**But newer version of Spotube that _doesn't_ use "Spotify™ APIs" will be available to replace those.** + +That means, in the upcoming new versions, you will no longer be able to login with your "Spotify™ Account", access your saved playlists, albums, tracks, followed artists or perform any action on that account or anything that is from "Spotify™" or owned by "Spotify™" (yes the API public data (e.g. track metadata) as well) through Spotube. + +**Conclusion:** I'm extremely sorry for this disruption to your day to day music listening experience. Spotube existed and it used by a large number of users because they find it better. And we'll continue to be better than others but legally\* from now on + +> Spotube has no affiliation with Spotify™ or any of its subsidiaries. +
Spotube Logo -An open source, cross-platform Spotify client compatible across multiple platforms
-utilizing Spotify's data API and YouTube, Piped.video or JioSaavn as an audio source,
-eliminating the need for Spotify Premium +An open source, cross-platform music client
+utilizing selected music provider API and YouTube®, Piped.video or JioSaavn as an audio source Btw it's not just another Electron app 😉 -Visit the website +Visit the website Discord Server Support me on Patron @@ -19,15 +57,11 @@ Btw it's not just another Electron app 😉 --- -![Spotube Desktop](assets/spotube-screenshot.png) - -![Spotube Mobile](assets/mobile-screenshots/combined.png) -
## 🌃 Features -- 🚫 No ads, thanks to the use of public & free Spotify and YT Music APIs¹ +- 🚫 No ads, thanks to the use of public & free music metadata providers and YT Music APIs¹ - ⬇️ Freely downloadable tracks - 🖥️ 📱 Cross-platform support - 🪶 Small size & less data usage @@ -38,285 +72,19 @@ Btw it's not just another Electron app 😉 - 📖 Open source/libre software - 🔉 Playback control is done locally, not on the server -**¹** It is still **recommended** to support creators by engaging with their YouTube channels/Spotify tracks (or preferably by buying their merch/concert tickets/physical media). +**¹** It is still **recommended** to support creators by engaging with their YouTube channels/tracks in music platforms (or preferably by buying their merch/concert tickets/physical media). ### ❌ Unsupported features -- 🗣️ **Spotify Shows & Podcasts:** Shows and Podcasts will **never be supported** because the audio tracks are _only_ available on Spotify and accessing them would require Spotify Premium. -- 🎧 **Spotify Listen Along:** [Coming soon!](https://github.com/KRTirtho/spotube/issues/8) - -## 📜 ⬇️ Installation guide - -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 - - Get it on Google Play - -
- - APK download - -
- - Download from F-Droid - -
Flatpak -

flatpak install com.github.KRTirtho.Spotube

- - Download on Flathub - -
AppImage - - Download AppImage - -

Note: AppimageLauncher is required!

-
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

-
- -### 🔄 Nightly Builds - -Grab the latest nightly builds of Spotube [from the GitHub Releases](https://github.com/KRTirtho/spotube/releases/tag/nightly). - -## 🕳️ Building from source - -GitHub Workflow Status - -You can compile Spotube's source code by [following these instructions](CONTRIBUTION.md#your-first-code-contribution). +- 🗣️ **Shows & Podcasts:** Shows and Podcasts will **never be supported** because the audio tracks are _only_ available on music providers and accessing them would require premium. +- 🎧 **Listen Along:** [Coming soon!](https://github.com/KRTirtho/spotube/issues/8) ## 👥 The Spotube team - [Kingkor Roy Tirtho](https://github.com/KRTirtho) - The Founder, Maintainer and Lead Developer -- [RaptaG](https://github.com/RaptaG) - The GitHub Moderator and Community Manager -- [Owen Connor](https://github.com/owencz1998) - The Cool Discord Moderator -- [Meenbeese](https://github.com/meenbeese) - The Android Developer -- [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. If you are concerned, 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. [Spotify API](https://developer.spotify.com/documentation/web-api) - The Spotify Web API is a RESTful API that provides access to Spotify data -1. [Piped](https://piped-docs.kavin.rocks/) - Piped is a privacy friendly alternative YouTube frontend, which is efficient and scalable by design. -1. [YouTube](https://youtube.com/) - YouTube is an American online video-sharing platform headquartered in San Bruno, California. Three former PayPal employees—Chad Hurley, Steve Chen, and Jawed Karim—created the service in February 2005 -1. [JioSaavn](https://www.jiosaavn.com) - JioSaavn is an Indian online music streaming service and a digital distributor of Bollywood, English and other regional Indian music across the world. Since it was founded in 2007 as Saavn, the company has acquired rights to over 5 crore (50 million) music tracks in 15 languages -1. [SongLink](https://song.link) - SongLink is a free smart link service that helps you share music with your audience. It's a one-stop-shop for creating smart links for music, podcasts, and other audio content -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. [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. [async](https://pub.dev/packages/async) - Utility functions and classes related to the 'dart:async' library. -1. [audio_service](https://pub.dev/packages/audio_service) - Flutter plugin to play audio in the background while the screen is off. -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_size_text](https://github.com/leisim/auto_size_text) - Flutter widget that automatically resizes text to fit perfectly within its bounds. -1. [buttons_tabbar](https://afonsoraposo.com) - A Flutter package that implements a TabBar where each label is a toggle button. -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. [catcher_2](https://github.com/ThexXTURBOXx/catcher_2) - Plugin for error catching which provides multiple handlers for dealing with errors when they are not caught by the developer. -1. [collection](https://pub.dev/packages/collection) - Collections and utilities functions and classes related to collections. -1. [cupertino_icons](https://pub.dev/packages/cupertino_icons) - Default icons asset for Cupertino widgets based on Apple styled icons -1. [curved_navigation_bar](https://github.com/rafalbednarczuk/curved_navigation_bar) - Stunning Animating Curved Shape Navigation Bar. Adjustable color, background color, animation curve, animation duration. -1. [dbus](https://github.com/canonical/dbus.dart) - A native Dart implementation of the D-Bus message bus client. This package allows Dart applications to directly access services on the Linux desktop. -1. [device_info_plus](https://plus.fluttercommunity.dev/) - Flutter plugin providing detailed information about the device (make, model, etc.), and Android or iOS version the app is running on. -1. [device_preview](https://github.com/aloisdeniel/flutter_device_preview) - Approximate how your Flutter app looks and performs on another device. -1. [dio](https://github.com/cfug/dio) - A powerful HTTP networking package,supports Interceptors,Aborting and canceling a request,Custom adapters, Transformers, etc. -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. [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. [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_selector](https://pub.dev/packages/file_selector) - Flutter plugin for opening and saving files, or selecting directories, using native file selection UI. -1. [fl_query](https://fl-query.krtirtho.dev) - Asynchronous data caching, refetching & invalidation library for Flutter -1. [fl_query_hooks](https://fl-query.krtirtho.dev) - Elite flutter_hooks compatible library for fl_query, the Asynchronous data caching, refetching & invalidation library for Flutter -1. [fl_query_devtools](https://fl-query.krtirtho.dev) - Devtools support for Fl-Query -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_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_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_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. [flutter_svg](https://pub.dev/packages/flutter_svg) - An SVG rendering and widget library for Flutter, which allows painting and displaying Scalable Vector Graphics 1.1 files. -1. [form_validator](https://github.com/TheMisir/form-validator) - Simplest form validation library for flutter's form field widgets -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. [go_router](https://pub.dev/packages/go_router) - A declarative router for Flutter based on Navigation 2 supporting deep linking, data-driven routes and more -1. [google_fonts](https://pub.dev/packages/google_fonts) - A Flutter package to use fonts from fonts.google.com. Supports HTTP fetching, caching, and asset bundling. -1. [hive](https://github.com/hivedb/hive/tree/master/hive) - Lightweight and blazing fast key-value database written in pure Dart. Strongly encrypted using AES-256. -1. [hive_flutter](https://github.com/hivedb/hive/tree/master/hive_flutter) - Extension for Hive. Makes it easier to use Hive in Flutter apps. -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. [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. [introduction_screen](https://github.com/pyozer/introduction_screen) - Introduction/Onboarding package for flutter app with some customizations possibilities -1. [json_annotation](https://pub.dev/packages/json_annotation) - Classes and helper functions that support JSON code generation via the `json_serializable` package. -1. [logger](https://pub.dev/packages/logger) - Small, easy to use and extensible logger which prints beautiful logs. -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. -1. [metadata_god](https://github.com/KRTirtho/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. [package_info_plus](https://plus.fluttercommunity.dev/) - 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. [popover](https://github.com/minikin/popover) - A popover is a transient view that appears above other content onscreen when you tap a control or in an area. -1. [scroll_to_index](https://github.com/quire-io/scroll-to-index) - Scroll to a specific child of any scrollable widget in Flutter -1. [sidebarx](https://github.com/Frezyx/sidebarx) - flutter multiplatform navigation sidebar / side navigationbar / drawer widget -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. [skeleton_text](https://github.com/101Loop/Skeleton-Text) - A package that provides an easy way to add skeleton text loading animation in Flutter project. This project is a part of 101Loop community. -1. [smtc_windows](https://github.com/KRTirtho/smtc_windows) - Windows `SystemMediaTransportControls` implementation for Flutter giving access to Windows OS Media Control applet. -1. [stroke_text](https://github.com/MohamedAbd0/stroke_text) - A Simple Flutter plugin for applying stroke (border) style to a text widget -1. [system_theme](https://pub.dev/packages/system_theme) - A plugin to get the current system theme info. Supports Android, Web, Windows, Linux and macOS -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. [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. [visibility_detector](https://pub.dev/packages/visibility_detector) - A widget that detects the visibility of its child and notifies a callback. -1. [window_manager](https://github.com/leanflutter/window_manager) - 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. [simple_icons](https://teavelopment.com/) - The Simple Icon pack available as Flutter Icons. Provides over 1500 Free SVG icons for popular brands. -1. [audio_service_mpris](https://github.com/bdrazhzhov/audio-service-mpris) - audio_service platform interface supporting Media Player Remote Interfacing Specification. -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. [jiosaavn](https://github.com/KRTirtho/jiosaavn) - Unofficial API client for jiosaavn.com -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. [gap](https://github.com/letsar/gap) - Flutter widgets for easily adding gaps inside Flex widgets such as Columns and Rows or scrolling views. -1. [sliver_tools](https://github.com/Kavantix) - A set of useful sliver tools that are missing from the flutter framework -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. [wikipedia_api](https://github.com/KRTirtho/wikipedia_api) - Wikipedia API for dart and flutter -1. [skeletonizer](https://github.com/Milad-Akarie/skeletonizer) - Converts already built widgets into skeleton loaders with no extra effort. -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. [win32_registry](https://win32.pub) - A package that provides a friendly Dart API for accessing the Windows Registry. -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_broadcasts](https://pub.dev/packages/flutter_broadcasts) - A plugin for sending and receiving broadcasts with Android intents and iOS notifications. -1. [freezed_annotation](https://pub.dev/packages/freezed_annotation) - Annotations for the freezed code-generator. This package does nothing without freezed too. -1. [spotify](https://github.com/rinukkusu/spotify-dart) - An incomplete dart library for interfacing with the Spotify Web API. -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_distributor](https://distributor.leanflutter.org) - A complete tool for packaging and publishing your Flutter apps. -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. [hive_generator](https://github.com/hivedb/hive/tree/master/hive_generator) - Extension for Hive. Automatically generates TypeAdapters to store any class. -1. [json_serializable](https://pub.dev/packages/json_serializable) - Automatically generate code for converting to and from JSON by annotating Dart classes. -1. [pub_api_client](https://github.com/leoafarias/pub_api_client) - An API Client for Pub to interact with public package information. -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. [freezed](https://pub.dev/packages/freezed) - Code generation for immutable classes that has a simple syntax/API without compromising on the features. -1. [flutter_desktop_tools](https://github.com/KRTirtho/flutter_desktop_tools) - Essential collection of tools for flutter desktop app development -1. [piped_client](https://github.com/KRTirtho/piped_client) - API Client for piped.video -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. [window_size](https://github.com/google/flutter-desktop-embedding.git) - Allows resizing and repositioning the window containing Flutter. -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. [dart_discord_rpc](https://github.com/alexmercerind/dart_discord_rpc) - Discord Rich Presence for Flutter & Dart apps & games. -
- -

© Copyright Spotube 2024

diff --git a/analysis_options.yaml b/analysis_options.yaml index 5f2cbbe1..af222653 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -25,12 +25,16 @@ linter: # 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: - enable-experiment: - - records - - patterns errors: invalid_annotation_target: ignore + exclude: + - "**.freezed.dart" + - "**.g.dart" + - "**.gr.dart" + - "**/generated_plugin_registrant.dart" + - test/**/*.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 2f85cdeb..5051f5a3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,3 +1,9 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -6,11 +12,6 @@ if (localPropertiesFile.exists()) { } } -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' @@ -21,22 +22,23 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - def keystoreProperties = new Properties() def keystorePropertiesFile = rootProject.file('key.properties') if (keystorePropertiesFile.exists()) { keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) } -android { - compileSdkVersion 34 +def composeVersion = "1.4.8" - ndkVersion "21.4.7075529" +android { + namespace "oss.krtirtho.spotube" + + compileSdkVersion 35 + + ndkVersion = "27.0.12077973" compileOptions { + coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } @@ -49,11 +51,18 @@ android { main.java.srcDirs += 'src/main/kotlin' } + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion "$composeVersion" // Correlates with org.jetbrains.kotlin.android plugin in settings.gradle + } + defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "oss.krtirtho.spotube" minSdkVersion 24 - targetSdkVersion flutter.targetSdkVersion + targetSdkVersion 35 versionCode flutterVersionCode.toInteger() versionName flutterVersionName multiDexEnabled true @@ -67,10 +76,14 @@ android { storePassword keystoreProperties['storePassword'] } } + buildTypes { release { signingConfig signingConfigs.release } + debug { + signingConfig signingConfigs.release + } } flavorDimensions "default" @@ -81,37 +94,46 @@ android { 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 { - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - constraints { - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version") { - because("kotlin-stdlib-jdk7 is now a part of kotlin-stdlib") - } - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version") { - because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib") - } - } - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' + 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 index 116bc22f..17a13e9d 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -1 +1,50 @@ --keep class androidx.lifecycle.DefaultLifecycleObserver \ No newline at end of file +-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 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 index 1041f6ca..400c91e8 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,7 +1,19 @@ - - - - + + + + + + \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5ab7a0b5..0effefe2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,9 @@ - + + @@ -16,33 +17,36 @@ + android:usesCleartextTraffic="true"> + + + + android:windowSoftInputMode="adjustResize"> + Specifies an Android theme to apply to this Activity as soon as + the Android process has started. This theme is visible to the user + while the Flutter UI initializes. After that, this theme continues + to determine the Window background behind the Flutter UI. + --> + android:resource="@style/NormalTheme" /> + @@ -50,12 +54,13 @@ + + + android:scheme="https" /> @@ -66,23 +71,30 @@ + + + + + - + - @@ -90,11 +102,40 @@ - + + + + + + + + + + + + + + + + + - + This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> + \ No newline at end of file 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 new file mode 100644 index 00000000..a20af959 --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidget.kt @@ -0,0 +1,207 @@ +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 new file mode 100644 index 00000000..2d23c64f --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidgetReceiver.kt @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000..4edd69f6 --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/AlbumSimple.kt @@ -0,0 +1,40 @@ +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 new file mode 100644 index 00000000..ef43ecc8 --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Artist.kt @@ -0,0 +1,25 @@ +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 new file mode 100644 index 00000000..de7d5521 --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Image.kt @@ -0,0 +1,10 @@ +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 new file mode 100644 index 00000000..717b790f --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Track.kt @@ -0,0 +1,37 @@ +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 new file mode 100644 index 00000000..79339cea --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/Base64ImageProvider.kt @@ -0,0 +1,14 @@ +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 new file mode 100644 index 00000000..ad51ca3c --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/FlutterAssetImageProvider.kt @@ -0,0 +1,14 @@ +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 new file mode 100644 index 00000000..fdfe8e4b --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackDetailsView.kt @@ -0,0 +1,78 @@ +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 new file mode 100644 index 00000000..b54059b1 --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackProgress.kt @@ -0,0 +1,77 @@ +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 index fff3f148..22a9b8d3 100644 Binary files a/android/app/src/main/res/drawable-hdpi-v31/android12branding.png and b/android/app/src/main/res/drawable-hdpi-v31/android12branding.png differ diff --git a/android/app/src/main/res/drawable-hdpi/android12splash.png b/android/app/src/main/res/drawable-hdpi/android12splash.png index 0d4e1c68..adeebcd1 100644 Binary files a/android/app/src/main/res/drawable-hdpi/android12splash.png and b/android/app/src/main/res/drawable-hdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-hdpi/branding.png b/android/app/src/main/res/drawable-hdpi/branding.png index fff3f148..22a9b8d3 100644 Binary files a/android/app/src/main/res/drawable-hdpi/branding.png and b/android/app/src/main/res/drawable-hdpi/branding.png 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 index a65caa1b..204ffe94 100644 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png index 0979b5e4..87eebe5c 100644 Binary files a/android/app/src/main/res/drawable-hdpi/splash.png and b/android/app/src/main/res/drawable-hdpi/splash.png 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 index 5a197da3..3ff2a2da 100644 Binary files a/android/app/src/main/res/drawable-mdpi-v31/android12branding.png and b/android/app/src/main/res/drawable-mdpi-v31/android12branding.png differ diff --git a/android/app/src/main/res/drawable-mdpi/android12splash.png b/android/app/src/main/res/drawable-mdpi/android12splash.png index 5d9760d4..72c22614 100644 Binary files a/android/app/src/main/res/drawable-mdpi/android12splash.png and b/android/app/src/main/res/drawable-mdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-mdpi/branding.png b/android/app/src/main/res/drawable-mdpi/branding.png index 5a197da3..3ff2a2da 100644 Binary files a/android/app/src/main/res/drawable-mdpi/branding.png and b/android/app/src/main/res/drawable-mdpi/branding.png 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 index 3ad0f47e..e7a75c9a 100644 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png index c5e4aca0..6e04efdc 100644 Binary files a/android/app/src/main/res/drawable-mdpi/splash.png and b/android/app/src/main/res/drawable-mdpi/splash.png 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 index fff3f148..22a9b8d3 100644 Binary files a/android/app/src/main/res/drawable-night-hdpi-v31/android12branding.png and b/android/app/src/main/res/drawable-night-hdpi-v31/android12branding.png 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 index 0d4e1c68..adeebcd1 100644 Binary files a/android/app/src/main/res/drawable-night-hdpi/android12splash.png and b/android/app/src/main/res/drawable-night-hdpi/android12splash.png 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 index 5a197da3..3ff2a2da 100644 Binary files a/android/app/src/main/res/drawable-night-mdpi-v31/android12branding.png and b/android/app/src/main/res/drawable-night-mdpi-v31/android12branding.png 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 index 5d9760d4..72c22614 100644 Binary files a/android/app/src/main/res/drawable-night-mdpi/android12splash.png and b/android/app/src/main/res/drawable-night-mdpi/android12splash.png 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 index efc7e6c8..8e2bb197 100644 Binary files a/android/app/src/main/res/drawable-night-xhdpi-v31/android12branding.png and b/android/app/src/main/res/drawable-night-xhdpi-v31/android12branding.png 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 index 8e5ceb0a..5adba278 100644 Binary files a/android/app/src/main/res/drawable-night-xhdpi/android12splash.png and b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png 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 index 593877e0..d301093a 100644 Binary files a/android/app/src/main/res/drawable-night-xxhdpi-v31/android12branding.png and b/android/app/src/main/res/drawable-night-xxhdpi-v31/android12branding.png 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 index fb89da37..4e294a2d 100644 Binary files a/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png and b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png 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 index 9d233302..42b0bdc2 100644 Binary files a/android/app/src/main/res/drawable-night-xxxhdpi-v31/android12branding.png and b/android/app/src/main/res/drawable-night-xxxhdpi-v31/android12branding.png 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 index 38e4a12c..dcc70377 100644 Binary files a/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png and b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png index 203fc77a..4bebb9de 100644 Binary files a/android/app/src/main/res/drawable-v21/background.png and b/android/app/src/main/res/drawable-v21/background.png 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 index 52e8749e..5367a886 100644 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -6,7 +6,7 @@ - + diff --git a/android/app/src/main/res/drawable-xhdpi-v31/android12branding.png b/android/app/src/main/res/drawable-xhdpi-v31/android12branding.png index efc7e6c8..8e2bb197 100644 Binary files a/android/app/src/main/res/drawable-xhdpi-v31/android12branding.png and b/android/app/src/main/res/drawable-xhdpi-v31/android12branding.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/android12splash.png b/android/app/src/main/res/drawable-xhdpi/android12splash.png index 8e5ceb0a..5adba278 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/android12splash.png and b/android/app/src/main/res/drawable-xhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/branding.png b/android/app/src/main/res/drawable-xhdpi/branding.png index efc7e6c8..8e2bb197 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/branding.png and b/android/app/src/main/res/drawable-xhdpi/branding.png 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 index 8774c0ff..b608dca7 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png index 41222c6c..51a669aa 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/splash.png and b/android/app/src/main/res/drawable-xhdpi/splash.png 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 index 593877e0..d301093a 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi-v31/android12branding.png and b/android/app/src/main/res/drawable-xxhdpi-v31/android12branding.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxhdpi/android12splash.png index fb89da37..4e294a2d 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/android12splash.png and b/android/app/src/main/res/drawable-xxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/branding.png b/android/app/src/main/res/drawable-xxhdpi/branding.png index 593877e0..d301093a 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/branding.png and b/android/app/src/main/res/drawable-xxhdpi/branding.png 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 index a379c006..b1e93458 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png index 5ab19d32..cc79cb85 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/splash.png and b/android/app/src/main/res/drawable-xxhdpi/splash.png 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 index 9d233302..42b0bdc2 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi-v31/android12branding.png and b/android/app/src/main/res/drawable-xxxhdpi-v31/android12branding.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png index 38e4a12c..dcc70377 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png and b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/branding.png b/android/app/src/main/res/drawable-xxxhdpi/branding.png index 9d233302..42b0bdc2 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/branding.png and b/android/app/src/main/res/drawable-xxxhdpi/branding.png 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 index 44621df8..d178ba12 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png index a6d40b89..f526b26d 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/splash.png and b/android/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png index 203fc77a..4bebb9de 100644 Binary files a/android/app/src/main/res/drawable/background.png and b/android/app/src/main/res/drawable/background.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 52e8749e..5367a886 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -6,7 +6,7 @@ - + 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 index f606c4d8..c79c58a3 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,9 @@ - - + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index d26a3a6e..c9e5cfad 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png 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 index 63d16c60..159b302f 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png 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 index fe2957ad..9c81fb60 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png 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 index 85c1d793..3450fb00 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png 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 index f22fa5cf..f94fd407 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ 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 new file mode 100644 index 00000000..c8ec7048 --- /dev/null +++ b/android/app/src/main/res/xml/home_player_widget_config.xml @@ -0,0 +1,7 @@ + + diff --git a/android/app/src/nightly/res/drawable-hdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-hdpi-v31/android12branding.png index 9e62c813..22a9b8d3 100644 Binary files a/android/app/src/nightly/res/drawable-hdpi-v31/android12branding.png and b/android/app/src/nightly/res/drawable-hdpi-v31/android12branding.png differ diff --git a/android/app/src/nightly/res/drawable-hdpi/android12splash.png b/android/app/src/nightly/res/drawable-hdpi/android12splash.png index 98fd87f4..1d1cb853 100644 Binary files a/android/app/src/nightly/res/drawable-hdpi/android12splash.png and b/android/app/src/nightly/res/drawable-hdpi/android12splash.png differ diff --git a/android/app/src/nightly/res/drawable-hdpi/branding.png b/android/app/src/nightly/res/drawable-hdpi/branding.png index 9e62c813..22a9b8d3 100644 Binary files a/android/app/src/nightly/res/drawable-hdpi/branding.png and b/android/app/src/nightly/res/drawable-hdpi/branding.png 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 index 20a57ba2..7373eec1 100644 Binary files a/android/app/src/nightly/res/drawable-hdpi/ic_launcher_foreground.png and b/android/app/src/nightly/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/nightly/res/drawable-hdpi/splash.png b/android/app/src/nightly/res/drawable-hdpi/splash.png index 07c7024a..e3869211 100644 Binary files a/android/app/src/nightly/res/drawable-hdpi/splash.png and b/android/app/src/nightly/res/drawable-hdpi/splash.png 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 index 80579983..3ff2a2da 100644 Binary files a/android/app/src/nightly/res/drawable-mdpi-v31/android12branding.png and b/android/app/src/nightly/res/drawable-mdpi-v31/android12branding.png differ diff --git a/android/app/src/nightly/res/drawable-mdpi/android12splash.png b/android/app/src/nightly/res/drawable-mdpi/android12splash.png index a86a2222..3f2a1a0a 100644 Binary files a/android/app/src/nightly/res/drawable-mdpi/android12splash.png and b/android/app/src/nightly/res/drawable-mdpi/android12splash.png differ diff --git a/android/app/src/nightly/res/drawable-mdpi/branding.png b/android/app/src/nightly/res/drawable-mdpi/branding.png index 80579983..3ff2a2da 100644 Binary files a/android/app/src/nightly/res/drawable-mdpi/branding.png and b/android/app/src/nightly/res/drawable-mdpi/branding.png 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 index 43f5e0fe..8d5a9656 100644 Binary files a/android/app/src/nightly/res/drawable-mdpi/ic_launcher_foreground.png and b/android/app/src/nightly/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/nightly/res/drawable-mdpi/splash.png b/android/app/src/nightly/res/drawable-mdpi/splash.png index 86d3fe74..d8f7cc0e 100644 Binary files a/android/app/src/nightly/res/drawable-mdpi/splash.png and b/android/app/src/nightly/res/drawable-mdpi/splash.png 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 index 9e62c813..22a9b8d3 100644 Binary files a/android/app/src/nightly/res/drawable-night-hdpi-v31/android12branding.png and b/android/app/src/nightly/res/drawable-night-hdpi-v31/android12branding.png 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 index 98fd87f4..1d1cb853 100644 Binary files a/android/app/src/nightly/res/drawable-night-hdpi/android12splash.png and b/android/app/src/nightly/res/drawable-night-hdpi/android12splash.png 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 index 80579983..3ff2a2da 100644 Binary files a/android/app/src/nightly/res/drawable-night-mdpi-v31/android12branding.png and b/android/app/src/nightly/res/drawable-night-mdpi-v31/android12branding.png 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 index a86a2222..3f2a1a0a 100644 Binary files a/android/app/src/nightly/res/drawable-night-mdpi/android12splash.png and b/android/app/src/nightly/res/drawable-night-mdpi/android12splash.png 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 index 0bcf138d..8e2bb197 100644 Binary files a/android/app/src/nightly/res/drawable-night-xhdpi-v31/android12branding.png and b/android/app/src/nightly/res/drawable-night-xhdpi-v31/android12branding.png 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 index ad3f39d0..ba73b0e2 100644 Binary files a/android/app/src/nightly/res/drawable-night-xhdpi/android12splash.png and b/android/app/src/nightly/res/drawable-night-xhdpi/android12splash.png 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 index c7d01776..d301093a 100644 Binary files a/android/app/src/nightly/res/drawable-night-xxhdpi-v31/android12branding.png and b/android/app/src/nightly/res/drawable-night-xxhdpi-v31/android12branding.png 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 index 133fb647..5e5cdc45 100644 Binary files a/android/app/src/nightly/res/drawable-night-xxhdpi/android12splash.png and b/android/app/src/nightly/res/drawable-night-xxhdpi/android12splash.png 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 index 5477b799..42b0bdc2 100644 Binary files a/android/app/src/nightly/res/drawable-night-xxxhdpi-v31/android12branding.png and b/android/app/src/nightly/res/drawable-night-xxxhdpi-v31/android12branding.png 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 index fa5a8c92..adff9bec 100644 Binary files a/android/app/src/nightly/res/drawable-night-xxxhdpi/android12splash.png and b/android/app/src/nightly/res/drawable-night-xxxhdpi/android12splash.png differ diff --git a/android/app/src/nightly/res/drawable-v21/background.png b/android/app/src/nightly/res/drawable-v21/background.png index 203fc77a..4bebb9de 100644 Binary files a/android/app/src/nightly/res/drawable-v21/background.png and b/android/app/src/nightly/res/drawable-v21/background.png 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 index 52e8749e..5367a886 100644 --- a/android/app/src/nightly/res/drawable-v21/launch_background.xml +++ b/android/app/src/nightly/res/drawable-v21/launch_background.xml @@ -6,7 +6,7 @@ - + diff --git a/android/app/src/nightly/res/drawable-xhdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-xhdpi-v31/android12branding.png index 0bcf138d..8e2bb197 100644 Binary files a/android/app/src/nightly/res/drawable-xhdpi-v31/android12branding.png and b/android/app/src/nightly/res/drawable-xhdpi-v31/android12branding.png differ diff --git a/android/app/src/nightly/res/drawable-xhdpi/android12splash.png b/android/app/src/nightly/res/drawable-xhdpi/android12splash.png index ad3f39d0..ba73b0e2 100644 Binary files a/android/app/src/nightly/res/drawable-xhdpi/android12splash.png and b/android/app/src/nightly/res/drawable-xhdpi/android12splash.png differ diff --git a/android/app/src/nightly/res/drawable-xhdpi/branding.png b/android/app/src/nightly/res/drawable-xhdpi/branding.png index 0bcf138d..8e2bb197 100644 Binary files a/android/app/src/nightly/res/drawable-xhdpi/branding.png and b/android/app/src/nightly/res/drawable-xhdpi/branding.png 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 index 4cf86d25..f4f416f7 100644 Binary files a/android/app/src/nightly/res/drawable-xhdpi/ic_launcher_foreground.png and b/android/app/src/nightly/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/nightly/res/drawable-xhdpi/splash.png b/android/app/src/nightly/res/drawable-xhdpi/splash.png index dbb0ea02..17a2c373 100644 Binary files a/android/app/src/nightly/res/drawable-xhdpi/splash.png and b/android/app/src/nightly/res/drawable-xhdpi/splash.png 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 index c7d01776..d301093a 100644 Binary files a/android/app/src/nightly/res/drawable-xxhdpi-v31/android12branding.png and b/android/app/src/nightly/res/drawable-xxhdpi-v31/android12branding.png differ diff --git a/android/app/src/nightly/res/drawable-xxhdpi/android12splash.png b/android/app/src/nightly/res/drawable-xxhdpi/android12splash.png index 133fb647..5e5cdc45 100644 Binary files a/android/app/src/nightly/res/drawable-xxhdpi/android12splash.png and b/android/app/src/nightly/res/drawable-xxhdpi/android12splash.png differ diff --git a/android/app/src/nightly/res/drawable-xxhdpi/branding.png b/android/app/src/nightly/res/drawable-xxhdpi/branding.png index c7d01776..d301093a 100644 Binary files a/android/app/src/nightly/res/drawable-xxhdpi/branding.png and b/android/app/src/nightly/res/drawable-xxhdpi/branding.png 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 index 95fa3443..9f88e976 100644 Binary files a/android/app/src/nightly/res/drawable-xxhdpi/ic_launcher_foreground.png and b/android/app/src/nightly/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/nightly/res/drawable-xxhdpi/splash.png b/android/app/src/nightly/res/drawable-xxhdpi/splash.png index 12eb5531..db53f016 100644 Binary files a/android/app/src/nightly/res/drawable-xxhdpi/splash.png and b/android/app/src/nightly/res/drawable-xxhdpi/splash.png 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 index 5477b799..42b0bdc2 100644 Binary files a/android/app/src/nightly/res/drawable-xxxhdpi-v31/android12branding.png and b/android/app/src/nightly/res/drawable-xxxhdpi-v31/android12branding.png differ diff --git a/android/app/src/nightly/res/drawable-xxxhdpi/android12splash.png b/android/app/src/nightly/res/drawable-xxxhdpi/android12splash.png index fa5a8c92..adff9bec 100644 Binary files a/android/app/src/nightly/res/drawable-xxxhdpi/android12splash.png and b/android/app/src/nightly/res/drawable-xxxhdpi/android12splash.png differ diff --git a/android/app/src/nightly/res/drawable-xxxhdpi/branding.png b/android/app/src/nightly/res/drawable-xxxhdpi/branding.png index 5477b799..42b0bdc2 100644 Binary files a/android/app/src/nightly/res/drawable-xxxhdpi/branding.png and b/android/app/src/nightly/res/drawable-xxxhdpi/branding.png 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 index 3de8a2ee..7e2bb4c3 100644 Binary files a/android/app/src/nightly/res/drawable-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/nightly/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/nightly/res/drawable-xxxhdpi/splash.png b/android/app/src/nightly/res/drawable-xxxhdpi/splash.png index 68e806f4..a74c95a4 100644 Binary files a/android/app/src/nightly/res/drawable-xxxhdpi/splash.png and b/android/app/src/nightly/res/drawable-xxxhdpi/splash.png differ diff --git a/android/app/src/nightly/res/drawable/background.png b/android/app/src/nightly/res/drawable/background.png index 203fc77a..4bebb9de 100644 Binary files a/android/app/src/nightly/res/drawable/background.png and b/android/app/src/nightly/res/drawable/background.png 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 new file mode 100644 index 00000000..8aae0e6c --- /dev/null +++ b/android/app/src/nightly/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/android/app/src/nightly/res/drawable/launch_background.xml b/android/app/src/nightly/res/drawable/launch_background.xml index 52e8749e..5367a886 100644 --- a/android/app/src/nightly/res/drawable/launch_background.xml +++ b/android/app/src/nightly/res/drawable/launch_background.xml @@ -6,7 +6,7 @@ - + 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 index 5f349f7f..c79c58a3 100644 --- a/android/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,9 @@ - + + + diff --git a/android/app/src/nightly/res/mipmap-hdpi/ic_launcher.png b/android/app/src/nightly/res/mipmap-hdpi/ic_launcher.png index a826bb73..8164cc67 100644 Binary files a/android/app/src/nightly/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/nightly/res/mipmap-hdpi/ic_launcher.png 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 index 3743861d..bff95e97 100644 Binary files a/android/app/src/nightly/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/nightly/res/mipmap-mdpi/ic_launcher.png 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 index 1be1daa7..df515ed1 100644 Binary files a/android/app/src/nightly/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/nightly/res/mipmap-xhdpi/ic_launcher.png 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 index 3a8a7832..e58ef25f 100644 Binary files a/android/app/src/nightly/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/nightly/res/mipmap-xxhdpi/ic_launcher.png 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 index 781c9c1a..d39a5e64 100644 Binary files a/android/app/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml index 1041f6ca..a32d12af 100644 --- a/android/app/src/profile/AndroidManifest.xml +++ b/android/app/src/profile/AndroidManifest.xml @@ -1,7 +1,6 @@ - - - - + + \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index 0801de62..8f31e8ca 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,16 +1,3 @@ -buildscript { - ext.kotlin_version = '1.8.22' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.2.1' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() @@ -28,4 +15,4 @@ subprojects { tasks.register("clean", Delete) { delete rootProject.buildDir -} +} \ No newline at end of file diff --git a/android/gradle.properties b/android/gradle.properties index 94adc3a3..ed508580 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4608m android.useAndroidX=true android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 02e5f581..bf6b7385 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Fri Jun 23 08:50:38 CEST 2017 +#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 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 44e62bcf..1e8ffbe3 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,11 +1,25 @@ -include ':app' +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 + }() -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" +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 "1.8.22" apply false +} + +include ':app' \ No newline at end of file diff --git a/appdmg.json b/appdmg.json index 350690e7..eb9b5236 100644 --- a/appdmg.json +++ b/appdmg.json @@ -1,8 +1,18 @@ { "title": "Spotube", - "icon": "assets/spotube-logo.png", + "icon": "assets/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" } + { + "x": 448, + "y": 344, + "type": "link", + "path": "/Applications" + }, + { + "x": 192, + "y": 344, + "type": "file", + "path": "build/macos/Build/Products/Release/Spotube.app" + } ] } \ No newline at end of file diff --git a/assets/backgrounds/xmas-effect.png b/assets/backgrounds/xmas-effect.png new file mode 100644 index 00000000..e7c8eeef Binary files /dev/null and b/assets/backgrounds/xmas-effect.png differ diff --git a/assets/fonts/Cookie-Regular.ttf b/assets/fonts/Cookie-Regular.ttf new file mode 100644 index 00000000..e3ac74eb Binary files /dev/null and b/assets/fonts/Cookie-Regular.ttf differ diff --git a/assets/invidious.jpg b/assets/invidious.jpg new file mode 100644 index 00000000..12c5f107 Binary files /dev/null and b/assets/invidious.jpg differ diff --git a/assets/mobile-screenshots/android-1.jpg b/assets/mobile-screenshots/android-1.jpg index ae1ef8ac..574c86c8 100644 Binary files a/assets/mobile-screenshots/android-1.jpg and b/assets/mobile-screenshots/android-1.jpg differ diff --git a/assets/mobile-screenshots/android-2.jpg b/assets/mobile-screenshots/android-2.jpg index b6668d2b..62da3c86 100644 Binary files a/assets/mobile-screenshots/android-2.jpg and b/assets/mobile-screenshots/android-2.jpg differ diff --git a/assets/mobile-screenshots/android-3.jpg b/assets/mobile-screenshots/android-3.jpg index 87619b21..ccdbee84 100644 Binary files a/assets/mobile-screenshots/android-3.jpg and b/assets/mobile-screenshots/android-3.jpg differ diff --git a/assets/mobile-screenshots/android-4.jpg b/assets/mobile-screenshots/android-4.jpg index 2d1e58e2..278aae21 100644 Binary files a/assets/mobile-screenshots/android-4.jpg and b/assets/mobile-screenshots/android-4.jpg differ diff --git a/assets/mobile-screenshots/android-5.jpg b/assets/mobile-screenshots/android-5.jpg index fc4b2c9a..563b26c6 100644 Binary files a/assets/mobile-screenshots/android-5.jpg and b/assets/mobile-screenshots/android-5.jpg differ diff --git a/assets/mobile-screenshots/android-6.jpg b/assets/mobile-screenshots/android-6.jpg new file mode 100644 index 00000000..fc8d4750 Binary files /dev/null and b/assets/mobile-screenshots/android-6.jpg differ diff --git a/assets/mobile-screenshots/combined.png b/assets/mobile-screenshots/combined.png index 23c761f9..a7a394f6 100644 Binary files a/assets/mobile-screenshots/combined.png and b/assets/mobile-screenshots/combined.png differ diff --git a/assets/patterns/black_white_visualized.jpg b/assets/patterns/black_white_visualized.jpg new file mode 100644 index 00000000..e56a2780 Binary files /dev/null and b/assets/patterns/black_white_visualized.jpg differ diff --git a/assets/patterns/brazil_carnival.jpg b/assets/patterns/brazil_carnival.jpg new file mode 100644 index 00000000..a7cdb3a1 Binary files /dev/null and b/assets/patterns/brazil_carnival.jpg differ diff --git a/assets/patterns/cotton_balls.jpg b/assets/patterns/cotton_balls.jpg new file mode 100644 index 00000000..db6f02a8 Binary files /dev/null and b/assets/patterns/cotton_balls.jpg differ diff --git a/assets/patterns/cute_worms.jpg b/assets/patterns/cute_worms.jpg new file mode 100644 index 00000000..0c9f4fbb Binary files /dev/null and b/assets/patterns/cute_worms.jpg differ diff --git a/assets/patterns/flash_cross_axis.jpg b/assets/patterns/flash_cross_axis.jpg new file mode 100644 index 00000000..c6e52283 Binary files /dev/null and b/assets/patterns/flash_cross_axis.jpg differ diff --git a/assets/patterns/memphis_shapes.jpg b/assets/patterns/memphis_shapes.jpg new file mode 100644 index 00000000..2db8e775 Binary files /dev/null and b/assets/patterns/memphis_shapes.jpg differ diff --git a/assets/patterns/oval_gloomy.jpg b/assets/patterns/oval_gloomy.jpg new file mode 100644 index 00000000..b44bf945 Binary files /dev/null and b/assets/patterns/oval_gloomy.jpg differ diff --git a/assets/patterns/oval_sunny.jpg b/assets/patterns/oval_sunny.jpg new file mode 100644 index 00000000..bc07ae83 Binary files /dev/null and b/assets/patterns/oval_sunny.jpg differ diff --git a/assets/patterns/red_nimbuses.jpg b/assets/patterns/red_nimbuses.jpg new file mode 100644 index 00000000..6527999c Binary files /dev/null and b/assets/patterns/red_nimbuses.jpg differ diff --git a/assets/patterns/tree_bark.jpg b/assets/patterns/tree_bark.jpg new file mode 100644 index 00000000..0dac37d7 Binary files /dev/null and b/assets/patterns/tree_bark.jpg differ diff --git a/assets/patterns/vibrant_pentagons.jpg b/assets/patterns/vibrant_pentagons.jpg new file mode 100644 index 00000000..d9e8d537 Binary files /dev/null and b/assets/patterns/vibrant_pentagons.jpg differ diff --git a/assets/patterns/wiring_pattern.jpg b/assets/patterns/wiring_pattern.jpg new file mode 100644 index 00000000..9fc3b781 Binary files /dev/null and b/assets/patterns/wiring_pattern.jpg differ diff --git a/assets/patterns/zigzags_gloomy.jpg b/assets/patterns/zigzags_gloomy.jpg new file mode 100644 index 00000000..c6ccd2a3 Binary files /dev/null and b/assets/patterns/zigzags_gloomy.jpg differ diff --git a/assets/patterns/zigzags_sunny.jpg b/assets/patterns/zigzags_sunny.jpg new file mode 100644 index 00000000..7470d5ef Binary files /dev/null and b/assets/patterns/zigzags_sunny.jpg differ diff --git a/assets/spotube-logo-foreground.jpg b/assets/spotube-logo-foreground.jpg deleted file mode 100644 index 0af774c9..00000000 Binary files a/assets/spotube-logo-foreground.jpg and /dev/null differ diff --git a/assets/spotube-logo-foreground.png b/assets/spotube-logo-foreground.png new file mode 100644 index 00000000..997d3209 Binary files /dev/null and b/assets/spotube-logo-foreground.png differ diff --git a/assets/spotube-logo-item.png b/assets/spotube-logo-item.png new file mode 100644 index 00000000..476dda29 Binary files /dev/null and b/assets/spotube-logo-item.png differ diff --git a/assets/spotube-logo-light.png b/assets/spotube-logo-light.png new file mode 100644 index 00000000..8c86d621 Binary files /dev/null and b/assets/spotube-logo-light.png differ diff --git a/assets/spotube-logo-macos.png b/assets/spotube-logo-macos.png new file mode 100644 index 00000000..2694b530 Binary files /dev/null and b/assets/spotube-logo-macos.png differ diff --git a/assets/spotube-logo.bmp b/assets/spotube-logo.bmp new file mode 100644 index 00000000..f95df092 Binary files /dev/null and b/assets/spotube-logo.bmp differ diff --git a/assets/spotube-logo.ico b/assets/spotube-logo.ico index 84906308..562110b9 100644 Binary files a/assets/spotube-logo.ico and b/assets/spotube-logo.ico differ diff --git a/assets/spotube-logo.png b/assets/spotube-logo.png index b24a8c23..d46d9ce5 100644 Binary files a/assets/spotube-logo.png and b/assets/spotube-logo.png differ diff --git a/assets/spotube-logo.svg b/assets/spotube-logo.svg deleted file mode 100644 index 5cd88f8e..00000000 --- a/assets/spotube-logo.svg +++ /dev/null @@ -1,349 +0,0 @@ - - diff --git a/assets/spotube-logo_android12.png b/assets/spotube-logo_android12.png index f04e25b0..4af89de4 100644 Binary files a/assets/spotube-logo_android12.png and b/assets/spotube-logo_android12.png differ diff --git a/assets/spotube-nightly-item.png b/assets/spotube-nightly-item.png new file mode 100644 index 00000000..61ef1c5d Binary files /dev/null and b/assets/spotube-nightly-item.png differ diff --git a/assets/spotube-nightly-logo-foreground.jpg b/assets/spotube-nightly-logo-foreground.jpg deleted file mode 100644 index a0c849b6..00000000 Binary files a/assets/spotube-nightly-logo-foreground.jpg and /dev/null differ diff --git a/assets/spotube-nightly-logo-foreground.png b/assets/spotube-nightly-logo-foreground.png new file mode 100644 index 00000000..def278cf Binary files /dev/null and b/assets/spotube-nightly-logo-foreground.png differ diff --git a/assets/spotube-nightly-logo-foreground.svg b/assets/spotube-nightly-logo-foreground.svg new file mode 100644 index 00000000..de2bf370 --- /dev/null +++ b/assets/spotube-nightly-logo-foreground.svg @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/spotube-nightly-logo.png b/assets/spotube-nightly-logo.png index ea7a8b20..269aaabc 100644 Binary files a/assets/spotube-nightly-logo.png and b/assets/spotube-nightly-logo.png differ diff --git a/assets/spotube-nightly-logo.svg b/assets/spotube-nightly-logo.svg deleted file mode 100644 index 7601108e..00000000 --- a/assets/spotube-nightly-logo.svg +++ /dev/null @@ -1,359 +0,0 @@ - - diff --git a/assets/spotube-nightly-logo_android12.png b/assets/spotube-nightly-logo_android12.png index 1a5bf4f1..e5683399 100644 Binary files a/assets/spotube-nightly-logo_android12.png and b/assets/spotube-nightly-logo_android12.png differ diff --git a/assets/spotube-screenshot.png b/assets/spotube-screenshot.png index 44567ae6..c76d2f8a 100644 Binary files a/assets/spotube-screenshot.png and b/assets/spotube-screenshot.png differ diff --git a/assets/spotube-tall-capsule.png b/assets/spotube-tall-capsule.png index 43fb8229..53bff791 100644 Binary files a/assets/spotube-tall-capsule.png and b/assets/spotube-tall-capsule.png differ diff --git a/assets/spotube-wide-capsule-large.png b/assets/spotube-wide-capsule-large.png index 09a93d83..41614c80 100644 Binary files a/assets/spotube-wide-capsule-large.png and b/assets/spotube-wide-capsule-large.png differ diff --git a/assets/spotube-wide-capsule-small.png b/assets/spotube-wide-capsule-small.png index 17566550..e0717dc5 100644 Binary files a/assets/spotube-wide-capsule-small.png and b/assets/spotube-wide-capsule-small.png differ diff --git a/assets/spotube_banner.png b/assets/spotube_banner.png index b2be4539..c9450265 100644 Binary files a/assets/spotube_banner.png and b/assets/spotube_banner.png differ diff --git a/aur-struct/.SRCINFO b/aur-struct/.SRCINFO index ae0b6d10..0460932b 100644 --- a/aur-struct/.SRCINFO +++ b/aur-struct/.SRCINFO @@ -1,17 +1,19 @@ pkgbase = spotube-bin - pkgdesc = Open source Spotify client that doesn't require Premium nor uses Electron! Available for both desktop & mobile! - pkgver = 2.3.0 - pkgrel = 1 - url = https://github.com/KRTirtho/spotube/ - arch = x86_64 - license = BSD-4-Clause - depends = mpv - depends = libappindicator-gtk3 - depends = libsecret - depends = jsoncpp - depends = libnotify - depends = xdg-user-dirs - source = https://github.com/KRTirtho/spotube/releases/download/v2.3.0/Spotube-linux-x86_64.tar.xz - md5sums = 8cd6a7385c5c75d203dccd762f1d63ec +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 index 4663c3ab..4f884f0d 100644 --- a/aur-struct/PKGBUILD +++ b/aur-struct/PKGBUILD @@ -3,15 +3,15 @@ pkgname=spotube-bin pkgver=%{{SPOTUBE_VERSION}}% pkgrel=%{{PKGREL}}% epoch= -pkgdesc="Open source Spotify client that doesn't require Premium nor uses Electron! Available for both desktop & mobile!" +pkgdesc="Open source Music client that doesn't require Premium nor uses Electron! Available for both desktop & mobile!" arch=(x86_64) -url="https://github.com/KRTirtho/spotube/" +url="https://spotube.krtirtho.dev" license=('BSD-4-Clause') groups=() -depends=('mpv' 'libappindicator-gtk3' 'libsecret' 'jsoncpp' 'libnotify' 'xdg-user-dirs') +depends=('mpv' 'libappindicator-gtk3' 'libsecret' 'jsoncpp' 'libnotify' 'xdg-user-dirs' 'webkit2gtk-4.1') makedepends=() checkdepends=() -optdepends=() +optdepends=('yt-dlp-git') provides=() conflicts=() replaces=() diff --git a/bin/gen-credits.dart b/bin/gen-credits.dart deleted file mode 100644 index f8975335..00000000 --- a/bin/gen-credits.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'dart:developer'; -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:http/http.dart'; -import 'package:html/parser.dart'; -import 'package:pub_api_client/pub_api_client.dart'; -import 'package:pubspec_parse/pubspec_parse.dart'; - -void main() async { - final client = PubClient(); - - final pubspec = Pubspec.parse(File('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(res) { - try { - return Pubspec.parse(res.body); - } catch (e) { - final document = parse(res.body); - final pre = document.querySelector('pre'); - if (pre == null) { - log(d.toString()); - rethrow; - } - return Pubspec.parse(pre.text); - } - } - - return get(Uri.parse(d.value)).then(parser).catchError( - (_) => get(Uri.parse(d.value.replaceFirst('/main', '/master'))) - .then(parser), - ); - }, - ), - ); - - // ignore: avoid_print - print( - packageInfo - .map( - (package) => - '1. [${package.name}](${package.latestPubspec.homepage ?? package.url}) - ${package.description.replaceAll('\n', '')}', - ) - .join('\n'), - ); - // ignore: avoid_print - print( - 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'), - ); - exit(0); -} diff --git a/bin/translated_messages.dart b/bin/translated_messages.dart deleted file mode 100644 index 0de398df..00000000 --- a/bin/translated_messages.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -void main(List args) async { - final translatedFile = - jsonDecode(await File('tm.json').readAsString()) as Map; - - for (final MapEntry(:key, :value) in translatedFile.entries) { - print('Updating locale: $key'); - final file = File('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), - ); - - print('✅ Updated locale: $key'); - } -} diff --git a/bin/untranslated_messages.dart b/bin/untranslated_messages.dart deleted file mode 100644 index e19f9a07..00000000 --- a/bin/untranslated_messages.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -/// Generate JSON output for untranslated messages with English values -/// for quick translation in ChatGPT -/// -/// Usage: dart bin/untranslated_messages.dart [locale?] -/// -/// Example: dart bin/untranslated_messages.dart -/// -/// or with specific locale (e.g. bn (Bengali)) -/// -/// Example: dart bin/untranslated_messages.dart bn - -void main(List args) { - final file = jsonDecode( - File('untranslated_messages.json').readAsStringSync(), - ) as Map; - - final englishMessages = - jsonDecode(File('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>(), - ); - } - - print( - "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.", - ); - // ignore: avoid_print - print( - const JsonEncoder.withIndent(' ').convert( - args.isNotEmpty ? messagesWithValues[args.first] : messagesWithValues, - ), - ); -} diff --git a/bin/verify-pkgbuild.dart b/bin/verify-pkgbuild.dart deleted file mode 100644 index 587e63d0..00000000 --- a/bin/verify-pkgbuild.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -void main() { - Process.run("sh", ["-c", '"./scripts/pkgbuild2json.sh aur-struct/PKGBUILD"']) - .then((result) { - try { - final pkgbuild = jsonDecode(result.stdout); - if (pkgbuild["version"] != - Platform.environment["RELEASE_VERSION"]?.substring(1)) { - throw Exception( - "PKGBUILD version doesn't match current RELEASE_VERSION"); - } - if (pkgbuild["release"] != "1") { - throw Exception("In new releases pkgrel should be 1"); - } - } catch (e) { - // ignore: avoid_print - print("[Failed to parse PKGBUILD] $e"); - } - }); -} diff --git a/build.yaml b/build.yaml index f074d6e1..76771f22 100644 --- a/build.yaml +++ b/build.yaml @@ -2,4 +2,28 @@ targets: $default: sources: exclude: - - bin/*.dart \ No newline at end of file + - 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/choco-struct/spotube.nuspec b/choco-struct/spotube.nuspec index 1cef4354..e3588d98 100644 --- a/choco-struct/spotube.nuspec +++ b/choco-struct/spotube.nuspec @@ -1,5 +1,6 @@ - + @@ -12,34 +13,39 @@ spotube (Install) Kingkor Roy Tirtho - https://github.com/KRTirtho/spotube/ - https://rawcdn.githack.com/KRTirtho/spotube/7edb0bb834eb18c05551e30a891720a6abf53dbe/assets/spotube-logo.png + https://spotube.krtirtho.dev + + https://rawcdn.githack.com/KRTirtho/spotube/7edb0bb834eb18c05551e30a891720a6abf53dbe/assets/spotube-logo.png 2022 Spotube https://github.com/KRTirtho/spotube/blob/master/LICENSE true https://github.com/KRTirtho/spotube - https://github.com/KRTirtho/spotube#readme + https://spotube.krtirtho.dev https://github.com/KRTirtho/spotube/issues/new - spotube music audio spotify youtube flutter - 🎧 Open source Spotify client that doesn't require Premium nor uses Electron! Available for both desktop & mobile! + 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 spotify client. It utilizes the power - of Spotify & Youtube's public API & creates a hazardless, performant & resource - friendly User Experience + 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 Spotify or YouTube 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 Spotify. Purchasing Spotify Premium is usually the best way to support their valuable creations.) - - Time synced lyrics - - Downloadable tracks + # 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}}% diff --git a/choco-struct/tools/LICENSE.txt b/choco-struct/tools/LICENSE.txt index 6d460a42..1a285107 100644 --- a/choco-struct/tools/LICENSE.txt +++ b/choco-struct/tools/LICENSE.txt @@ -2,7 +2,7 @@ BSD 4-Clause License -Copyright (c) 2022 Kingkor Roy Tirtho. All rights reserved. +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: diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 00000000..b2ba8ebd --- /dev/null +++ b/cli/README.md @@ -0,0 +1,4 @@ +## 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 new file mode 100644 index 00000000..26190d4c --- /dev/null +++ b/cli/cli.dart @@ -0,0 +1,22 @@ +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 new file mode 100644 index 00000000..e0c254ff --- /dev/null +++ b/cli/commands/build.dart @@ -0,0 +1,29 @@ +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 new file mode 100644 index 00000000..4216553a --- /dev/null +++ b/cli/commands/build/android.dart @@ -0,0 +1,92 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:collection/collection.dart'; +import 'package:path/path.dart'; +import 'package:xml/xml.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}", + ); + + await dotEnvFile.writeAsString( + "\nENABLE_UPDATE_CHECK=0" + "\nHIDE_DONATIONS=1", + mode: FileMode.append, + ); + + final androidManifestFile = File( + join(cwd.path, "android", "app", "src", "main", "AndroidManifest.xml")); + + final androidManifestXml = + XmlDocument.parse(await androidManifestFile.readAsString()); + + final deletingElement = + androidManifestXml.findAllElements("meta-data").firstWhereOrNull( + (el) => + el.getAttribute("android:name") == + "com.google.android.gms.car.application", + ); + + deletingElement?.parent?.children.remove(deletingElement); + + await androidManifestFile.writeAsString( + androidManifestXml.toXmlString(pretty: true), + ); + + await shell.run( + """ + dart run build_runner clean + dart run build_runner build --delete-conflicting-outputs + flutter build appbundle --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"), + ); + + final ogAppbundleFile = File( + join( + cwd.path, + "build", + "app", + "outputs", + "bundle", + "${CliEnv.channel.name}Release", + "app-${CliEnv.channel.name}-release.aab", + ), + ); + + await ogAppbundleFile.copy( + join(cwd.path, "build", "Spotube-playstore-all-arch.aab"), + ); + + stdout.writeln("✅ Built Android Apk and Appbundle"); + } +} diff --git a/cli/commands/build/common.dart b/cli/commands/build/common.dart new file mode 100644 index 00000000..30906b3c --- /dev/null +++ b/cli/commands/build/common.dart @@ -0,0 +1,68 @@ +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 flutter_distributor + """, + ); + } + + String get architecture => parent?.argResults?.option("arch") as String; +} diff --git a/cli/commands/build/ios.dart b/cli/commands/build/ios.dart new file mode 100644 index 00000000..6460f9ed --- /dev/null +++ b/cli/commands/build/ios.dart @@ -0,0 +1,29 @@ +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 new file mode 100644 index 00000000..3fd8a0b9 --- /dev/null +++ b/cli/commands/build/linux.dart @@ -0,0 +1,121 @@ +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( + "flutter_distributor package --platform=linux --targets=deb", + ); + + if (architecture == "x86") { + await shell.run( + "flutter_distributor 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", "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(); + } + + stdout.writeln("✅ Linux building done"); + } +} diff --git a/cli/commands/build/macos.dart b/cli/commands/build/macos.dart new file mode 100644 index 00000000..e8f34b77 --- /dev/null +++ b/cli/commands/build/macos.dart @@ -0,0 +1,42 @@ +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")} + flutter_distributor 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 new file mode 100644 index 00000000..c44ed52f --- /dev/null +++ b/cli/commands/build/windows.dart @@ -0,0 +1,119 @@ +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( + "flutter_distributor 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 new file mode 100644 index 00000000..6bad7a44 --- /dev/null +++ b/cli/commands/credits.dart @@ -0,0 +1,121 @@ +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 new file mode 100644 index 00000000..e26b8078 --- /dev/null +++ b/cli/commands/install-dependencies.dart @@ -0,0 +1,78 @@ +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(); + + switch (argResults!.option("platform")) { + case "windows": + break; + case "linux": + await shell.run( + """ + sudo apt-get update -y + sudo apt-get install -y 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 + """, + ); + 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 new file mode 100644 index 00000000..43c4ea49 --- /dev/null +++ b/cli/commands/translated.dart @@ -0,0 +1,39 @@ +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 new file mode 100644 index 00000000..dadcd8b5 --- /dev/null +++ b/cli/commands/untranslated.dart @@ -0,0 +1,48 @@ +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 new file mode 100644 index 00000000..33cc5df1 --- /dev/null +++ b/cli/core/env.dart @@ -0,0 +1,24 @@ +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/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 00000000..7e7e7f67 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1 @@ +extensions: diff --git a/drift_schemas/app_db/drift_schema_v1.json b/drift_schemas/app_db/drift_schema_v1.json new file mode 100644 index 00000000..b894446c --- /dev/null +++ b/drift_schemas/app_db/drift_schema_v1.json @@ -0,0 +1 @@ +{"_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_v2.json b/drift_schemas/app_db/drift_schema_v2.json new file mode 100644 index 00000000..668afb3f --- /dev/null +++ b/drift_schemas/app_db/drift_schema_v2.json @@ -0,0 +1 @@ +{"_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 new file mode 100644 index 00000000..93e0ef1b --- /dev/null +++ b/drift_schemas/app_db/drift_schema_v3.json @@ -0,0 +1 @@ +{"_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 new file mode 100644 index 00000000..fc50a6f8 --- /dev/null +++ b/drift_schemas/app_db/drift_schema_v4.json @@ -0,0 +1 @@ +{"_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 new file mode 100644 index 00000000..eefe0205 --- /dev/null +++ b/drift_schemas/app_db/drift_schema_v5.json @@ -0,0 +1 @@ +{"_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 new file mode 100644 index 00000000..8a646be1 --- /dev/null +++ b/drift_schemas/app_db/drift_schema_v6.json @@ -0,0 +1 @@ +{"_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/flutter_launcher_icons-nightly.yaml b/flutter_launcher_icons-nightly.yaml index c6892d4b..770033bd 100644 --- a/flutter_launcher_icons-nightly.yaml +++ b/flutter_launcher_icons-nightly.yaml @@ -2,5 +2,5 @@ flutter_launcher_icons: android: true ios: true image_path: "assets/spotube-nightly-logo.png" - adaptive_icon_foreground: "assets/spotube-nightly-logo-foreground.jpg" + adaptive_icon_foreground: "assets/spotube-nightly-logo-foreground.png" adaptive_icon_background: "#242832" diff --git a/flutter_launcher_icons.yaml b/flutter_launcher_icons.yaml new file mode 100644 index 00000000..2c558583 --- /dev/null +++ b/flutter_launcher_icons.yaml @@ -0,0 +1,29 @@ +# flutter pub run flutter_launcher_icons +flutter_launcher_icons: + image_path: "assets/spotube-logo.png" + + android: true + # image_path_android: "assets/icon/icon.png" + min_sdk_android: 21 # android min sdk min:16, default 21 + adaptive_icon_background: "#242832" + adaptive_icon_foreground: "assets/spotube-logo-foreground.png" + # adaptive_icon_monochrome: "assets/icon/monochrome.png" + + ios: true + # image_path_ios: "assets/icon/icon.png" + remove_alpha_channel_ios: true + # image_path_ios_dark_transparent: "assets/icon/icon_dark.png" + # image_path_ios_tinted_grayscale: "assets/icon/icon_tinted.png" + # desaturate_tinted_to_grayscale_ios: true + + web: + generate: false + + windows: + generate: true + image_path: "assets/spotube-logo.png" + icon_size: 48 # min:48, max:256, default: 48 + + macos: + generate: true + image_path: "assets/spotube-logo-macos.png" diff --git a/ios/HomePlayerWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/HomePlayerWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/ios/HomePlayerWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/HomePlayerWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/HomePlayerWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..23058801 --- /dev/null +++ b/ios/HomePlayerWidget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/HomePlayerWidget/Assets.xcassets/Contents.json b/ios/HomePlayerWidget/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ios/HomePlayerWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/HomePlayerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/ios/HomePlayerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/ios/HomePlayerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/HomePlayerWidget/HomePlayerWidget.swift b/ios/HomePlayerWidget/HomePlayerWidget.swift new file mode 100644 index 00000000..8808aae1 --- /dev/null +++ b/ios/HomePlayerWidget/HomePlayerWidget.swift @@ -0,0 +1,86 @@ +// +// 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 new file mode 100644 index 00000000..68158b53 --- /dev/null +++ b/ios/HomePlayerWidget/HomePlayerWidgetBundle.swift @@ -0,0 +1,16 @@ +// +// 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 new file mode 100644 index 00000000..0f118fb7 --- /dev/null +++ b/ios/HomePlayerWidget/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/ios/HomePlayerWidgetExtension.entitlements b/ios/HomePlayerWidgetExtension.entitlements new file mode 100644 index 00000000..58165678 --- /dev/null +++ b/ios/HomePlayerWidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.spotube_home_player_widget + + + diff --git a/ios/Podfile b/ios/Podfile index bc3dcaa6..7235f482 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0b75217f..31ffe436 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,10 +1,13 @@ PODS: - - app_links (0.0.1): + - app_links (0.0.2): - Flutter - audio_service (0.0.1): - Flutter - audio_session (0.0.1): - Flutter + - bonsoir_darwin (0.0.1): + - Flutter + - FlutterMacOS - device_info_plus (0.0.1): - Flutter - DKImagePickerController/Core (4.3.4): @@ -44,29 +47,25 @@ PODS: - file_selector_ios (0.0.1): - Flutter - Flutter (1.0.0) - - flutter_inappwebview (0.0.1): + - flutter_broadcasts (0.0.1): - Flutter - - flutter_inappwebview/Core (= 0.0.1) - - OrderedSet (~> 5.0) - - flutter_inappwebview/Core (0.0.1): + - flutter_discord_rpc (0.0.1): - Flutter - - OrderedSet (~> 5.0) - - flutter_keyboard_visibility (0.0.1): + - flutter_inappwebview_ios (0.0.1): - Flutter - - flutter_mailer (0.0.1): + - flutter_inappwebview_ios/Core (= 0.0.1) + - OrderedSet (~> 6.0.3) + - flutter_inappwebview_ios/Core (0.0.1): - Flutter - - flutter_native_splash (0.0.1): + - 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 - - fluttertoast (0.0.2): + - home_widget (0.0.1): - Flutter - - Toast - - FMDB (2.7.5): - - FMDB/standard (= 2.7.5) - - FMDB/standard (2.7.5) - image_picker_ios (0.0.1): - Flutter - integration_test (0.0.1): @@ -75,14 +74,17 @@ PODS: - Flutter - media_kit_native_event_loop (1.0.0): - Flutter - - metadata_god (0.0.1) - - OrderedSet (5.0.0) + - 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.1.1): + - permission_handler_apple (9.3.0): - Flutter - SDWebImage (5.18.8): - SDWebImage/Core (= 5.18.8) @@ -90,11 +92,31 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - sqflite (0.0.3): + - sqflite_darwin (0.0.4): - Flutter - - FMDB (>= 2.7.5) + - FlutterMacOS + - sqlite3 (3.47.1): + - sqlite3/common (= 3.47.1) + - sqlite3/common (3.47.1) + - sqlite3/dbstatvtab (3.47.1): + - sqlite3/common + - sqlite3/fts5 (3.47.1): + - sqlite3/common + - sqlite3/perf-threadsafe (3.47.1): + - sqlite3/common + - sqlite3/rtree (3.47.1): + - sqlite3/common + - sqlite3_flutter_libs (0.0.1): + - Flutter + - FlutterMacOS + - sqlite3 (~> 3.47.1) + - sqlite3/dbstatvtab + - sqlite3/fts5 + - sqlite3/perf-threadsafe + - sqlite3/rtree - SwiftyGif (5.4.4) - - Toast (4.0.0) + - system_theme (0.0.1): + - Flutter - url_launcher_ios (0.0.1): - Flutter @@ -102,38 +124,41 @@ DEPENDENCIES: - app_links (from `.symlinks/plugins/app_links/ios`) - audio_service (from `.symlinks/plugins/audio_service/ios`) - audio_session (from `.symlinks/plugins/audio_session/ios`) + - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`) - 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`) - Flutter (from `Flutter`) - - flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`) - - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - - flutter_mailer (from `.symlinks/plugins/flutter_mailer/ios`) + - 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`) - - fluttertoast (from `.symlinks/plugins/fluttertoast/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`) - media_kit_libs_ios_audio (from `.symlinks/plugins/media_kit_libs_ios_audio/ios`) - media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/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 (from `.symlinks/plugins/sqflite/ios`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) + - system_theme (from `.symlinks/plugins/system_theme/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) SPEC REPOS: trunk: - DKImagePickerController - DKPhotoGallery - - FMDB - OrderedSet - SDWebImage + - sqlite3 - SwiftyGif - - Toast EXTERNAL SOURCES: app_links: @@ -142,6 +167,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/audio_service/ios" audio_session: :path: ".symlinks/plugins/audio_session/ios" + bonsoir_darwin: + :path: ".symlinks/plugins/bonsoir_darwin/darwin" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" file_picker: @@ -150,20 +177,20 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/file_selector_ios/ios" Flutter: :path: Flutter - flutter_inappwebview: - :path: ".symlinks/plugins/flutter_inappwebview/ios" - flutter_keyboard_visibility: - :path: ".symlinks/plugins/flutter_keyboard_visibility/ios" - flutter_mailer: - :path: ".symlinks/plugins/flutter_mailer/ios" + 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" - fluttertoast: - :path: ".symlinks/plugins/fluttertoast/ios" + home_widget: + :path: ".symlinks/plugins/home_widget/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" integration_test: @@ -174,6 +201,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/media_kit_native_event_loop/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: @@ -182,45 +211,52 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/permission_handler_apple/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - sqflite: - :path: ".symlinks/plugins/sqflite/ios" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + sqlite3_flutter_libs: + :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" + system_theme: + :path: ".symlinks/plugins/system_theme/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: - app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875 + app_links: e7a6750a915a9e161c58d91bc610e8cd1d4d0ad0 audio_service: f509d65da41b9521a61f1c404dd58651f265a567 - audio_session: 4f3e461722055d21515cf3261b64c973c062f345 - device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea + audio_session: 088d2483ebd1dc43f51d253d4a1c517d9a2e7207 + bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 + device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de - file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b + file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 + file_selector_ios: f0670c1064a8c8450e38145d8043160105d0b97c Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - flutter_inappwebview: acd4fc0f012cefd09015000c241137d82f01ba62 - flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 - flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83 - flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef - flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be + flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882 + flutter_discord_rpc: e1c342f29ceb9dd76cdc01db59a70c93bb4d9ec5 + flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4 + flutter_native_splash: e8a1e01082d97a8099d973f919f57904c925008a + flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 flutter_sharing_intent: e35380d0e1501d7111dbb7e46d5ac6339da6da98 - fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265 - FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a - image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 - integration_test: 13825b8a9334a850581300559b8839134b124670 + home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57 + image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 + integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 media_kit_libs_ios_audio: 8f39d96a9c630685dfb844c289bd1d114c486fb3 media_kit_native_event_loop: 99111eded5acbdc9c2738021ea6550dd36ca8837 - metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7 - OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c - package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7 - path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 - permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 + metadata_god: 4bbd8523cdb5d42c5e59d2fabad01ff8f4bc53f9 + open_file_ios: 461db5853723763573e140de3193656f91990d9e + OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 + package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 SDWebImage: a81bbb3ba4ea5f810f4069c68727cb118467a04a - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 - sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + sqlite3: 1e522f0938463e44b7faf50393b40bdc1e1e456d + sqlite3_flutter_libs: 1b4e98da20ebd4e9b1240269b78cdcf492dbe9f3 SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f - Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 - url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 + system_theme: bfc1b0913d08f38d8c6bbe94b202a58df599d9f7 + url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe -PODFILE CHECKSUM: 5129d2e80ab0dfc533f262cedf032011b1dfe4fd +PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 13f624a4..88a40d6f 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -36,8 +36,21 @@ 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; @@ -79,6 +92,17 @@ 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 */ @@ -148,6 +172,14 @@ 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 = ""; }; @@ -155,6 +187,20 @@ 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; @@ -189,6 +235,15 @@ ); 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 */ @@ -199,6 +254,8 @@ 4238A4985255EC9F93067739 /* Pods_dev.framework */, BAC36FC304DBD4E8A8C00694 /* Pods_nightly.framework */, B5F91A319C771EEC978B238A /* Pods_stable.framework */, + E612EC3A2D0F07A90022720C /* WidgetKit.framework */, + E612EC3C2D0F07A90022720C /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -272,8 +329,13 @@ 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 */, @@ -290,6 +352,7 @@ B536BDA02B405DB1009B3CE4 /* stable.app */, B536BDBF2B405FDE009B3CE4 /* dev.app */, B536BDE42B4060B3009B3CE4 /* nightly.app */, + E612EC392D0F07A90022720C /* HomePlayerWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -297,6 +360,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + E6F17DBA2D0F352C00BC2FA2 /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -324,10 +388,14 @@ 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; @@ -346,6 +414,7 @@ B536BD992B405DB1009B3CE4 /* Embed Frameworks */, B536BD9A2B405DB1009B3CE4 /* Thin Binary */, A6D446F111DE4C4A202BE7F7 /* [CP] Embed Pods Frameworks */, + 2DEF3CF18D30E819C0FF4BCE /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -368,6 +437,7 @@ B536BDB62B405FDE009B3CE4 /* Embed Frameworks */, B536BDB72B405FDE009B3CE4 /* Thin Binary */, 244D41CE80E4BC0FFD63F8C6 /* [CP] Embed Pods Frameworks */, + 4DD66E9E53D92195290872BE /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -390,6 +460,7 @@ B536BDD82B4060B3009B3CE4 /* Embed Frameworks */, B536BDD92B4060B3009B3CE4 /* Thin Binary */, D566C841A84D807A607F6DE5 /* [CP] Embed Pods Frameworks */, + 5C9D945D6569D9C3AC420285 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -400,12 +471,35 @@ 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 = { @@ -413,6 +507,9 @@ CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; }; + E612EC382D0F07A80022720C = { + CreatedOnToolsVersion = 16.2; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -432,6 +529,7 @@ B536BD8C2B405DB1009B3CE4 /* stable */, B536BDAB2B405FDE009B3CE4 /* dev */, B536BDCD2B4060B3009B3CE4 /* nightly */, + E612EC382D0F07A80022720C /* HomePlayerWidgetExtension */, ); }; /* End PBXProject section */ @@ -481,6 +579,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E612EC372D0F07A80022720C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -523,6 +628,23 @@ 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; @@ -539,6 +661,57 @@ 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; @@ -613,7 +786,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; }; A6D446F111DE4C4A202BE7F7 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; @@ -742,6 +915,23 @@ 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; @@ -803,8 +993,23 @@ ); 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; @@ -881,6 +1086,7 @@ 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; @@ -1010,6 +1216,7 @@ 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; @@ -1033,6 +1240,7 @@ 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; @@ -1055,6 +1263,7 @@ 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; @@ -1078,6 +1287,7 @@ 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; @@ -1100,6 +1310,7 @@ 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; @@ -1177,6 +1388,7 @@ 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; @@ -1200,6 +1412,7 @@ 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; @@ -1275,6 +1488,7 @@ 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; @@ -1297,6 +1511,7 @@ 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; @@ -1369,6 +1584,7 @@ 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; @@ -1391,6 +1607,7 @@ 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; @@ -1413,6 +1630,7 @@ 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; @@ -1436,6 +1654,7 @@ 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; @@ -1459,6 +1678,7 @@ 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; @@ -1481,6 +1701,7 @@ 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; @@ -1503,6 +1724,7 @@ 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; @@ -1525,6 +1747,7 @@ 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; @@ -1602,6 +1825,7 @@ 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; @@ -1625,6 +1849,7 @@ 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; @@ -1648,6 +1873,7 @@ 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; @@ -1723,6 +1949,7 @@ 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; @@ -1745,6 +1972,7 @@ 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; @@ -1767,6 +1995,7 @@ 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; @@ -1839,6 +2068,7 @@ 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; @@ -1861,6 +2091,7 @@ 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; @@ -1883,6 +2114,7 @@ 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; @@ -1903,8 +2135,9 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + 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; @@ -1926,8 +2159,9 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + 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; @@ -1949,8 +2183,9 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + 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; @@ -1972,8 +2207,9 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + 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; @@ -1994,8 +2230,9 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + 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; @@ -2016,8 +2253,9 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + 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; @@ -2038,8 +2276,9 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + 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; @@ -2060,8 +2299,9 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + 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; @@ -2082,8 +2322,9 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + 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; @@ -2161,6 +2402,7 @@ 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; @@ -2184,6 +2426,7 @@ 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; @@ -2207,6 +2450,7 @@ 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; @@ -2228,8 +2472,9 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + 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; @@ -2305,6 +2550,7 @@ 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; @@ -2327,6 +2573,7 @@ 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; @@ -2349,6 +2596,7 @@ 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; @@ -2369,8 +2617,9 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + 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; @@ -2443,6 +2692,7 @@ 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; @@ -2465,6 +2715,7 @@ 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; @@ -2487,6 +2738,7 @@ 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; @@ -2507,8 +2759,9 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + 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; @@ -2525,6 +2778,498 @@ }; 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 */ @@ -2623,6 +3368,25 @@ 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/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 5e31d3d3..c53e2b31 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -48,6 +48,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 70693e4a..f512ac86 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -7,6 +7,11 @@ import Flutter _ 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 index dbc4596b..2185c35d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-1024x1024@1x.png 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 index 4836771d..172bc383 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@1x.png 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 index 90954ce9..876617d9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@2x.png 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 index 9c0ebd5f..fec86d87 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@3x.png 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 index 94cd79be..fbb0f45a 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@1x.png 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 index ff70cab7..854e1e45 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@2x.png 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 index 6cdda1b6..420d2a17 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@3x.png 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 index 90954ce9..876617d9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@1x.png 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 index 5184f84f..6b7a608d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@2x.png 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 index 57e21a75..d871fa3f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@3x.png 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 index 93e157b6..e77b38df 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-50x50@1x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-50x50@1x.png 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 index d175beb2..358cb28f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-50x50@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-50x50@2x.png 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 index 6d634c87..87f4290b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-57x57@1x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-57x57@1x.png 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 index 22da4950..53c10a09 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-57x57@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-57x57@2x.png 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 index 57e21a75..d871fa3f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-60x60@2x.png 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 index 3cfd01c2..4bb39789 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-60x60@3x.png 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 index a826bb73..8164cc67 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-72x72@1x.png 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 index 3a8a7832..e58ef25f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-72x72@2x.png 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 index f233322b..f5e1ae5a 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-76x76@1x.png 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 index 2f5b082a..99bd36c9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-76x76@2x.png 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 index e4ecc19a..8d199658 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/Contents.json index e8947587..1ce0f517 100644 --- a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/Contents.json +++ b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/Contents.json @@ -1 +1 @@ -{"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":"50x50","idiom":"ipad","filename":"AppIcon-nightly-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"AppIcon-nightly-50x50@2x.png","scale":"2x"},{"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":"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 +{"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/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index 05843b52..d0d98aa1 100644 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1 +1 @@ -{"images":[{"size":"20x20","idiom":"iphone","filename":"AppIcon-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"AppIcon-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"AppIcon-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"AppIcon-40x40@3x.png","scale":"3x"},{"size":"50x50","idiom":"ipad","filename":"AppIcon-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"AppIcon-50x50@2x.png","scale":"2x"},{"size":"57x57","idiom":"iphone","filename":"AppIcon-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"AppIcon-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"AppIcon-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"AppIcon-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"AppIcon-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"AppIcon-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"AppIcon-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"AppIcon-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"AppIcon-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"AppIcon-40x40@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"AppIcon-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"AppIcon-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"AppIcon-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"AppIcon-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"AppIcon-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"AppIcon-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file +{"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 new file mode 100644 index 00000000..59407b44 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png 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 new file mode 100644 index 00000000..5eacaa5f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png 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 new file mode 100644 index 00000000..bbb9f839 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png 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 new file mode 100644 index 00000000..28d0d8a8 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png 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 new file mode 100644 index 00000000..b1df0e72 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png 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 new file mode 100644 index 00000000..24b76e25 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png 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 new file mode 100644 index 00000000..5c0b6d57 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png 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 new file mode 100644 index 00000000..bbb9f839 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png 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 new file mode 100644 index 00000000..bdc5656b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png 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 new file mode 100644 index 00000000..c03c89fe Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png 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 new file mode 100644 index 00000000..40b88968 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png 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 new file mode 100644 index 00000000..2050f427 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png 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 new file mode 100644 index 00000000..d1ccab30 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png 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 new file mode 100644 index 00000000..47c629f3 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png 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 new file mode 100644 index 00000000..c03c89fe Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png 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 new file mode 100644 index 00000000..22d28c12 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png 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 new file mode 100644 index 00000000..c9e5cfad Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png 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 new file mode 100644 index 00000000..3450fb00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png 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 new file mode 100644 index 00000000..3dd3eda3 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png 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 new file mode 100644 index 00000000..2e69c843 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png 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 new file mode 100644 index 00000000..f769eb6e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage.png b/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage.png index 5a197da3..3ff2a2da 100644 Binary files a/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage.png and b/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage.png differ diff --git a/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage@2x.png b/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage@2x.png index efc7e6c8..8e2bb197 100644 Binary files a/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage@2x.png and b/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage@3x.png b/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage@3x.png index 593877e0..d301093a 100644 Binary files a/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage@3x.png and b/ios/Runner/Assets.xcassets/BrandingImage.imageset/BrandingImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage.png b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage.png index 80579983..3ff2a2da 100644 Binary files a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage.png and b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage.png differ diff --git a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@2x.png b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@2x.png index 0bcf138d..8e2bb197 100644 Binary files a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@2x.png and b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@3x.png b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@3x.png index c7d01776..d301093a 100644 Binary files a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@3x.png and b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png index 203fc77a..4bebb9de 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png and b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/background.png b/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/background.png index 203fc77a..4bebb9de 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/background.png and b/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/background.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png index c5e4aca0..6e04efdc 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png index 41222c6c..51a669aa 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png index 5ab19d32..cc79cb85 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage.png index 86d3fe74..d8f7cc0e 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage.png and b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@2x.png index dbb0ea02..17a2c373 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@2x.png and b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@3x.png index 12eb5531..db53f016 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@3x.png and b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard index 208e1c53..ec8a1de3 100644 --- a/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -22,7 +22,7 @@ - + diff --git a/ios/Runner/Base.lproj/LaunchScreenNightly.storyboard b/ios/Runner/Base.lproj/LaunchScreenNightly.storyboard index 6869214f..645a417f 100644 --- a/ios/Runner/Base.lproj/LaunchScreenNightly.storyboard +++ b/ios/Runner/Base.lproj/LaunchScreenNightly.storyboard @@ -22,7 +22,7 @@ - + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 8e103cfa..8d6c09a2 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,70 +1,82 @@ - - 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 - - - \ No newline at end of file + + 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.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 00000000..58165678 --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.spotube_home_player_widget + + + diff --git a/ios/dev.entitlements b/ios/dev.entitlements new file mode 100644 index 00000000..58165678 --- /dev/null +++ b/ios/dev.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.spotube_home_player_widget + + + diff --git a/ios/nightly.entitlements b/ios/nightly.entitlements new file mode 100644 index 00000000..58165678 --- /dev/null +++ b/ios/nightly.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.spotube_home_player_widget + + + diff --git a/ios/stable.entitlements b/ios/stable.entitlements new file mode 100644 index 00000000..58165678 --- /dev/null +++ b/ios/stable.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.spotube_home_player_widget + + + diff --git a/l10n.yaml b/l10n.yaml index b49b5df4..ffab1c86 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -1,4 +1,5 @@ arb-dir: lib/l10n template-arb-file: app_en.arb -output-localization-file: app_localizations.dart +output-dir: lib/l10n/generated untranslated-messages-file: untranslated_messages.json +synthetic-package: false diff --git a/lib/collections/assets.gen.dart b/lib/collections/assets.gen.dart index 8a2950fb..09b6cdd2 100644 --- a/lib/collections/assets.gen.dart +++ b/lib/collections/assets.gen.dart @@ -9,6 +9,17 @@ import 'package:flutter/widgets.dart'; +class $AssetsBackgroundsGen { + const $AssetsBackgroundsGen(); + + /// File path: assets/backgrounds/xmas-effect.png + AssetGenImage get xmasEffect => + const AssetGenImage('assets/backgrounds/xmas-effect.png'); + + /// List of all assets + List get values => [xmasEffect]; +} + class $AssetsLogosGen { const $AssetsLogosGen(); @@ -24,6 +35,84 @@ class $AssetsLogosGen { List get values => [songlinkTransparent, songlink]; } +class $AssetsPatternsGen { + const $AssetsPatternsGen(); + + /// File path: assets/patterns/black_white_visualized.jpg + AssetGenImage get blackWhiteVisualized => + const AssetGenImage('assets/patterns/black_white_visualized.jpg'); + + /// File path: assets/patterns/brazil_carnival.jpg + AssetGenImage get brazilCarnival => + const AssetGenImage('assets/patterns/brazil_carnival.jpg'); + + /// File path: assets/patterns/cotton_balls.jpg + AssetGenImage get cottonBalls => + const AssetGenImage('assets/patterns/cotton_balls.jpg'); + + /// File path: assets/patterns/cute_worms.jpg + AssetGenImage get cuteWorms => + const AssetGenImage('assets/patterns/cute_worms.jpg'); + + /// File path: assets/patterns/flash_cross_axis.jpg + AssetGenImage get flashCrossAxis => + const AssetGenImage('assets/patterns/flash_cross_axis.jpg'); + + /// File path: assets/patterns/memphis_shapes.jpg + AssetGenImage get memphisShapes => + const AssetGenImage('assets/patterns/memphis_shapes.jpg'); + + /// File path: assets/patterns/oval_gloomy.jpg + AssetGenImage get ovalGloomy => + const AssetGenImage('assets/patterns/oval_gloomy.jpg'); + + /// File path: assets/patterns/oval_sunny.jpg + AssetGenImage get ovalSunny => + const AssetGenImage('assets/patterns/oval_sunny.jpg'); + + /// File path: assets/patterns/red_nimbuses.jpg + AssetGenImage get redNimbuses => + const AssetGenImage('assets/patterns/red_nimbuses.jpg'); + + /// File path: assets/patterns/tree_bark.jpg + AssetGenImage get treeBark => + const AssetGenImage('assets/patterns/tree_bark.jpg'); + + /// File path: assets/patterns/vibrant_pentagons.jpg + AssetGenImage get vibrantPentagons => + const AssetGenImage('assets/patterns/vibrant_pentagons.jpg'); + + /// File path: assets/patterns/wiring_pattern.jpg + AssetGenImage get wiringPattern => + const AssetGenImage('assets/patterns/wiring_pattern.jpg'); + + /// File path: assets/patterns/zigzags_gloomy.jpg + AssetGenImage get zigzagsGloomy => + const AssetGenImage('assets/patterns/zigzags_gloomy.jpg'); + + /// File path: assets/patterns/zigzags_sunny.jpg + AssetGenImage get zigzagsSunny => + const AssetGenImage('assets/patterns/zigzags_sunny.jpg'); + + /// List of all assets + List get values => [ + blackWhiteVisualized, + brazilCarnival, + cottonBalls, + cuteWorms, + flashCrossAxis, + memphisShapes, + ovalGloomy, + ovalSunny, + redNimbuses, + treeBark, + vibrantPentagons, + wiringPattern, + zigzagsGloomy, + zigzagsSunny + ]; +} + class $AssetsTutorialGen { const $AssetsTutorialGen(); @@ -43,33 +132,47 @@ class $AssetsTutorialGen { class Assets { Assets._(); + static const String license = 'LICENSE'; static const AssetGenImage albumPlaceholder = AssetGenImage('assets/album-placeholder.png'); + static const $AssetsBackgroundsGen backgrounds = $AssetsBackgroundsGen(); static const AssetGenImage bengaliPatternsBg = AssetGenImage('assets/bengali-patterns-bg.jpg'); static const AssetGenImage branding = AssetGenImage('assets/branding.png'); static const AssetGenImage emptyBox = AssetGenImage('assets/empty_box.png'); + static const AssetGenImage invidious = AssetGenImage('assets/invidious.jpg'); static const AssetGenImage jiosaavn = AssetGenImage('assets/jiosaavn.png'); static const AssetGenImage likedTracks = AssetGenImage('assets/liked-tracks.jpg'); static const $AssetsLogosGen logos = $AssetsLogosGen(); + static const $AssetsPatternsGen patterns = $AssetsPatternsGen(); static const AssetGenImage placeholder = AssetGenImage('assets/placeholder.png'); static const AssetGenImage spotubeHeroBanner = AssetGenImage('assets/spotube-hero-banner.png'); static const AssetGenImage spotubeLogoForeground = - AssetGenImage('assets/spotube-logo-foreground.jpg'); + AssetGenImage('assets/spotube-logo-foreground.png'); + static const AssetGenImage spotubeLogoItem = + AssetGenImage('assets/spotube-logo-item.png'); + static const AssetGenImage spotubeLogoLight = + AssetGenImage('assets/spotube-logo-light.png'); + static const AssetGenImage spotubeLogoMacos = + AssetGenImage('assets/spotube-logo-macos.png'); + static const AssetGenImage spotubeLogoBmp = + AssetGenImage('assets/spotube-logo.bmp'); static const String spotubeLogoIco = 'assets/spotube-logo.ico'; static const AssetGenImage spotubeLogoPng = AssetGenImage('assets/spotube-logo.png'); - static const String spotubeLogoSvg = 'assets/spotube-logo.svg'; static const AssetGenImage spotubeLogoAndroid12 = AssetGenImage('assets/spotube-logo_android12.png'); - static const AssetGenImage spotubeNightlyLogoForeground = - AssetGenImage('assets/spotube-nightly-logo-foreground.jpg'); - static const AssetGenImage spotubeNightlyLogoPng = + static const AssetGenImage spotubeNightlyItem = + AssetGenImage('assets/spotube-nightly-item.png'); + static const AssetGenImage spotubeNightlyLogoForegroundPng = + AssetGenImage('assets/spotube-nightly-logo-foreground.png'); + static const String spotubeNightlyLogoForegroundSvg = + 'assets/spotube-nightly-logo-foreground.svg'; + static const AssetGenImage spotubeNightlyLogo = AssetGenImage('assets/spotube-nightly-logo.png'); - static const String spotubeNightlyLogoSvg = 'assets/spotube-nightly-logo.svg'; static const AssetGenImage spotubeNightlyLogoAndroid12 = AssetGenImage('assets/spotube-nightly-logo_android12.png'); static const AssetGenImage spotubeScreenshot = @@ -88,23 +191,29 @@ class Assets { AssetGenImage('assets/user-placeholder.png'); /// List of all assets - List get values => [ + static List get values => [ + license, albumPlaceholder, bengaliPatternsBg, branding, emptyBox, + invidious, jiosaavn, likedTracks, placeholder, spotubeHeroBanner, spotubeLogoForeground, + spotubeLogoItem, + spotubeLogoLight, + spotubeLogoMacos, + spotubeLogoBmp, spotubeLogoIco, spotubeLogoPng, - spotubeLogoSvg, spotubeLogoAndroid12, - spotubeNightlyLogoForeground, - spotubeNightlyLogoPng, - spotubeNightlyLogoSvg, + spotubeNightlyItem, + spotubeNightlyLogoForegroundPng, + spotubeNightlyLogoForegroundSvg, + spotubeNightlyLogo, spotubeNightlyLogoAndroid12, spotubeScreenshot, spotubeTallCapsule, @@ -117,10 +226,17 @@ class Assets { } class AssetGenImage { - const AssetGenImage(this._assetName); + const AssetGenImage( + this._assetName, { + this.size, + this.flavors = const {}, + }); final String _assetName; + final Size? size; + final Set flavors; + Image image({ Key? key, AssetBundle? bundle, @@ -139,7 +255,7 @@ class AssetGenImage { ImageRepeat repeat = ImageRepeat.noRepeat, Rect? centerSlice, bool matchTextDirection = false, - bool gaplessPlayback = false, + bool gaplessPlayback = true, bool isAntiAlias = false, String? package, FilterQuality filterQuality = FilterQuality.low, diff --git a/lib/collections/cache_keys.dart b/lib/collections/cache_keys.dart deleted file mode 100644 index bca13322..00000000 --- a/lib/collections/cache_keys.dart +++ /dev/null @@ -1,21 +0,0 @@ -abstract class LocalStorageKeys { - static String saveTrackLyrics = 'save_track_lyrics'; - static String recommendationMarket = 'recommendation_market'; - static String ytSearchFormate = 'youtube_search_format'; - - static String clientId = 'clientId'; - static String clientSecret = 'clientSecret'; - static String accessToken = 'accessToken'; - static String refreshToken = 'refreshToken'; - static String expiration = "expiration"; - static String geniusAccessToken = "genius_access_token"; - - static String themeMode = "theme_mode"; - static String nextTrackHotKey = "next_track_hot_key"; - static String prevTrackHotKey = "prev_track_hot_key"; - static String playPauseHotKey = "play_pause_hot_key"; - - static String volume = "volume"; - - static String windowSizeInfo = "window_size_info"; -} diff --git a/lib/collections/env.dart b/lib/collections/env.dart index 50fe1e6a..feb2a2db 100644 --- a/lib/collections/env.dart +++ b/lib/collections/env.dart @@ -1,8 +1,13 @@ import 'package:envied/envied.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.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: 'SPOTIFY_SECRETS') @@ -14,6 +19,11 @@ abstract class Env { @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; + static final spotifySecrets = rawSpotifySecrets.split(',').map((e) { final secrets = e.trim().split(":").map((e) => e.trim()); return { @@ -25,8 +35,20 @@ abstract class Env { @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; + + @EnviedField(varName: "DISABLE_SPOTIFY_IMAGES", defaultValue: "0") + static final String _disableSpotifyImages = _Env._disableSpotifyImages; + + static bool get disableSpotifyImages => _disableSpotifyImages == "1"; + + static ReleaseChannel get releaseChannel => _releaseChannel == "stable" + ? ReleaseChannel.stable + : ReleaseChannel.nightly; + static bool get enableUpdateChecker => - DesktopTools.platform.isFlatpak || _enableUpdateChecker == "1"; + kIsFlatpak || _enableUpdateChecker == "1"; static String discordAppId = "1176718791388975124"; } diff --git a/lib/collections/fake.dart b/lib/collections/fake.dart index 8f5f9e8b..8af40e71 100644 --- a/lib/collections/fake.dart +++ b/lib/collections/fake.dart @@ -1,12 +1,14 @@ import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/models/spotify/home_feed.dart'; import 'package:spotube/models/spotify_friends.dart'; +import 'package:spotube/provider/history/summary.dart'; abstract class FakeData { static final Image image = Image() ..height = 1 ..width = 1 - ..url = "url"; + ..url = "https://dummyimage.com/100x100/cfcfcf/cfcfcf.jpg"; static final Followers followers = Followers() ..href = "text" @@ -92,6 +94,7 @@ abstract class FakeData { ..trackNumber = 1 ..type = "type" ..uri = "uri" + ..externalIds = externalIds ..isPlayable = true ..explicit = false ..linkedFrom = trackLink; @@ -196,4 +199,62 @@ abstract class FakeData { ), ], ); + + static final feedSection = SpotifyHomeFeedSection( + typename: "HomeGenericSectionData", + uri: "spotify:section:lol", + title: "Dummy", + items: [ + for (int i = 0; i < 10; i++) + SpotifyHomeFeedSectionItem( + typename: "PlaylistResponseWrapper", + playlist: SpotifySectionPlaylist( + name: "Playlist $i", + description: "Really super important description $i", + format: "daily-mix", + images: [ + const SpotifySectionItemImage( + height: 1, + width: 1, + url: "https://dummyimage.com/100x100/cfcfcf/cfcfcf.jpg", + ), + ], + owner: "Spotify", + uri: "spotify:playlist:id", + ), + ) + ], + ); + + 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 new file mode 100644 index 00000000..033d3a79 --- /dev/null +++ b/lib/collections/fonts.gen.dart @@ -0,0 +1,18 @@ +/// GENERATED CODE - DO NOT MODIFY BY HAND +/// ***************************************************** +/// FlutterGen +/// ***************************************************** + +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: directives_ordering,unnecessary_import,implicit_dynamic_list_literal,deprecated_member_use + +class FontFamily { + FontFamily._(); + + /// Font family: BootstrapIcons + static const String bootstrapIcons = 'BootstrapIcons'; + + /// Font family: RadixIcons + static const String radixIcons = 'RadixIcons'; +} diff --git a/lib/collections/formatters.dart b/lib/collections/formatters.dart new file mode 100644 index 00000000..0aed9e9f --- /dev/null +++ b/lib/collections/formatters.dart @@ -0,0 +1,8 @@ +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 index e861dde7..a7936ee2 100644 --- a/lib/collections/gradients.dart +++ b/lib/collections/gradients.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; const gradients = [ LinearGradient(colors: [ diff --git a/lib/collections/initializers.dart b/lib/collections/initializers.dart index 9627de1c..976661fc 100644 --- a/lib/collections/initializers.dart +++ b/lib/collections/initializers.dart @@ -1,9 +1,10 @@ import 'dart:io'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; + +import 'package:spotube/utils/platform.dart'; import 'package:win32_registry/win32_registry.dart'; Future registerWindowsScheme(String scheme) async { - if (!DesktopTools.platform.isWindows) return; + if (!kIsWindows) return; String appPath = Platform.resolvedExecutable; String protocolRegKey = 'Software\\Classes\\$scheme'; diff --git a/lib/collections/intents.dart b/lib/collections/intents.dart index 6f42113c..42c580ca 100644 --- a/lib/collections/intents.dart +++ b/lib/collections/intents.dart @@ -3,11 +3,10 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; import 'package:spotube/collections/routes.dart'; -import 'package:spotube/components/player/player_controls.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.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'; @@ -17,8 +16,6 @@ class PlayPauseIntent extends Intent { } class PlayPauseAction extends Action { - final logger = getLogger(PlayPauseAction); - @override invoke(intent) async { if (PlayerControls.focusNode.canRequestFocus) { @@ -35,7 +32,7 @@ class PlayPauseAction extends Action { } class NavigationIntent extends Intent { - final GoRouter router; + final AppRouter router; final String path; const NavigationIntent(this.router, this.path); } @@ -43,7 +40,7 @@ class NavigationIntent extends Intent { class NavigationAction extends Action { @override invoke(intent) { - intent.router.go(intent.path); + intent.router.navigateNamed(intent.path); return null; } } @@ -51,32 +48,49 @@ class NavigationAction extends Action { enum HomeTabs { browse, search, - library, + lyrics, + userPlaylists, + userArtists, + userAlbums, + userLocalLibrary, + userDownloads, } class HomeTabIntent extends Intent { - final WidgetRef ref; + final AppRouter router; final HomeTabs tab; - const HomeTabIntent(this.ref, {required this.tab}); + const HomeTabIntent(this.router, {required this.tab}); } class HomeTabAction extends Action { @override invoke(intent) { - final router = intent.ref.read(routerProvider); + final router = intent.router; switch (intent.tab) { case HomeTabs.browse: - router.go("/"); + router.navigate(const HomeRoute()); break; case HomeTabs.search: - router.go("/search"); - break; - case HomeTabs.library: - router.go("/library"); + router.navigate(const SearchRoute()); break; case HomeTabs.lyrics: - router.go("/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; @@ -92,8 +106,8 @@ class SeekIntent extends Intent { class SeekAction extends Action { @override invoke(intent) async { - final playlist = intent.ref.read(ProxyPlaylistNotifier.provider); - if (playlist.isFetching) { + final isFetchingActiveTrack = intent.ref.read(queryingTrackInfoProvider); + if (isFetchingActiveTrack) { DirectionalFocusAction().invoke( DirectionalFocusIntent( intent.forward ? TraversalDirection.right : TraversalDirection.left, @@ -101,7 +115,7 @@ class SeekAction extends Action { ); return null; } - final position = (await audioPlayer.position ?? Duration.zero).inSeconds; + final position = audioPlayer.position.inSeconds; await audioPlayer.seek( Duration( seconds: intent.forward ? position + 5 : position - 5, diff --git a/lib/collections/language_codes.dart b/lib/collections/language_codes.dart index 4b7a3a90..75d1e65d 100644 --- a/lib/collections/language_codes.dart +++ b/lib/collections/language_codes.dart @@ -81,10 +81,10 @@ abstract class LanguageLocals { // name: "Bashkir", // nativeName: "башҡорт теле", // ), - // "eu": const ISOLanguageName( - // name: "Basque", - // nativeName: "euskara,", - // ), + "eu": const ISOLanguageName( + name: "Basque", + nativeName: "Euskara", + ), // "be": const ISOLanguageName( // name: "Belarusian", // nativeName: "Беларуская", @@ -157,10 +157,10 @@ abstract class LanguageLocals { // name: "Croatian", // nativeName: "hrvatski", // ), - // "cs": const ISOLanguageName( - // name: "Czech", - // nativeName: "česky, čeština", - // ), + "cs": const ISOLanguageName( + name: "Czech", + nativeName: "česky, čeština", + ), // "da": const ISOLanguageName( // name: "Danish", // nativeName: "dansk", @@ -197,10 +197,10 @@ abstract class LanguageLocals { // name: "Fijian", // nativeName: "vosa Vakaviti", // ), - // "fi": const ISOLanguageName( - // name: "Finnish", - // nativeName: "suomi", - // ), + "fi": const ISOLanguageName( + name: "Finnish", + nativeName: "suomi", + ), "fr": const ISOLanguageName( name: "French", nativeName: "français", @@ -213,10 +213,10 @@ abstract class LanguageLocals { // name: "Galician", // nativeName: "Galego", // ), - // "ka": const ISOLanguageName( - // name: "Georgian", - // nativeName: "ქართული", - // ), + "ka": const ISOLanguageName( + name: "Georgian", + nativeName: "ქართული", + ), "de": const ISOLanguageName( name: "German", nativeName: "Deutsch", @@ -265,10 +265,10 @@ abstract class LanguageLocals { // name: "Interlingua", // nativeName: "Interlingua", // ), - // "id": const ISOLanguageName( - // name: "Indonesian", - // nativeName: "Bahasa Indonesia", - // ), + "id": const ISOLanguageName( + name: "Indonesian", + nativeName: "Bahasa Indonesia", + ), // "ie": const ISOLanguageName( // name: "Interlingue", // nativeName: "Occidental", @@ -354,8 +354,8 @@ abstract class LanguageLocals { // nativeName: "KiKongo", // ), "ko": const ISOLanguageName( - name: "Korean", - nativeName: "한국어 (韓國語), 조선말 (朝鮮語)", + name: "Korean", + nativeName: "한국어 (韓國語), 조선말 (朝鮮語)", ), // "ku": const ISOLanguageName( // name: "Kurdish", @@ -625,10 +625,10 @@ abstract class LanguageLocals { // name: "Swedish", // nativeName: "svenska", // ), - // "ta": const ISOLanguageName( - // name: "Tamil", - // nativeName: "தமிழ்", - // ), + "ta": const ISOLanguageName( + name: "Tamil", + nativeName: "தமிழ்", + ), // "te": const ISOLanguageName( // name: "Telugu", // nativeName: "తెలుగు", @@ -637,10 +637,10 @@ abstract class LanguageLocals { // name: "Tajik", // nativeName: "тоҷикӣ, toğikī, تاجیکی‎", // ), - // "th": const ISOLanguageName( - // name: "Thai", - // nativeName: "ไทย", - // ), + "th": const ISOLanguageName( + name: "Thai", + nativeName: "ไทย", + ), // "ti": const ISOLanguageName( // name: "Tigrinya", // nativeName: "ትግርኛ", @@ -653,10 +653,10 @@ abstract class LanguageLocals { // name: "Turkmen", // nativeName: "Türkmen, Түркмен", // ), - // "tl": const ISOLanguageName( - // name: "Tagalog", - // nativeName: "Wikang Tagalog, ᜏᜒᜃᜅ᜔ ᜆᜄᜎᜓᜄ᜔", - // ), + "tl": const ISOLanguageName( + name: "Tagalog", + nativeName: "Wikang Tagalog", + ), // "tn": const ISOLanguageName( // name: "Tswana", // nativeName: "Setswana", diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 43d0cf2e..543bc471 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -1,214 +1,235 @@ -import 'package:catcher_2/catcher_2.dart'; -import 'package:flutter/foundation.dart' hide Category; -import 'package:flutter/widgets.dart'; -import 'package:go_router/go_router.dart'; +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:spotify/spotify.dart' hide Search; -import 'package:spotube/pages/album/album.dart'; -import 'package:spotube/pages/getting_started/getting_started.dart'; -import 'package:spotube/pages/home/genres/genre_playlists.dart'; -import 'package:spotube/pages/home/genres/genres.dart'; -import 'package:spotube/pages/home/home.dart'; -import 'package:spotube/pages/lastfm_login/lastfm_login.dart'; -import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; -import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart'; -import 'package:spotube/pages/lyrics/mini_lyrics.dart'; -import 'package:spotube/pages/playlist/liked_playlist.dart'; -import 'package:spotube/pages/playlist/playlist.dart'; -import 'package:spotube/pages/search/search.dart'; -import 'package:spotube/pages/settings/blacklist.dart'; -import 'package:spotube/pages/settings/about.dart'; -import 'package:spotube/pages/settings/logs.dart'; -import 'package:spotube/pages/track/track.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:spotube/components/shared/spotube_page_route.dart'; -import 'package:spotube/pages/artist/artist.dart'; -import 'package:spotube/pages/library/library.dart'; -import 'package:spotube/pages/desktop_login/login_tutorial.dart'; -import 'package:spotube/pages/desktop_login/desktop_login.dart'; -import 'package:spotube/pages/lyrics/lyrics.dart'; -import 'package:spotube/pages/root/root_app.dart'; -import 'package:spotube/pages/settings/settings.dart'; -import 'package:spotube/pages/mobile_login/mobile_login.dart'; -final rootNavigatorKey = Catcher2.navigatorKey; -final shellRouteNavigatorKey = GlobalKey(); -final routerProvider = Provider((ref) { - return GoRouter( - navigatorKey: rootNavigatorKey, - routes: [ - ShellRoute( - navigatorKey: shellRouteNavigatorKey, - builder: (context, state, child) => RootApp(child: child), - routes: [ - GoRoute( - path: "/", - redirect: (context, state) async { - final authNotifier = - ref.read(AuthenticationNotifier.provider.notifier); - final json = await authNotifier.box.get(authNotifier.cacheKey); +final rootNavigatorKey = GlobalKey(); - if (json?["cookie"] == null && - !KVStoreService.doneGettingStarted) { - return "/getting-started"; - } +@AutoRouterConfig(replaceInRouteName: 'Screen|Page,Route') +class AppRouter extends RootStackRouter { + final WidgetRef ref; - return null; - }, - pageBuilder: (context, state) => - const SpotubePage(child: HomePage()), - routes: [ - GoRoute( - path: "genres", - pageBuilder: (context, state) => - const SpotubePage(child: GenrePage()), - ), - GoRoute( - path: "genre/:categoryId", - pageBuilder: (context, state) => SpotubePage( - child: GenrePlaylistsPage( - category: state.extra as Category, - ), + 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 auth = await ref.read(authenticationProvider.future); + + if (auth == null && !KVStoreService.doneGettingStarted) { + resolver.redirect(const GettingStartedRoute()); + } else { + resolver.next(true); + } + }, ), - ), - ], - ), - GoRoute( - path: "/search", - name: "Search", - pageBuilder: (context, state) => - const SpotubePage(child: SearchPage()), - ), - GoRoute( - path: "/library", - name: "Library", - pageBuilder: (context, state) => - const SpotubePage(child: LibraryPage()), - routes: [ - GoRoute( - path: "generate", - pageBuilder: (context, state) => - const SpotubePage(child: PlaylistGeneratorPage()), - routes: [ - GoRoute( - path: "result", - pageBuilder: (context, state) => SpotubePage( - child: PlaylistGenerateResultPage( - state: - state.extra as PlaylistGenerateResultRouteState, - ), - ), - ), - ]), - ]), - GoRoute( - path: "/lyrics", - name: "Lyrics", - pageBuilder: (context, state) => - const SpotubePage(child: LyricsPage()), - ), - GoRoute( - path: "/settings", - pageBuilder: (context, state) => const SpotubePage( - child: SettingsPage(), + ], ), - routes: [ - GoRoute( - path: "blacklist", - pageBuilder: (context, state) => SpotubeSlidePage( - child: const BlackListPage(), + AutoRoute( + path: "home/genres", + page: GenreRoute.page, + ), + AutoRoute( + path: "home/genre/:categoryId", + page: GenrePlaylistsRoute.page, + ), + AutoRoute( + path: "home/feeds/:feedId", + page: HomeFeedSectionRoute.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: "library/generate", + page: PlaylistGeneratorRoute.page, + ), + AutoRoute( + path: "library/generate/result", + page: PlaylistGenerateResultRoute.page, + ), + AutoRoute( + path: "lyrics", + page: LyricsRoute.page, + ), + AutoRoute( + path: "settings", + page: SettingsRoute.page, + ), + AutoRoute( + path: "settings/blacklist", + page: BlackListRoute.page, + ), + if (!kIsWeb) + AutoRoute( + path: "settings/logs", + page: LogsRoute.page, ), - if (!kIsWeb) - GoRoute( - path: "logs", - pageBuilder: (context, state) => SpotubeSlidePage( - child: const LogsPage(), - ), + AutoRoute( + path: "settings/about", + page: AboutSpotubeRoute.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; + }, ), - GoRoute( - path: "about", - pageBuilder: (context, state) => SpotubeSlidePage( - child: const AboutSpotube(), - ), - ), - ], - ), - GoRoute( - path: "/album/:id", - pageBuilder: (context, state) { - assert(state.extra is AlbumSimple); - return SpotubePage( - child: AlbumPage(album: state.extra as AlbumSimple), - ); - }, - ), - GoRoute( - path: "/artist/:id", - pageBuilder: (context, state) { - assert(state.pathParameters["id"] != null); - return SpotubePage( - child: ArtistPage(state.pathParameters["id"]!)); - }, - ), - GoRoute( - path: "/playlist/:id", - pageBuilder: (context, state) { - assert(state.extra is PlaylistSimple); - return SpotubePage( - child: state.pathParameters["id"] == "user-liked-tracks" - ? LikedPlaylistPage(playlist: state.extra as PlaylistSimple) - : PlaylistPage(playlist: state.extra as PlaylistSimple), - ); - }, - ), - GoRoute( - path: "/track/:id", - pageBuilder: (context, state) { - final id = state.pathParameters["id"]!; - return SpotubePage( - child: TrackPage(trackId: id), - ); - }, - ), - ], - ), - GoRoute( - path: "/mini-player", - parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) => SpotubePage( - child: MiniLyricsPage(prevSize: state.extra as Size), + ], + ), + 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, + ), + ], ), - ), - GoRoute( - path: "/getting-started", - parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) => const SpotubePage( - child: GettingStarting(), + CustomRoute( + transitionsBuilder: TransitionsBuilders.slideBottom, + durationInMilliseconds: 200, + reverseDurationInMilliseconds: 200, + path: "/player/queue", + page: PlayerQueueRoute.page, ), - ), - GoRoute( - path: "/login", - parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) => SpotubePage( - child: kIsMobile ? const WebViewLogin() : const DesktopLoginPage(), + CustomRoute( + transitionsBuilder: TransitionsBuilders.slideBottom, + durationInMilliseconds: 200, + reverseDurationInMilliseconds: 200, + path: "/player/sources", + page: PlayerTrackSourcesRoute.page, ), - ), - GoRoute( - path: "/login-tutorial", - parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) => const SpotubePage( - child: LoginTutorial(), + CustomRoute( + transitionsBuilder: TransitionsBuilders.slideBottom, + durationInMilliseconds: 200, + reverseDurationInMilliseconds: 200, + path: "/player/lyrics", + page: PlayerLyricsRoute.page, ), - ), - GoRoute( - path: "/lastfm-login", - parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) => - const SpotubePage(child: LastFMLoginPage()), - ), - ], - ); -}); + AutoRoute( + path: "/mini-player", + page: MiniLyricsRoute.page, + // parentNavigatorKey: rootNavigatorKey, + ), + AutoRoute( + path: "/getting-started", + page: GettingStartedRoute.page, + // parentNavigatorKey: rootNavigatorKey, + ), + AutoRoute( + path: "/login", + page: WebViewLoginRoute.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 new file mode 100644 index 00000000..1d608896 --- /dev/null +++ b/lib/collections/routes.gr.dart @@ -0,0 +1,1174 @@ +// 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 _i43; +import 'package:flutter/material.dart' as _i44; +import 'package:shadcn_flutter/shadcn_flutter.dart' as _i46; +import 'package:spotify/spotify.dart' as _i45; +import 'package:spotube/models/spotify/recommendation_seeds.dart' as _i47; +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 _i9; +import 'package:spotube/pages/home/feed/feed_section.dart' as _i10; +import 'package:spotube/pages/home/genres/genre_playlists.dart' as _i8; +import 'package:spotube/pages/home/genres/genres.dart' as _i7; +import 'package:spotube/pages/home/home.dart' as _i11; +import 'package:spotube/pages/lastfm_login/lastfm_login.dart' as _i12; +import 'package:spotube/pages/library/library.dart' as _i13; +import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart' + as _i23; +import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart' + as _i22; +import 'package:spotube/pages/library/user_albums.dart' as _i37; +import 'package:spotube/pages/library/user_artists.dart' as _i38; +import 'package:spotube/pages/library/user_downloads.dart' as _i39; +import 'package:spotube/pages/library/user_local_tracks/local_folder.dart' + as _i15; +import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart' + as _i40; +import 'package:spotube/pages/library/user_playlists.dart' as _i41; +import 'package:spotube/pages/lyrics/lyrics.dart' as _i17; +import 'package:spotube/pages/lyrics/mini_lyrics.dart' as _i18; +import 'package:spotube/pages/mobile_login/mobile_login.dart' as _i42; +import 'package:spotube/pages/player/lyrics.dart' as _i19; +import 'package:spotube/pages/player/queue.dart' as _i20; +import 'package:spotube/pages/player/sources.dart' as _i21; +import 'package:spotube/pages/playlist/liked_playlist.dart' as _i14; +import 'package:spotube/pages/playlist/playlist.dart' as _i24; +import 'package:spotube/pages/profile/profile.dart' as _i25; +import 'package:spotube/pages/root/root_app.dart' as _i26; +import 'package:spotube/pages/search/search.dart' as _i27; +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 _i16; +import 'package:spotube/pages/settings/settings.dart' as _i28; +import 'package:spotube/pages/stats/albums/albums.dart' as _i29; +import 'package:spotube/pages/stats/artists/artists.dart' as _i30; +import 'package:spotube/pages/stats/fees/fees.dart' as _i34; +import 'package:spotube/pages/stats/minutes/minutes.dart' as _i31; +import 'package:spotube/pages/stats/playlists/playlists.dart' as _i33; +import 'package:spotube/pages/stats/stats.dart' as _i32; +import 'package:spotube/pages/stats/streams/streams.dart' as _i35; +import 'package:spotube/pages/track/track.dart' as _i36; + +/// generated route for +/// [_i1.AboutSpotubePage] +class AboutSpotubeRoute extends _i43.PageRouteInfo { + const AboutSpotubeRoute({List<_i43.PageRouteInfo>? children}) + : super( + AboutSpotubeRoute.name, + initialChildren: children, + ); + + static const String name = 'AboutSpotubeRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i1.AboutSpotubePage(); + }, + ); +} + +/// generated route for +/// [_i2.AlbumPage] +class AlbumRoute extends _i43.PageRouteInfo { + AlbumRoute({ + _i44.Key? key, + required String id, + required _i45.AlbumSimple album, + List<_i43.PageRouteInfo>? children, + }) : super( + AlbumRoute.name, + args: AlbumRouteArgs( + key: key, + id: id, + album: album, + ), + rawPathParams: {'id': id}, + initialChildren: children, + ); + + static const String name = 'AlbumRoute'; + + static _i43.PageInfo page = _i43.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 _i44.Key? key; + + final String id; + + final _i45.AlbumSimple album; + + @override + String toString() { + return 'AlbumRouteArgs{key: $key, id: $id, album: $album}'; + } +} + +/// generated route for +/// [_i3.ArtistPage] +class ArtistRoute extends _i43.PageRouteInfo { + ArtistRoute({ + required String artistId, + _i44.Key? key, + List<_i43.PageRouteInfo>? children, + }) : super( + ArtistRoute.name, + args: ArtistRouteArgs( + artistId: artistId, + key: key, + ), + rawPathParams: {'id': artistId}, + initialChildren: children, + ); + + static const String name = 'ArtistRoute'; + + static _i43.PageInfo page = _i43.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 _i44.Key? key; + + @override + String toString() { + return 'ArtistRouteArgs{artistId: $artistId, key: $key}'; + } +} + +/// generated route for +/// [_i4.BlackListPage] +class BlackListRoute extends _i43.PageRouteInfo { + const BlackListRoute({List<_i43.PageRouteInfo>? children}) + : super( + BlackListRoute.name, + initialChildren: children, + ); + + static const String name = 'BlackListRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i4.BlackListPage(); + }, + ); +} + +/// generated route for +/// [_i5.ConnectControlPage] +class ConnectControlRoute extends _i43.PageRouteInfo { + const ConnectControlRoute({List<_i43.PageRouteInfo>? children}) + : super( + ConnectControlRoute.name, + initialChildren: children, + ); + + static const String name = 'ConnectControlRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i5.ConnectControlPage(); + }, + ); +} + +/// generated route for +/// [_i6.ConnectPage] +class ConnectRoute extends _i43.PageRouteInfo { + const ConnectRoute({List<_i43.PageRouteInfo>? children}) + : super( + ConnectRoute.name, + initialChildren: children, + ); + + static const String name = 'ConnectRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i6.ConnectPage(); + }, + ); +} + +/// generated route for +/// [_i7.GenrePage] +class GenreRoute extends _i43.PageRouteInfo { + const GenreRoute({List<_i43.PageRouteInfo>? children}) + : super( + GenreRoute.name, + initialChildren: children, + ); + + static const String name = 'GenreRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i7.GenrePage(); + }, + ); +} + +/// generated route for +/// [_i8.GenrePlaylistsPage] +class GenrePlaylistsRoute extends _i43.PageRouteInfo { + GenrePlaylistsRoute({ + _i44.Key? key, + required String id, + required _i45.Category category, + List<_i43.PageRouteInfo>? children, + }) : super( + GenrePlaylistsRoute.name, + args: GenrePlaylistsRouteArgs( + key: key, + id: id, + category: category, + ), + rawPathParams: {'categoryId': id}, + initialChildren: children, + ); + + static const String name = 'GenrePlaylistsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return _i8.GenrePlaylistsPage( + key: args.key, + id: args.id, + category: args.category, + ); + }, + ); +} + +class GenrePlaylistsRouteArgs { + const GenrePlaylistsRouteArgs({ + this.key, + required this.id, + required this.category, + }); + + final _i44.Key? key; + + final String id; + + final _i45.Category category; + + @override + String toString() { + return 'GenrePlaylistsRouteArgs{key: $key, id: $id, category: $category}'; + } +} + +/// generated route for +/// [_i9.GettingStartedPage] +class GettingStartedRoute extends _i43.PageRouteInfo { + const GettingStartedRoute({List<_i43.PageRouteInfo>? children}) + : super( + GettingStartedRoute.name, + initialChildren: children, + ); + + static const String name = 'GettingStartedRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i9.GettingStartedPage(); + }, + ); +} + +/// generated route for +/// [_i10.HomeFeedSectionPage] +class HomeFeedSectionRoute + extends _i43.PageRouteInfo { + HomeFeedSectionRoute({ + _i46.Key? key, + required String sectionUri, + List<_i43.PageRouteInfo>? children, + }) : super( + HomeFeedSectionRoute.name, + args: HomeFeedSectionRouteArgs( + key: key, + sectionUri: sectionUri, + ), + rawPathParams: {'feedId': sectionUri}, + initialChildren: children, + ); + + static const String name = 'HomeFeedSectionRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + final pathParams = data.inheritedPathParams; + final args = data.argsAs( + orElse: () => HomeFeedSectionRouteArgs( + sectionUri: pathParams.getString('feedId'))); + return _i10.HomeFeedSectionPage( + key: args.key, + sectionUri: args.sectionUri, + ); + }, + ); +} + +class HomeFeedSectionRouteArgs { + const HomeFeedSectionRouteArgs({ + this.key, + required this.sectionUri, + }); + + final _i46.Key? key; + + final String sectionUri; + + @override + String toString() { + return 'HomeFeedSectionRouteArgs{key: $key, sectionUri: $sectionUri}'; + } +} + +/// generated route for +/// [_i11.HomePage] +class HomeRoute extends _i43.PageRouteInfo { + const HomeRoute({List<_i43.PageRouteInfo>? children}) + : super( + HomeRoute.name, + initialChildren: children, + ); + + static const String name = 'HomeRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i11.HomePage(); + }, + ); +} + +/// generated route for +/// [_i12.LastFMLoginPage] +class LastFMLoginRoute extends _i43.PageRouteInfo { + const LastFMLoginRoute({List<_i43.PageRouteInfo>? children}) + : super( + LastFMLoginRoute.name, + initialChildren: children, + ); + + static const String name = 'LastFMLoginRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i12.LastFMLoginPage(); + }, + ); +} + +/// generated route for +/// [_i13.LibraryPage] +class LibraryRoute extends _i43.PageRouteInfo { + const LibraryRoute({List<_i43.PageRouteInfo>? children}) + : super( + LibraryRoute.name, + initialChildren: children, + ); + + static const String name = 'LibraryRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i13.LibraryPage(); + }, + ); +} + +/// generated route for +/// [_i14.LikedPlaylistPage] +class LikedPlaylistRoute extends _i43.PageRouteInfo { + LikedPlaylistRoute({ + _i44.Key? key, + required _i45.PlaylistSimple playlist, + List<_i43.PageRouteInfo>? children, + }) : super( + LikedPlaylistRoute.name, + args: LikedPlaylistRouteArgs( + key: key, + playlist: playlist, + ), + initialChildren: children, + ); + + static const String name = 'LikedPlaylistRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return _i14.LikedPlaylistPage( + key: args.key, + playlist: args.playlist, + ); + }, + ); +} + +class LikedPlaylistRouteArgs { + const LikedPlaylistRouteArgs({ + this.key, + required this.playlist, + }); + + final _i44.Key? key; + + final _i45.PlaylistSimple playlist; + + @override + String toString() { + return 'LikedPlaylistRouteArgs{key: $key, playlist: $playlist}'; + } +} + +/// generated route for +/// [_i15.LocalLibraryPage] +class LocalLibraryRoute extends _i43.PageRouteInfo { + LocalLibraryRoute({ + required String location, + _i44.Key? key, + bool isDownloads = false, + bool isCache = false, + List<_i43.PageRouteInfo>? children, + }) : super( + LocalLibraryRoute.name, + args: LocalLibraryRouteArgs( + location: location, + key: key, + isDownloads: isDownloads, + isCache: isCache, + ), + initialChildren: children, + ); + + static const String name = 'LocalLibraryRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return _i15.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 _i44.Key? key; + + final bool isDownloads; + + final bool isCache; + + @override + String toString() { + return 'LocalLibraryRouteArgs{location: $location, key: $key, isDownloads: $isDownloads, isCache: $isCache}'; + } +} + +/// generated route for +/// [_i16.LogsPage] +class LogsRoute extends _i43.PageRouteInfo { + const LogsRoute({List<_i43.PageRouteInfo>? children}) + : super( + LogsRoute.name, + initialChildren: children, + ); + + static const String name = 'LogsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i16.LogsPage(); + }, + ); +} + +/// generated route for +/// [_i17.LyricsPage] +class LyricsRoute extends _i43.PageRouteInfo { + const LyricsRoute({List<_i43.PageRouteInfo>? children}) + : super( + LyricsRoute.name, + initialChildren: children, + ); + + static const String name = 'LyricsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i17.LyricsPage(); + }, + ); +} + +/// generated route for +/// [_i18.MiniLyricsPage] +class MiniLyricsRoute extends _i43.PageRouteInfo { + MiniLyricsRoute({ + _i46.Key? key, + required _i46.Size prevSize, + List<_i43.PageRouteInfo>? children, + }) : super( + MiniLyricsRoute.name, + args: MiniLyricsRouteArgs( + key: key, + prevSize: prevSize, + ), + initialChildren: children, + ); + + static const String name = 'MiniLyricsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return _i18.MiniLyricsPage( + key: args.key, + prevSize: args.prevSize, + ); + }, + ); +} + +class MiniLyricsRouteArgs { + const MiniLyricsRouteArgs({ + this.key, + required this.prevSize, + }); + + final _i46.Key? key; + + final _i46.Size prevSize; + + @override + String toString() { + return 'MiniLyricsRouteArgs{key: $key, prevSize: $prevSize}'; + } +} + +/// generated route for +/// [_i19.PlayerLyricsPage] +class PlayerLyricsRoute extends _i43.PageRouteInfo { + const PlayerLyricsRoute({List<_i43.PageRouteInfo>? children}) + : super( + PlayerLyricsRoute.name, + initialChildren: children, + ); + + static const String name = 'PlayerLyricsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i19.PlayerLyricsPage(); + }, + ); +} + +/// generated route for +/// [_i20.PlayerQueuePage] +class PlayerQueueRoute extends _i43.PageRouteInfo { + const PlayerQueueRoute({List<_i43.PageRouteInfo>? children}) + : super( + PlayerQueueRoute.name, + initialChildren: children, + ); + + static const String name = 'PlayerQueueRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i20.PlayerQueuePage(); + }, + ); +} + +/// generated route for +/// [_i21.PlayerTrackSourcesPage] +class PlayerTrackSourcesRoute extends _i43.PageRouteInfo { + const PlayerTrackSourcesRoute({List<_i43.PageRouteInfo>? children}) + : super( + PlayerTrackSourcesRoute.name, + initialChildren: children, + ); + + static const String name = 'PlayerTrackSourcesRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i21.PlayerTrackSourcesPage(); + }, + ); +} + +/// generated route for +/// [_i22.PlaylistGenerateResultPage] +class PlaylistGenerateResultRoute + extends _i43.PageRouteInfo { + PlaylistGenerateResultRoute({ + _i46.Key? key, + required _i47.GeneratePlaylistProviderInput state, + List<_i43.PageRouteInfo>? children, + }) : super( + PlaylistGenerateResultRoute.name, + args: PlaylistGenerateResultRouteArgs( + key: key, + state: state, + ), + initialChildren: children, + ); + + static const String name = 'PlaylistGenerateResultRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return _i22.PlaylistGenerateResultPage( + key: args.key, + state: args.state, + ); + }, + ); +} + +class PlaylistGenerateResultRouteArgs { + const PlaylistGenerateResultRouteArgs({ + this.key, + required this.state, + }); + + final _i46.Key? key; + + final _i47.GeneratePlaylistProviderInput state; + + @override + String toString() { + return 'PlaylistGenerateResultRouteArgs{key: $key, state: $state}'; + } +} + +/// generated route for +/// [_i23.PlaylistGeneratorPage] +class PlaylistGeneratorRoute extends _i43.PageRouteInfo { + const PlaylistGeneratorRoute({List<_i43.PageRouteInfo>? children}) + : super( + PlaylistGeneratorRoute.name, + initialChildren: children, + ); + + static const String name = 'PlaylistGeneratorRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i23.PlaylistGeneratorPage(); + }, + ); +} + +/// generated route for +/// [_i24.PlaylistPage] +class PlaylistRoute extends _i43.PageRouteInfo { + PlaylistRoute({ + _i44.Key? key, + required String id, + required _i45.PlaylistSimple playlist, + List<_i43.PageRouteInfo>? children, + }) : super( + PlaylistRoute.name, + args: PlaylistRouteArgs( + key: key, + id: id, + playlist: playlist, + ), + rawPathParams: {'id': id}, + initialChildren: children, + ); + + static const String name = 'PlaylistRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return _i24.PlaylistPage( + key: args.key, + id: args.id, + playlist: args.playlist, + ); + }, + ); +} + +class PlaylistRouteArgs { + const PlaylistRouteArgs({ + this.key, + required this.id, + required this.playlist, + }); + + final _i44.Key? key; + + final String id; + + final _i45.PlaylistSimple playlist; + + @override + String toString() { + return 'PlaylistRouteArgs{key: $key, id: $id, playlist: $playlist}'; + } +} + +/// generated route for +/// [_i25.ProfilePage] +class ProfileRoute extends _i43.PageRouteInfo { + const ProfileRoute({List<_i43.PageRouteInfo>? children}) + : super( + ProfileRoute.name, + initialChildren: children, + ); + + static const String name = 'ProfileRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i25.ProfilePage(); + }, + ); +} + +/// generated route for +/// [_i26.RootAppPage] +class RootAppRoute extends _i43.PageRouteInfo { + const RootAppRoute({List<_i43.PageRouteInfo>? children}) + : super( + RootAppRoute.name, + initialChildren: children, + ); + + static const String name = 'RootAppRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i26.RootAppPage(); + }, + ); +} + +/// generated route for +/// [_i27.SearchPage] +class SearchRoute extends _i43.PageRouteInfo { + const SearchRoute({List<_i43.PageRouteInfo>? children}) + : super( + SearchRoute.name, + initialChildren: children, + ); + + static const String name = 'SearchRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i27.SearchPage(); + }, + ); +} + +/// generated route for +/// [_i28.SettingsPage] +class SettingsRoute extends _i43.PageRouteInfo { + const SettingsRoute({List<_i43.PageRouteInfo>? children}) + : super( + SettingsRoute.name, + initialChildren: children, + ); + + static const String name = 'SettingsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i28.SettingsPage(); + }, + ); +} + +/// generated route for +/// [_i29.StatsAlbumsPage] +class StatsAlbumsRoute extends _i43.PageRouteInfo { + const StatsAlbumsRoute({List<_i43.PageRouteInfo>? children}) + : super( + StatsAlbumsRoute.name, + initialChildren: children, + ); + + static const String name = 'StatsAlbumsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i29.StatsAlbumsPage(); + }, + ); +} + +/// generated route for +/// [_i30.StatsArtistsPage] +class StatsArtistsRoute extends _i43.PageRouteInfo { + const StatsArtistsRoute({List<_i43.PageRouteInfo>? children}) + : super( + StatsArtistsRoute.name, + initialChildren: children, + ); + + static const String name = 'StatsArtistsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i30.StatsArtistsPage(); + }, + ); +} + +/// generated route for +/// [_i31.StatsMinutesPage] +class StatsMinutesRoute extends _i43.PageRouteInfo { + const StatsMinutesRoute({List<_i43.PageRouteInfo>? children}) + : super( + StatsMinutesRoute.name, + initialChildren: children, + ); + + static const String name = 'StatsMinutesRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i31.StatsMinutesPage(); + }, + ); +} + +/// generated route for +/// [_i32.StatsPage] +class StatsRoute extends _i43.PageRouteInfo { + const StatsRoute({List<_i43.PageRouteInfo>? children}) + : super( + StatsRoute.name, + initialChildren: children, + ); + + static const String name = 'StatsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i32.StatsPage(); + }, + ); +} + +/// generated route for +/// [_i33.StatsPlaylistsPage] +class StatsPlaylistsRoute extends _i43.PageRouteInfo { + const StatsPlaylistsRoute({List<_i43.PageRouteInfo>? children}) + : super( + StatsPlaylistsRoute.name, + initialChildren: children, + ); + + static const String name = 'StatsPlaylistsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i33.StatsPlaylistsPage(); + }, + ); +} + +/// generated route for +/// [_i34.StatsStreamFeesPage] +class StatsStreamFeesRoute extends _i43.PageRouteInfo { + const StatsStreamFeesRoute({List<_i43.PageRouteInfo>? children}) + : super( + StatsStreamFeesRoute.name, + initialChildren: children, + ); + + static const String name = 'StatsStreamFeesRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i34.StatsStreamFeesPage(); + }, + ); +} + +/// generated route for +/// [_i35.StatsStreamsPage] +class StatsStreamsRoute extends _i43.PageRouteInfo { + const StatsStreamsRoute({List<_i43.PageRouteInfo>? children}) + : super( + StatsStreamsRoute.name, + initialChildren: children, + ); + + static const String name = 'StatsStreamsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i35.StatsStreamsPage(); + }, + ); +} + +/// generated route for +/// [_i36.TrackPage] +class TrackRoute extends _i43.PageRouteInfo { + TrackRoute({ + _i46.Key? key, + required String trackId, + List<_i43.PageRouteInfo>? children, + }) : super( + TrackRoute.name, + args: TrackRouteArgs( + key: key, + trackId: trackId, + ), + rawPathParams: {'id': trackId}, + initialChildren: children, + ); + + static const String name = 'TrackRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + final pathParams = data.inheritedPathParams; + final args = data.argsAs( + orElse: () => TrackRouteArgs(trackId: pathParams.getString('id'))); + return _i36.TrackPage( + key: args.key, + trackId: args.trackId, + ); + }, + ); +} + +class TrackRouteArgs { + const TrackRouteArgs({ + this.key, + required this.trackId, + }); + + final _i46.Key? key; + + final String trackId; + + @override + String toString() { + return 'TrackRouteArgs{key: $key, trackId: $trackId}'; + } +} + +/// generated route for +/// [_i37.UserAlbumsPage] +class UserAlbumsRoute extends _i43.PageRouteInfo { + const UserAlbumsRoute({List<_i43.PageRouteInfo>? children}) + : super( + UserAlbumsRoute.name, + initialChildren: children, + ); + + static const String name = 'UserAlbumsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i37.UserAlbumsPage(); + }, + ); +} + +/// generated route for +/// [_i38.UserArtistsPage] +class UserArtistsRoute extends _i43.PageRouteInfo { + const UserArtistsRoute({List<_i43.PageRouteInfo>? children}) + : super( + UserArtistsRoute.name, + initialChildren: children, + ); + + static const String name = 'UserArtistsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i38.UserArtistsPage(); + }, + ); +} + +/// generated route for +/// [_i39.UserDownloadsPage] +class UserDownloadsRoute extends _i43.PageRouteInfo { + const UserDownloadsRoute({List<_i43.PageRouteInfo>? children}) + : super( + UserDownloadsRoute.name, + initialChildren: children, + ); + + static const String name = 'UserDownloadsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i39.UserDownloadsPage(); + }, + ); +} + +/// generated route for +/// [_i40.UserLocalLibraryPage] +class UserLocalLibraryRoute extends _i43.PageRouteInfo { + const UserLocalLibraryRoute({List<_i43.PageRouteInfo>? children}) + : super( + UserLocalLibraryRoute.name, + initialChildren: children, + ); + + static const String name = 'UserLocalLibraryRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i40.UserLocalLibraryPage(); + }, + ); +} + +/// generated route for +/// [_i41.UserPlaylistsPage] +class UserPlaylistsRoute extends _i43.PageRouteInfo { + const UserPlaylistsRoute({List<_i43.PageRouteInfo>? children}) + : super( + UserPlaylistsRoute.name, + initialChildren: children, + ); + + static const String name = 'UserPlaylistsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i41.UserPlaylistsPage(); + }, + ); +} + +/// generated route for +/// [_i42.WebViewLoginPage] +class WebViewLoginRoute extends _i43.PageRouteInfo { + const WebViewLoginRoute({List<_i43.PageRouteInfo>? children}) + : super( + WebViewLoginRoute.name, + initialChildren: children, + ); + + static const String name = 'WebViewLoginRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i42.WebViewLoginPage(); + }, + ); +} diff --git a/lib/collections/side_bar_tiles.dart b/lib/collections/side_bar_tiles.dart index 551d70d7..c647c9fb 100644 --- a/lib/collections/side_bar_tiles.dart +++ b/lib/collections/side_bar_tiles.dart @@ -1,33 +1,114 @@ -import 'package:flutter/material.dart'; +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:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:spotube/l10n/l10n.dart'; class SideBarTiles { final IconData icon; final String title; final String id; - SideBarTiles({required this.icon, required this.title, required this.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: "browse", icon: SpotubeIcons.home, title: l10n.browse), - SideBarTiles(id: "search", icon: SpotubeIcons.search, title: l10n.search), SideBarTiles( - id: "library", icon: SpotubeIcons.library, title: l10n.library), - SideBarTiles(id: "lyrics", icon: SpotubeIcons.music, title: l10n.lyrics), + 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: "browse", icon: SpotubeIcons.home, title: l10n.browse), - SideBarTiles(id: "search", icon: SpotubeIcons.search, title: l10n.search), + 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: "settings", - icon: SpotubeIcons.settings, - title: l10n.settings, - ) + 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 index 6cf92085..bd9d037c 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -1,5 +1,5 @@ import 'package:fluentui_system_icons/fluentui_system_icons.dart'; -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_feather_icons/flutter_feather_icons.dart'; import 'package:simple_icons/simple_icons.dart'; @@ -37,6 +37,7 @@ abstract class SpotubeIcons { 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; @@ -115,4 +116,23 @@ abstract class SpotubeIcons { 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; } diff --git a/lib/components/adaptive/adaptive_list_tile.dart b/lib/components/adaptive/adaptive_list_tile.dart new file mode 100644 index 00000000..c6d00bd4 --- /dev/null +++ b/lib/components/adaptive/adaptive_list_tile.dart @@ -0,0 +1,63 @@ +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 new file mode 100644 index 00000000..6eba1148 --- /dev/null +++ b/lib/components/adaptive/adaptive_pop_sheet_list.dart @@ -0,0 +1,191 @@ +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 new file mode 100644 index 00000000..afa982af --- /dev/null +++ b/lib/components/adaptive/adaptive_select_tile.dart @@ -0,0 +1,137 @@ +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/album/album_card.dart b/lib/components/album/album_card.dart deleted file mode 100644 index 4d2e12d6..00000000 --- a/lib/components/album/album_card.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/playbutton_card.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/infinite_query.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/album.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -extension FormattedAlbumType on AlbumType { - String get formatted => name.replaceFirst(name[0], name[0].toUpperCase()); -} - -class AlbumCard extends HookConsumerWidget { - final AlbumSimple album; - const AlbumCard( - this.album, { - super.key, - }); - - @override - Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playing = - useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - - final queryClient = useQueryClient(); - - bool isPlaylistPlaying = useMemoized( - () => playlist.containsCollection(album.id!), - [playlist, album.id], - ); - - final updating = useState(false); - final spotify = ref.watch(spotifyProvider); - - final scaffoldMessenger = ScaffoldMessenger.maybeOf(context); - - Future> fetchAllTrack() async { - if (album.tracks != null && album.tracks!.isNotEmpty) { - return album.tracks! - .map((track) => - TypeConversionUtils.simpleTrack_X_Track(track, album)) - .toList(); - } - final job = AlbumQueries.tracksOfJob(album.id!); - - final query = queryClient.createInfiniteQuery( - job.queryKey, - (page) => job.task(page, (spotify: spotify, album: album)), - initialPage: 0, - nextPage: job.nextPage, - ); - - return await query.fetchAllTracks( - getAllTracks: () async { - final res = await spotify.albums.tracks(album.id!).all(); - return res - .map((e) => TypeConversionUtils.simpleTrack_X_Track(e, album)) - .toList(); - }, - ); - } - - return PlaybuttonCard( - imageUrl: TypeConversionUtils.image_X_UrlString( - album.images, - placeholder: ImagePlaceholder.collection, - ), - margin: const EdgeInsets.symmetric(horizontal: 10), - isPlaying: isPlaylistPlaying, - isLoading: (isPlaylistPlaying && playlist.isFetching == true) || - updating.value, - title: album.name!, - description: - "${album.albumType?.formatted} • ${TypeConversionUtils.artists_X_String(album.artists ?? [])}", - onTap: () { - ServiceUtils.push(context, "/album/${album.id}", extra: album); - }, - onPlaybuttonPressed: () async { - updating.value = true; - try { - if (isPlaylistPlaying) { - return playing ? audioPlayer.pause() : audioPlayer.resume(); - } - - final fetchedTracks = await fetchAllTrack(); - - if (fetchedTracks.isEmpty) return; - - await playlistNotifier.load(fetchedTracks, autoPlay: true); - playlistNotifier.addCollection(album.id!); - } finally { - updating.value = false; - } - }, - onAddToQueuePressed: () async { - if (isPlaylistPlaying) { - return; - } - - updating.value = true; - try { - final fetchedTracks = await fetchAllTrack(); - - if (fetchedTracks.isEmpty) return; - playlistNotifier.addTracks(fetchedTracks); - playlistNotifier.addCollection(album.id!); - if (context.mounted) { - final snackbar = SnackBar( - content: Text( - context.l10n.added_to_queue(fetchedTracks.length), - ), - action: SnackBarAction( - label: "Undo", - onPressed: () { - playlistNotifier - .removeTracks(fetchedTracks.map((e) => e.id!)); - }, - ), - ); - - scaffoldMessenger?.showSnackBar(snackbar); - } - } finally { - updating.value = false; - } - }); - } -} diff --git a/lib/components/artist/artist_album_list.dart b/lib/components/artist/artist_album_list.dart deleted file mode 100644 index 5114170c..00000000 --- a/lib/components/artist/artist_album_list.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:spotube/services/queries/queries.dart'; - -class ArtistAlbumList extends HookConsumerWidget { - final String artistId; - ArtistAlbumList( - this.artistId, { - Key? key, - }) : super(key: key); - - final logger = getLogger(ArtistAlbumList); - - @override - Widget build(BuildContext context, ref) { - final albumsQuery = useQueries.artist.albumsOf(ref, artistId); - - final albums = useMemoized(() { - return albumsQuery.pages - .expand((page) => page.items ?? const Iterable.empty()) - .toList(); - }, [albumsQuery.pages]); - - final theme = Theme.of(context); - - return HorizontalPlaybuttonCardView( - isLoadingNextPage: albumsQuery.isLoadingNextPage, - hasNextPage: albumsQuery.hasNextPage, - items: albums, - onFetchMore: albumsQuery.fetchNext, - title: Text( - context.l10n.albums, - style: theme.textTheme.headlineSmall, - ), - ); - } -} diff --git a/lib/components/artist/artist_card.dart b/lib/components/artist/artist_card.dart deleted file mode 100644 index 3526e88f..00000000 --- a/lib/components/artist/artist_card.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; - -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; -import 'package:spotube/hooks/utils/use_brightness_value.dart'; -import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class ArtistCard extends HookConsumerWidget { - final Artist artist; - const ArtistCard(this.artist, {Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final backgroundImage = UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - artist.images, - placeholder: ImagePlaceholder.artist, - ), - ); - final isBlackListed = ref.watch( - BlackListNotifier.provider.select( - (blacklist) => blacklist.contains( - BlacklistedElement.artist(artist.id!, artist.name!), - ), - ), - ); - - final radius = BorderRadius.circular(15); - - final double size = useBreakpointValue( - xs: 130, - sm: 130, - md: 150, - others: 170, - ); - - return Container( - width: size, - margin: const EdgeInsets.symmetric(vertical: 5), - child: Material( - shadowColor: theme.colorScheme.background, - color: Color.lerp( - theme.colorScheme.surfaceVariant, - theme.colorScheme.surface, - useBrightnessValue(.9, .7), - ), - elevation: 3, - shape: RoundedRectangleBorder( - borderRadius: radius, - side: isBlackListed - ? const BorderSide( - color: Colors.red, - width: 2, - ) - : BorderSide.none, - ), - child: InkWell( - onTap: () { - ServiceUtils.push(context, "/artist/${artist.id}"); - }, - borderRadius: radius, - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - children: [ - Stack( - children: [ - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: size, - ), - child: CircleAvatar( - backgroundImage: backgroundImage, - radius: size / 2, - ), - ), - Positioned( - right: 0, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 5, - ), - decoration: BoxDecoration( - color: Colors.blue, - borderRadius: BorderRadius.circular(50)), - child: Skeleton.ignore( - child: Text( - context.l10n.artist, - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - ], - ), - const SizedBox(height: 10), - AutoSizeText( - artist.name!, - maxLines: 1, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - )), - ), - ); - } -} diff --git a/lib/components/button/back_button.dart b/lib/components/button/back_button.dart new file mode 100644 index 00000000..42c952ab --- /dev/null +++ b/lib/components/button/back_button.dart @@ -0,0 +1,21 @@ +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(.9), + icon: Icon(icon, color: color), + onPressed: () => Navigator.of(context).pop(), + ); + } +} diff --git a/lib/components/desktop_login/login_form.dart b/lib/components/desktop_login/login_form.dart deleted file mode 100644 index 5abb9524..00000000 --- a/lib/components/desktop_login/login_form.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/extensions/context.dart'; - -import 'package:spotube/provider/authentication_provider.dart'; - -class TokenLoginForm extends HookConsumerWidget { - final void Function()? onDone; - const TokenLoginForm({ - Key? key, - this.onDone, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final authenticationNotifier = - ref.watch(AuthenticationNotifier.provider.notifier); - final directCodeController = useTextEditingController(); - final mounted = useIsMounted(); - - final isLoading = useState(false); - - return ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 400, - ), - child: Column( - children: [ - TextField( - controller: directCodeController, - decoration: InputDecoration( - hintText: context.l10n.spotify_cookie("\"sp_dc\""), - labelText: context.l10n.cookie_name_cookie("sp_dc"), - ), - keyboardType: TextInputType.visiblePassword, - ), - const SizedBox(height: 10), - FilledButton( - onPressed: isLoading.value - ? null - : () async { - try { - isLoading.value = true; - if (directCodeController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.fill_in_all_fields), - behavior: SnackBarBehavior.floating, - ), - ); - return; - } - final cookieHeader = - "sp_dc=${directCodeController.text.trim()}"; - - authenticationNotifier.setCredentials( - await AuthenticationCredentials.fromCookie( - cookieHeader), - ); - if (mounted()) { - onDone?.call(); - } - } finally { - isLoading.value = false; - } - }, - child: Text(context.l10n.submit), - ) - ], - ), - ); - } -} diff --git a/lib/components/dialogs/confirm_download_dialog.dart b/lib/components/dialogs/confirm_download_dialog.dart new file mode 100644 index 00000000..a2df0e9c --- /dev/null +++ b/lib/components/dialogs/confirm_download_dialog.dart @@ -0,0 +1,94 @@ +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/playlist_add_track_dialog.dart b/lib/components/dialogs/playlist_add_track_dialog.dart new file mode 100644 index 00000000..5098bf9d --- /dev/null +++ b/lib/components/dialogs/playlist_add_track_dialog.dart @@ -0,0 +1,149 @@ +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import 'package:spotify/spotify.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/extensions/image.dart'; +import 'package:spotube/provider/spotify/spotify.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(favoritePlaylistsProvider); + final favoritePlaylistsNotifier = + ref.watch(favoritePlaylistsProvider.notifier); + + final me = ref.watch(meProvider); + + final filteredPlaylists = useMemoized( + () => + userPlaylists.asData?.value.items + .where( + (playlist) => + playlist.owner?.id != null && + 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 ?? "Playlist"), + 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/shared/dialogs/prompt_dialog.dart b/lib/components/dialogs/prompt_dialog.dart similarity index 88% rename from lib/components/shared/dialogs/prompt_dialog.dart rename to lib/components/dialogs/prompt_dialog.dart index 30a63bcf..3498bf02 100644 --- a/lib/components/shared/dialogs/prompt_dialog.dart +++ b/lib/components/dialogs/prompt_dialog.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/extensions/context.dart'; Future showPromptDialog({ @@ -16,13 +16,13 @@ Future showPromptDialog({ content: Text(message), actions: [ if (cancelText != null) - OutlinedButton( + Button.outline( onPressed: () => Navigator.of(context).pop(false), child: Text( cancelText == "Cancel" ? context.l10n.cancel : cancelText, ), ), - FilledButton( + Button.primary( child: Text(okText == "Ok" ? context.l10n.ok : okText), onPressed: () => Navigator.of(context).pop(true), ), diff --git a/lib/components/dialogs/replace_downloaded_dialog.dart b/lib/components/dialogs/replace_downloaded_dialog.dart new file mode 100644 index 00000000..3a0f3a1d --- /dev/null +++ b/lib/components/dialogs/replace_downloaded_dialog.dart @@ -0,0 +1,63 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/context.dart'; + +final replaceDownloadedFileState = StateProvider((ref) => null); + +class ReplaceDownloadedDialog extends ConsumerWidget { + final Track track; + const ReplaceDownloadedDialog({required this.track, super.key}); + + @override + Widget build(BuildContext context, ref) { + final groupValue = ref.watch(replaceDownloadedFileState); + final replaceAll = ref.watch(replaceDownloadedFileState); + + return AlertDialog( + title: Text(context.l10n.track_exists(track.name ?? "")), + content: RadioGroup( + value: groupValue, + 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 new file mode 100644 index 00000000..5392a403 --- /dev/null +++ b/lib/components/dialogs/select_device_dialog.dart @@ -0,0 +1,68 @@ +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/shared/dialogs/track_details_dialog.dart b/lib/components/dialogs/track_details_dialog.dart similarity index 59% rename from lib/components/shared/dialogs/track_details_dialog.dart rename to lib/components/dialogs/track_details_dialog.dart index 8634776f..bfb4a318 100644 --- a/lib/components/shared/dialogs/track_details_dialog.dart +++ b/lib/components/dialogs/track_details_dialog.dart @@ -1,21 +1,22 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/links/hyper_link.dart'; -import 'package:spotube/components/shared/links/link_text.dart'; +import 'package:spotube/components/links/artist_link.dart'; +import 'package:spotube/components/links/hyper_link.dart'; +import 'package:spotube/components/links/link_text.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/extensions/duration.dart'; class TrackDetailsDialog extends HookWidget { final Track track; const TrackDetailsDialog({ - Key? key, + super.key, required this.track, - }) : super(key: key); + }); @override Widget build(BuildContext context) { @@ -24,15 +25,15 @@ class TrackDetailsDialog extends HookWidget { final detailsMap = { context.l10n.title: track.name!, - context.l10n.artist: TypeConversionUtils.artists_X_ClickableArtists( - track.artists ?? [], + 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!, - "/album/${track.album?.id}", - extra: track.album, + AlbumRoute(album: track.album!, id: track.album!.id!), overflow: TextOverflow.ellipsis, style: const TextStyle(color: Colors.blue), ), @@ -72,17 +73,15 @@ class TrackDetailsDialog extends HookWidget { }; return AlertDialog( - contentPadding: const EdgeInsets.all(16), - insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 100), - scrollable: true, + surfaceBlur: 0, + surfaceOpacity: 1, title: Row( - mainAxisAlignment: MainAxisAlignment.center, + spacing: 8, children: [ const Icon(SpotubeIcons.info), - const SizedBox(width: 8), Text( context.l10n.details, - style: theme.textTheme.titleMedium, + style: theme.typography.h4, ), ], ), @@ -90,65 +89,64 @@ class TrackDetailsDialog extends HookWidget { width: mediaQuery.mdAndUp ? double.infinity : 700, child: Table( columnWidths: const { - 0: FixedColumnWidth(95), - 1: FixedColumnWidth(10), - 2: FlexColumnWidth(1), + 0: FixedTableSize(95), + 1: FixedTableSize(10), + 2: FlexTableSize(), }, - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - children: [ + 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( - children: [ + cells: [ TableCell( - verticalAlignment: TableCellVerticalAlignment.top, child: Text( entry.key, - style: theme.textTheme.titleMedium, + style: theme.typography.bold, ), ), const TableCell( - verticalAlignment: TableCellVerticalAlignment.top, child: Text(":"), ), - if (entry.value is Widget) - entry.value as Widget - else if (entry.value is String) - Text( - entry.value as String, - style: theme.textTheme.bodyMedium, - ), + 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(""), + ), ], ), - const TableRow( - children: [ - SizedBox(height: 16), - SizedBox(height: 16), - SizedBox(height: 16), - ], - ), for (final entry in ytTracksDetailsMap.entries) TableRow( - children: [ + cells: [ TableCell( - verticalAlignment: TableCellVerticalAlignment.top, child: Text( entry.key, - style: theme.textTheme.titleMedium, + style: theme.typography.bold, ), ), const TableCell( - verticalAlignment: TableCellVerticalAlignment.top, child: Text(":"), ), - if (entry.value is Widget) - entry.value as Widget - else - Text( - entry.value, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium, - ), + 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/shared/expandable_search/expandable_search.dart b/lib/components/expandable_search/expandable_search.dart similarity index 76% rename from lib/components/shared/expandable_search/expandable_search.dart rename to lib/components/expandable_search/expandable_search.dart index 75ac6841..279a3e5f 100644 --- a/lib/components/shared/expandable_search/expandable_search.dart +++ b/lib/components/expandable_search/expandable_search.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; 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'; @@ -10,12 +10,12 @@ class ExpandableSearchField extends StatelessWidget { final FocusNode searchFocus; const ExpandableSearchField({ - Key? key, + super.key, required this.isFiltering, required this.onChangeFiltering, required this.searchController, required this.searchFocus, - }) : super(key: key); + }); @override Widget build(BuildContext context) { @@ -39,11 +39,10 @@ class ExpandableSearchField extends StatelessWidget { child: TextField( focusNode: searchFocus, controller: searchController, - decoration: InputDecoration( - hintText: context.l10n.search_tracks, - isDense: true, - prefixIcon: const Icon(SpotubeIcons.search), - ), + placeholder: Text(context.l10n.search_tracks), + features: const [ + InputFeature.leading(Icon(SpotubeIcons.search)) + ], ), ), ), @@ -60,25 +59,18 @@ class ExpandableSearchButton extends StatelessWidget { final ValueChanged? onPressed; const ExpandableSearchButton({ - Key? key, + super.key, required this.isFiltering, required this.searchFocus, this.icon = const Icon(SpotubeIcons.filter), this.onPressed, - }) : super(key: key); + }); @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return IconButton( icon: icon, - style: IconButton.styleFrom( - backgroundColor: - isFiltering ? theme.colorScheme.secondaryContainer : null, - foregroundColor: isFiltering ? theme.colorScheme.secondary : null, - minimumSize: const Size(25, 25), - ), + variance: isFiltering ? ButtonVariance.secondary : ButtonVariance.outline, onPressed: () { if (isFiltering) { searchFocus.requestFocus(); diff --git a/lib/components/fallbacks/anonymous_fallback.dart b/lib/components/fallbacks/anonymous_fallback.dart new file mode 100644 index 00000000..293df932 --- /dev/null +++ b/lib/components/fallbacks/anonymous_fallback.dart @@ -0,0 +1,49 @@ +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/authentication/authentication.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(authenticationProvider); + + if (isLoggedIn.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (isLoggedIn.asData?.value != null && 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_with_spotify), + onPressed: () => context.navigateTo(const SettingsRoute()), + ) + ], + ), + ); + } +} diff --git a/lib/components/fallbacks/not_found.dart b/lib/components/fallbacks/not_found.dart new file mode 100644 index 00000000..9a994446 --- /dev/null +++ b/lib/components/fallbacks/not_found.dart @@ -0,0 +1,27 @@ +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 new file mode 100644 index 00000000..0e794833 --- /dev/null +++ b/lib/components/form/checkbox_form_field.dart @@ -0,0 +1,45 @@ +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 new file mode 100644 index 00000000..b07c33e3 --- /dev/null +++ b/lib/components/form/text_form_field.dart @@ -0,0 +1,184 @@ +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 bool 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 = true, + 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 new file mode 100644 index 00000000..fe923958 --- /dev/null +++ b/lib/components/framework/app_pop_scope.dart @@ -0,0 +1,104 @@ +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 new file mode 100644 index 00000000..275d5db1 --- /dev/null +++ b/lib/components/heart_button/heart_button.dart @@ -0,0 +1,94 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/heart_button/use_track_toggle_like.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/spotify/spotify.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 auth = ref.watch(authenticationProvider); + + if (auth.asData?.value == null) return const SizedBox.shrink(); + + return Tooltip( + tooltip: TooltipContainer(child: Text(tooltip ?? "")).call, + child: IconButton( + variance: variance, + size: size, + 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 Track track; + const TrackHeartButton({ + super.key, + required this.track, + }); + + @override + Widget build(BuildContext context, ref) { + final savedTracks = ref.watch(likedTracksProvider); + final me = ref.watch(meProvider); + final (:isLiked, :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 + ? () { + toggleTrackLike(track); + } + : null, + ); + } +} diff --git a/lib/components/heart_button/use_track_toggle_like.dart b/lib/components/heart_button/use_track_toggle_like.dart new file mode 100644 index 00000000..ba5cbee1 --- /dev/null +++ b/lib/components/heart_button/use_track_toggle_like.dart @@ -0,0 +1,37 @@ +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/provider/scrobbler/scrobbler.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +typedef UseTrackToggleLike = ({ + bool isLiked, + Future Function(Track track) toggleTrackLike, +}); + +UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) { + final savedTracks = ref.watch(likedTracksProvider); + final savedTracksNotifier = ref.watch(likedTracksProvider.notifier); + + final isLiked = useMemoized( + () => + savedTracks.asData?.value.any((element) => element.id == track.id) ?? + false, + [savedTracks.asData?.value, track.id], + ); + + final scrobblerNotifier = ref.read(scrobblerProvider.notifier); + + return ( + isLiked: isLiked, + toggleTrackLike: (track) async { + await savedTracksNotifier.toggleFavorite(track); + + if (!isLiked) { + await scrobblerNotifier.love(track); + } else { + await scrobblerNotifier.unlove(track); + } + }, + ); +} diff --git a/lib/components/home/sections/featured.dart b/lib/components/home/sections/featured.dart deleted file mode 100644 index 8a7c2c95..00000000 --- a/lib/components/home/sections/featured.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/services/queries/queries.dart'; - -class HomeFeaturedSection extends HookConsumerWidget { - const HomeFeaturedSection({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final featuredPlaylistsQuery = useQueries.playlist.featured(ref); - final playlists = useMemoized( - () => featuredPlaylistsQuery.pages - .whereType>() - .expand((page) => page.items ?? const []), - [featuredPlaylistsQuery.pages], - ); - final isLoadingFeaturedPlaylists = !featuredPlaylistsQuery.hasPageData && - !featuredPlaylistsQuery.isLoadingNextPage; - - return Skeletonizer( - enabled: isLoadingFeaturedPlaylists, - child: HorizontalPlaybuttonCardView( - items: playlists.toList(), - title: Text(context.l10n.featured), - isLoadingNextPage: featuredPlaylistsQuery.isLoadingNextPage, - hasNextPage: featuredPlaylistsQuery.hasNextPage, - onFetchMore: featuredPlaylistsQuery.fetchNext, - ), - ); - } -} diff --git a/lib/components/home/sections/friends.dart b/lib/components/home/sections/friends.dart deleted file mode 100644 index 6382f6fd..00000000 --- a/lib/components/home/sections/friends.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'dart:ffi'; -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/components/home/sections/friends/friend_item.dart'; -import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; -import 'package:spotube/models/spotify_friends.dart'; -import 'package:spotube/services/queries/queries.dart'; - -class HomePageFriendsSection extends HookConsumerWidget { - const HomePageFriendsSection({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final friendsQuery = useQueries.user.friendActivity(ref); - final friends = friendsQuery.data?.friends ?? FakeData.friends.friends; - - final groupCount = useBreakpointValue( - sm: 3, - xs: 2, - md: 4, - lg: 5, - xl: 6, - xxl: 7, - ); - - final friendGroup = friends.fold>>( - [], - (previousValue, element) { - if (previousValue.isEmpty) { - return [ - [element] - ]; - } - - final lastGroup = previousValue.last; - if (lastGroup.length < groupCount) { - return [ - ...previousValue.sublist(0, previousValue.length - 1), - [...lastGroup, element] - ]; - } - - return [ - ...previousValue, - [element] - ]; - }, - ); - - if (!friendsQuery.isLoading && - (!friendsQuery.hasData || friendsQuery.data!.friends.isEmpty)) { - return const SliverToBoxAdapter( - child: SizedBox.shrink(), - ); - } - - return Skeletonizer.sliver( - enabled: friendsQuery.isLoading, - child: SliverMainAxisGroup( - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - 'Friends', - style: Theme.of(context).textTheme.titleMedium, - ), - ), - ), - SliverToBoxAdapter( - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: PointerDeviceKind.values.toSet(), - ), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final group in friendGroup) - Row( - children: [ - for (final friend in group) - Padding( - padding: const EdgeInsets.all(8.0), - child: FriendItem(friend: friend), - ), - ], - ), - ], - ), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/components/home/sections/genres.dart b/lib/components/home/sections/genres.dart deleted file mode 100644 index 41ba235c..00000000 --- a/lib/components/home/sections/genres.dart +++ /dev/null @@ -1,154 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/collections/gradients.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; - -class HomeGenresSection extends HookConsumerWidget { - const HomeGenresSection({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final ThemeData(:textTheme, :colorScheme) = Theme.of(context); - final mediaQuery = MediaQuery.of(context); - - final recommendationMarket = ref.watch( - userPreferencesProvider.select((s) => s.recommendationMarket), - ); - final categoriesQuery = - useQueries.category.listAll(ref, recommendationMarket); - - final categories = categoriesQuery.data - ?.where((c) => (c.icons?.length ?? 0) > 0) - .take(mediaQuery.mdAndDown ? 6 : 10) - .toList() ?? - []; - - return SliverMainAxisGroup( - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - context.l10n.genres, - style: textTheme.headlineSmall, - ), - Directionality( - textDirection: TextDirection.rtl, - child: TextButton.icon( - onPressed: () { - context.push('/genres'); - }, - icon: const Icon(SpotubeIcons.angleRight), - label: Text( - "Browse All", - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.secondary, - ), - ), - ), - ), - ], - ), - ), - ), - const SliverGap(8), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: Skeletonizer.sliver( - enabled: categoriesQuery.isLoading, - child: SliverGrid.builder( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: mediaQuery.mdAndDown ? 200 : 250, - mainAxisExtent: 50, - crossAxisSpacing: 16, - mainAxisSpacing: 16, - ), - itemCount: categoriesQuery.isLoading - ? mediaQuery.mdAndDown - ? 6 - : 10 - : categories.length, - itemBuilder: (context, index) { - final category = - categories.elementAtOrNull(index) ?? FakeData.category; - - return HookBuilder(builder: (context) { - final (:gradient, :textColor) = useMemoized( - () { - final gradient = - gradients[Random().nextInt(gradients.length)]; - final text = gradient.colors - .take(2) - .any((c) => c.computeLuminance() > 0.5) - ? Colors.grey[900] - : Colors.white; - return ( - gradient: LinearGradient( - colors: gradient.colors - .map((c) => c.withOpacity(0.8)) - .toList(), - ), - textColor: text - ); - }, - [], - ); - - return InkWell( - onTap: () { - context.push('/genre/${category.id}', extra: category); - }, - borderRadius: BorderRadius.circular(8), - child: Ink( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - image: DecorationImage( - image: UniversalImage.imageProvider( - category.icons!.first.url!, - ), - fit: BoxFit.cover, - ), - ), - child: Ink( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(5), - color: colorScheme.surfaceVariant, - gradient: categoriesQuery.isLoading ? null : gradient, - ), - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - category.name!, - style: textTheme.titleMedium - ?.copyWith(color: textColor), - ), - ), - ), - ), - ); - }); - }, - ), - ), - ), - ], - ); - } -} diff --git a/lib/components/home/sections/new_releases.dart b/lib/components/home/sections/new_releases.dart deleted file mode 100644 index 0f4a046a..00000000 --- a/lib/components/home/sections/new_releases.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class HomeNewReleasesSection extends HookConsumerWidget { - const HomeNewReleasesSection({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final auth = ref.watch(AuthenticationNotifier.provider); - - final newReleases = useQueries.album.newReleases(ref); - final userArtistsQuery = useQueries.artist.followedByMeAll(ref); - final userArtists = - userArtistsQuery.data?.map((s) => s.id!).toList() ?? const []; - - final albums = useMemoized( - () { - final allReleases = newReleases.pages - .whereType>() - .expand((page) => page.items ?? const []) - .map((album) => TypeConversionUtils.simpleAlbum_X_Album(album)); - - final userArtistReleases = allReleases.where((album) { - return album.artists - ?.any((artist) => userArtists.contains(artist.id!)) == - true; - }).toList(); - - if (userArtistReleases.isEmpty) return allReleases.toList(); - return userArtistReleases; - }, - [newReleases.pages], - ); - - final hasNewReleases = newReleases.hasPageData && - userArtistsQuery.hasData && - !newReleases.isLoadingNextPage; - - if (auth == null || !hasNewReleases) return const SizedBox.shrink(); - - return HorizontalPlaybuttonCardView( - items: albums, - title: Text(context.l10n.new_releases), - isLoadingNextPage: newReleases.isLoadingNextPage, - hasNextPage: newReleases.hasNextPage, - onFetchMore: newReleases.fetchNext, - ); - } -} diff --git a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart b/lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart similarity index 60% rename from lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart rename to lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart index dc9d30da..47fb0f33 100644 --- a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart +++ b/lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart @@ -1,14 +1,14 @@ import 'dart:ui'; -import 'package:flutter/material.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:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.dart'; -import 'package:spotube/components/album/album_card.dart'; -import 'package:spotube/components/artist/artist_card.dart'; -import 'package:spotube/components/playlist/playlist_card.dart'; -import 'package:spotube/hooks/utils/use_breakpoint_value.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 { @@ -17,31 +17,28 @@ class HorizontalPlaybuttonCardView extends HookWidget { final VoidCallback onFetchMore; final bool isLoadingNextPage; final bool hasNextPage; + final Widget? titleTrailing; - const HorizontalPlaybuttonCardView({ + HorizontalPlaybuttonCardView({ required this.title, required this.items, required this.hasNextPage, required this.onFetchMore, required this.isLoadingNextPage, - Key? key, - }) : assert( - items is List || - items is List || - items is List, - ), - super(key: key); + this.titleTrailing, + super.key, + }) : assert( + items.every( + (item) => + item is PlaylistSimple || item is Artist || item is AlbumSimple, + ), + ); @override Widget build(BuildContext context) { - final ThemeData(:textTheme) = Theme.of(context); final scrollController = useScrollController(); - final height = useBreakpointValue( - xs: 226, - sm: 226, - md: 236, - others: 266, - ); + final isArtist = items.every((s) => s is Artist); + final scale = context.theme.scaling; return Padding( padding: const EdgeInsets.all(8.0), @@ -49,12 +46,24 @@ class HorizontalPlaybuttonCardView extends HookWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - DefaultTextStyle( - style: textTheme.titleMedium!, - child: title, + 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!, + ], ), SizedBox( - height: height, + height: isArtist ? 250 : 225, child: NotificationListener( // disable multiple scrollbar to use this onNotification: (notification) => true, @@ -78,22 +87,21 @@ class HorizontalPlaybuttonCardView extends HookWidget { onFetchData: onFetchMore, loadingBuilder: (context) => Skeletonizer( enabled: true, - child: AlbumCard(FakeData.albumSimple), + 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.runtimeType) { - PlaylistSimple => + return switch (item) { + PlaylistSimple() => PlaylistCard(item as PlaylistSimple), - Album => AlbumCard(item as Album), - Artist => Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12.0), - child: ArtistCard(item as Artist), - ), + AlbumSimple() => AlbumCard(item as AlbumSimple), + Artist() => ArtistCard(item as Artist), _ => const SizedBox.shrink(), }; }), diff --git a/lib/components/shared/hover_builder.dart b/lib/components/hover_builder.dart similarity index 95% rename from lib/components/shared/hover_builder.dart rename to lib/components/hover_builder.dart index ec60848e..7793e744 100644 --- a/lib/components/shared/hover_builder.dart +++ b/lib/components/hover_builder.dart @@ -7,8 +7,8 @@ class HoverBuilder extends HookWidget { const HoverBuilder({ required this.builder, this.permanentState, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/image/universal_image.dart b/lib/components/image/universal_image.dart similarity index 98% rename from lib/components/shared/image/universal_image.dart rename to lib/components/image/universal_image.dart index 04c62478..d8902e63 100644 --- a/lib/components/shared/image/universal_image.dart +++ b/lib/components/image/universal_image.dart @@ -20,8 +20,8 @@ class UniversalImage extends HookWidget { this.placeholder, this.fit, this.scale = 1, - Key? key, - }) : super(key: key); + super.key, + }); static ImageProvider imageProvider( String path, { diff --git a/lib/components/shared/inter_scrollbar/inter_scrollbar.dart b/lib/components/inter_scrollbar/inter_scrollbar.dart similarity index 74% rename from lib/components/shared/inter_scrollbar/inter_scrollbar.dart rename to lib/components/inter_scrollbar/inter_scrollbar.dart index 2b3ce319..415ba6da 100644 --- a/lib/components/shared/inter_scrollbar/inter_scrollbar.dart +++ b/lib/components/inter_scrollbar/inter_scrollbar.dart @@ -1,7 +1,7 @@ import 'package:draggable_scrollbar/draggable_scrollbar.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.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; @@ -15,7 +15,7 @@ class InterScrollbar extends HookWidget { @override Widget build(BuildContext context) { - if (DesktopTools.platform.isDesktop) return child; + if (kIsDesktop) return child; return DraggableScrollbar.semicircle( controller: controller, diff --git a/lib/components/library/playlist_generate/recommendation_attribute_dials.dart b/lib/components/library/playlist_generate/recommendation_attribute_dials.dart deleted file mode 100644 index 87f7cb1b..00000000 --- a/lib/components/library/playlist_generate/recommendation_attribute_dials.dart +++ /dev/null @@ -1,182 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; - -typedef RecommendationAttribute = ({double min, double target, double max}); - -RecommendationAttribute lowValues(double base) => - (min: 1 * base, target: 0.3 * base, max: 0.3 * base); -RecommendationAttribute moderateValues(double base) => - (min: 0.5 * base, target: 1 * base, max: 0.5 * base); -RecommendationAttribute highValues(double base) => - (min: 0.3 * base, target: 0.3 * base, max: 1 * base); - -class RecommendationAttributeDials extends HookWidget { - final Widget title; - final RecommendationAttribute values; - final ValueChanged onChanged; - final double base; - - const RecommendationAttributeDials({ - Key? key, - required this.values, - required this.onChanged, - required this.title, - this.base = 1, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final animation = useAnimationController( - duration: const Duration(milliseconds: 300), - ); - final labelStyle = Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.w500, - ); - - final minSlider = Row( - children: [ - Text(context.l10n.min, style: labelStyle), - Expanded( - child: Slider( - value: values.min / base, - min: 0, - max: 1, - onChanged: (value) => onChanged(( - min: value * base, - target: values.target, - max: values.max, - )), - ), - ), - ], - ); - - final targetSlider = Row( - children: [ - Text(context.l10n.target, style: labelStyle), - Expanded( - child: Slider( - value: values.target / base, - min: 0, - max: 1, - onChanged: (value) => onChanged(( - min: values.min, - target: value * base, - max: values.max, - )), - ), - ), - ], - ); - - final maxSlider = Row( - children: [ - Text(context.l10n.max, style: labelStyle), - Expanded( - child: Slider( - value: values.max / base, - min: 0, - max: 1, - onChanged: (value) => onChanged(( - min: values.min, - target: values.target, - max: value * base, - )), - ), - ), - ], - ); - - return LayoutBuilder(builder: (context, constrain) { - return Card( - child: ExpansionTile( - title: DefaultTextStyle( - style: Theme.of(context).textTheme.titleSmall!, - child: title, - ), - shape: const Border(), - leading: AnimatedBuilder( - animation: animation, - builder: (context, child) { - return Transform.rotate( - angle: (animation.value * 3.14) / 2, - child: child, - ); - }, - child: const Icon(Icons.chevron_right), - ), - trailing: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: ToggleButtons( - borderRadius: BorderRadius.circular(8), - textStyle: labelStyle, - isSelected: [ - values == lowValues(base), - values == moderateValues(base), - values == highValues(base), - ], - onPressed: (index) { - RecommendationAttribute newValues = zeroValues; - switch (index) { - case 0: - newValues = lowValues(base); - break; - case 1: - newValues = moderateValues(base); - break; - case 2: - newValues = highValues(base); - break; - } - - if (newValues == values) { - onChanged(zeroValues); - } else { - onChanged(newValues); - } - }, - children: [ - Text(context.l10n.low), - Text(" ${context.l10n.moderate} "), - Text(context.l10n.high), - ], - ), - ), - onExpansionChanged: (value) { - if (value) { - animation.forward(); - } else { - animation.reverse(); - } - }, - children: [ - if (constrain.mdAndUp) - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - const SizedBox(width: 16), - Expanded(child: minSlider), - Expanded(child: targetSlider), - Expanded(child: maxSlider), - ], - ) - else - Padding( - padding: const EdgeInsets.only(left: 16), - child: Column( - children: [ - minSlider, - targetSlider, - maxSlider, - ], - ), - ), - ], - ), - ); - }); - } -} diff --git a/lib/components/library/playlist_generate/recommendation_attribute_fields.dart b/lib/components/library/playlist_generate/recommendation_attribute_fields.dart deleted file mode 100644 index de169147..00000000 --- a/lib/components/library/playlist_generate/recommendation_attribute_fields.dart +++ /dev/null @@ -1,179 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:spotube/components/library/playlist_generate/recommendation_attribute_dials.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; - -class RecommendationAttributeFields extends HookWidget { - final Widget title; - final RecommendationAttribute values; - final ValueChanged onChanged; - final Map? presets; - - const RecommendationAttributeFields({ - Key? key, - required this.values, - required this.onChanged, - required this.title, - this.presets, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final animation = useAnimationController( - duration: const Duration(milliseconds: 300), - ); - final labelStyle = Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.w500, - ); - - final minController = useTextEditingController(text: values.min.toString()); - final targetController = - useTextEditingController(text: values.target.toString()); - final maxController = useTextEditingController(text: values.max.toString()); - - useEffect(() { - listener() { - onChanged(( - min: double.tryParse(minController.text) ?? 0, - target: double.tryParse(targetController.text) ?? 0, - max: double.tryParse(maxController.text) ?? 0, - )); - } - - minController.addListener(listener); - targetController.addListener(listener); - maxController.addListener(listener); - - return () { - minController.removeListener(listener); - targetController.removeListener(listener); - maxController.removeListener(listener); - }; - }, [values]); - - final minField = TextField( - controller: minController, - decoration: InputDecoration( - labelText: context.l10n.min, - isDense: true, - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: false, - signed: true, - ), - ); - - final targetField = TextField( - controller: targetController, - decoration: InputDecoration( - labelText: context.l10n.target, - isDense: true, - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: false, - signed: true, - ), - ); - - final maxField = TextField( - controller: maxController, - decoration: InputDecoration( - labelText: context.l10n.max, - isDense: true, - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: false, - signed: true, - ), - ); - - return LayoutBuilder(builder: (context, constrain) { - return Card( - child: ExpansionTile( - title: DefaultTextStyle( - style: Theme.of(context).textTheme.titleSmall!, - child: title, - ), - shape: const Border(), - leading: AnimatedBuilder( - animation: animation, - builder: (context, child) { - return Transform.rotate( - angle: (animation.value * 3.14) / 2, - child: child, - ); - }, - child: const Icon(Icons.chevron_right), - ), - trailing: presets == null - ? const SizedBox.shrink() - : Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: ToggleButtons( - borderRadius: BorderRadius.circular(8), - textStyle: labelStyle, - isSelected: presets!.values - .map((value) => value == values) - .toList(), - onPressed: (index) { - RecommendationAttribute newValues = - presets!.values.elementAt(index); - if (newValues == values) { - onChanged(zeroValues); - minController.text = zeroValues.min.toString(); - targetController.text = zeroValues.target.toString(); - maxController.text = zeroValues.max.toString(); - } else { - onChanged(newValues); - minController.text = newValues.min.toString(); - targetController.text = newValues.target.toString(); - maxController.text = newValues.max.toString(); - } - }, - children: presets!.keys.map((key) => Text(key)).toList(), - ), - ), - onExpansionChanged: (value) { - if (value) { - animation.forward(); - } else { - animation.reverse(); - } - }, - children: [ - const SizedBox(height: 8), - if (constrain.mdAndUp) - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - const SizedBox(width: 16), - Expanded(child: minField), - const SizedBox(width: 16), - Expanded(child: targetField), - const SizedBox(width: 16), - Expanded(child: maxField), - const SizedBox(width: 16), - ], - ) - else - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - children: [ - minField, - const SizedBox(height: 16), - targetField, - const SizedBox(height: 16), - maxField, - ], - ), - ), - const SizedBox(height: 8), - ], - ), - ); - }); - } -} diff --git a/lib/components/library/user_albums.dart b/lib/components/library/user_albums.dart deleted file mode 100644 index 200d1c59..00000000 --- a/lib/components/library/user_albums.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'package:flutter/material.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:skeletonizer/skeletonizer.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/fake.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/album/album_card.dart'; -import 'package:spotube/components/shared/fallbacks/not_found.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/components/shared/waypoint.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; - -import 'package:spotube/utils/type_conversion_utils.dart'; - -class UserAlbums extends HookConsumerWidget { - const UserAlbums({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final auth = ref.watch(AuthenticationNotifier.provider); - final albumsQuery = useQueries.album.ofMine(ref); - - final controller = useScrollController(); - - final searchText = useState(''); - - final allAlbums = useMemoized( - () => albumsQuery.pages - .expand((element) => element.items ?? []), - [albumsQuery.pages], - ); - - final albums = useMemoized(() { - if (searchText.value.isEmpty) { - return allAlbums; - } - return allAlbums - .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(); - }, [allAlbums, searchText.value]); - - if (auth == null) { - return const AnonymousFallback(); - } - - final theme = Theme.of(context); - - return RefreshIndicator( - onRefresh: () async { - await albumsQuery.refresh(); - }, - child: SafeArea( - child: Scaffold( - appBar: PreferredSize( - preferredSize: const Size.fromHeight(50), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: ColoredBox( - color: theme.scaffoldBackgroundColor, - child: SearchBar( - onChanged: (value) => searchText.value = value, - leading: const Icon(SpotubeIcons.filter), - hintText: context.l10n.filter_albums, - ), - ), - ), - ), - body: SizedBox.expand( - child: InterScrollbar( - controller: controller, - child: SingleChildScrollView( - padding: const EdgeInsets.all(8.0), - controller: controller, - child: Skeletonizer( - enabled: albumsQuery.pages.isEmpty, - child: Center( - child: Wrap( - runSpacing: 20, - alignment: WrapAlignment.center, - runAlignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - if (albumsQuery.pages.isEmpty) - ...List.generate( - 10, - (index) => AlbumCard(FakeData.album), - ) - else if (albums.isEmpty) - const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [NotFound()], - ), - for (final album in albums) - AlbumCard( - TypeConversionUtils.simpleAlbum_X_Album(album), - ), - if (albums.isNotEmpty && albumsQuery.hasNextPage) - Waypoint( - controller: controller, - isGrid: true, - onTouchEdge: albumsQuery.fetchNext, - child: AlbumCard(FakeData.album), - ) - ], - ), - ), - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/components/library/user_artists.dart b/lib/components/library/user_artists.dart deleted file mode 100644 index 36b8528e..00000000 --- a/lib/components/library/user_artists.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:flutter/material.dart'; -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:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/fake.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/components/artist/artist_card.dart'; -import 'package:spotube/components/shared/fallbacks/not_found.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; - -class UserArtists extends HookConsumerWidget { - const UserArtists({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final auth = ref.watch(AuthenticationNotifier.provider); - - final artistQuery = useQueries.artist.followedByMeAll(ref); - - final searchText = useState(''); - - final filteredArtists = useMemoized(() { - final artists = artistQuery.data ?? []; - - 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.data, searchText.value]); - - final controller = useScrollController(); - - if (auth == null) { - return const AnonymousFallback(); - } - - return Scaffold( - appBar: PreferredSize( - preferredSize: const Size.fromHeight(50), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: ColoredBox( - color: theme.scaffoldBackgroundColor, - child: SearchBar( - onChanged: (value) => searchText.value = value, - leading: const Icon(SpotubeIcons.filter), - hintText: context.l10n.filter_artist, - ), - ), - ), - ), - backgroundColor: theme.scaffoldBackgroundColor, - body: artistQuery.data?.isEmpty == true - ? Padding( - padding: const EdgeInsets.all(20), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - const SizedBox(width: 10), - Text(context.l10n.loading), - ], - ), - ) - : RefreshIndicator( - onRefresh: () async { - await artistQuery.refresh(); - }, - child: InterScrollbar( - controller: controller, - child: SingleChildScrollView( - controller: controller, - child: SizedBox( - width: double.infinity, - child: SafeArea( - child: Center( - child: Skeletonizer( - enabled: artistQuery.isLoading, - child: Wrap( - spacing: 15, - runSpacing: 5, - children: artistQuery.isLoading - ? List.generate( - 10, (index) => ArtistCard(FakeData.artist)) - : filteredArtists.isEmpty - ? [ - const Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - NotFound(), - ], - ) - ] - : filteredArtists - .mapIndexed((index, artist) => - ArtistCard(artist)) - .toList(), - ), - ), - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart deleted file mode 100644 index 095e6e97..00000000 --- a/lib/components/library/user_local_tracks.dart +++ /dev/null @@ -1,324 +0,0 @@ -import 'dart:io'; - -import 'package:catcher_2/catcher_2.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -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:metadata_god/metadata_god.dart'; -import 'package:mime/mime.dart'; -import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:skeletonizer/skeletonizer.dart'; - -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; -import 'package:spotube/components/shared/fallbacks/not_found.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; -import 'package:spotube/components/shared/track_tile/track_tile.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/local_track.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; -import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; - -const supportedAudioTypes = [ - "audio/webm", - "audio/ogg", - "audio/mpeg", - "audio/mp4", - "audio/opus", - "audio/wav", - "audio/aac", -]; - -const imgMimeToExt = { - "image/png": ".png", - "image/jpeg": ".jpg", - "image/webp": ".webp", - "image/gif": ".gif", -}; - -enum SortBy { - none, - ascending, - descending, - newest, - oldest, - duration, - artist, - album, -} - -final localTracksProvider = FutureProvider>((ref) async { - try { - if (kIsWeb) return []; - final downloadLocation = ref.watch( - userPreferencesProvider.select((s) => s.downloadLocation), - ); - if (downloadLocation.isEmpty) return []; - final downloadDir = Directory(downloadLocation); - if (!await downloadDir.exists()) { - await downloadDir.create(recursive: true); - return []; - } - final entities = downloadDir.listSync(recursive: true); - - final filesWithMetadata = (await Future.wait( - entities.map((e) => File(e.path)).where((file) { - final mimetype = lookupMimeType(file.path); - return mimetype != null && supportedAudioTypes.contains(mimetype); - }).map( - (file) async { - try { - final metadata = await MetadataGod.readMetadata(file: file.path); - - final imageFile = File(join( - (await getTemporaryDirectory()).path, - "spotube", - 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 is FfiException) { - return {"file": file}; - } - Catcher2.reportCheckedError(e, stack); - return {}; - } - }, - ), - )) - .where((e) => e.isNotEmpty) - .toList(); - - final tracks = filesWithMetadata - .map( - (fileWithMetadata) => LocalTrack.fromTrack( - track: TypeConversionUtils.localTrack_X_Track( - fileWithMetadata["file"], - metadata: fileWithMetadata["metadata"], - art: fileWithMetadata["art"], - ), - path: fileWithMetadata["file"].path, - ), - ) - .toList(); - - return tracks; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - return []; - } -}); - -class UserLocalTracks extends HookConsumerWidget { - const UserLocalTracks({Key? key}) : super(key: key); - - Future playLocalTracks( - WidgetRef ref, - List tracks, { - LocalTrack? currentTrack, - }) async { - final playlist = ref.read(ProxyPlaylistNotifier.provider); - final playback = ref.read(ProxyPlaylistNotifier.notifier); - currentTrack ??= tracks.first; - final isPlaylistPlaying = playlist.containsTracks(tracks); - if (!isPlaylistPlaying) { - await playback.load( - tracks, - initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), - autoPlay: true, - ); - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != playlist.activeTrack?.id) { - await playback.jumpToTrack(currentTrack); - } - } - - @override - Widget build(BuildContext context, ref) { - final sortBy = useState(SortBy.none); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final trackSnapshot = ref.watch(localTracksProvider); - final isPlaylistPlaying = - playlist.containsTracks(trackSnapshot.value ?? []); - - final searchController = useTextEditingController(); - useValueListenable(searchController); - final searchFocus = useFocusNode(); - final isFiltering = useState(false); - - final controller = useScrollController(); - - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - const SizedBox(width: 10), - FilledButton( - onPressed: trackSnapshot.value != null - ? () async { - if (trackSnapshot.value?.isNotEmpty == true) { - if (!isPlaylistPlaying) { - await playLocalTracks( - ref, - trackSnapshot.value!, - ); - } else { - // TODO: Remove stop capability - // playlistNotifier.stop(); - } - } - } - : null, - child: Row( - children: [ - Text(context.l10n.play), - Icon( - isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play, - ) - ], - ), - ), - const Spacer(), - ExpandableSearchButton( - isFiltering: isFiltering.value, - onPressed: (value) => isFiltering.value = value, - searchFocus: searchFocus, - ), - const SizedBox(width: 10), - SortTracksDropdown( - value: sortBy.value, - onChanged: (value) { - sortBy.value = value; - }, - ), - const SizedBox(width: 10), - FilledButton( - child: const Icon(SpotubeIcons.refresh), - onPressed: () { - ref.refresh(localTracksProvider); - }, - ) - ], - ), - ), - ExpandableSearchField( - searchController: searchController, - searchFocus: searchFocus, - isFiltering: isFiltering.value, - onChangeFiltering: (value) => isFiltering.value = value, - ), - trackSnapshot.when( - data: (tracks) { - final sortedTracks = useMemoized(() { - return ServiceUtils.sortTracks(tracks, sortBy.value); - }, [sortBy.value, tracks]); - - final filteredTracks = useMemoized(() { - if (searchController.text.isEmpty) { - return sortedTracks; - } - return sortedTracks - .map((e) => ( - weightedRatio( - "${e.name} - ${TypeConversionUtils.artists_X_String(e.artists ?? [])}", - 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 const Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [NotFound()], - ), - ); - } - - return Expanded( - child: RefreshIndicator( - onRefresh: () async { - ref.refresh(localTracksProvider); - }, - child: InterScrollbar( - controller: controller, - child: Skeletonizer( - enabled: trackSnapshot.isLoading, - child: ListView.builder( - controller: controller, - physics: const AlwaysScrollableScrollPhysics(), - itemCount: - trackSnapshot.isLoading ? 5 : filteredTracks.length, - itemBuilder: (context, index) { - if (trackSnapshot.isLoading) { - return TrackTile(track: FakeData.track, index: index); - } - - final track = filteredTracks[index]; - return TrackTile( - index: index, - track: track, - userPlaylist: false, - onTap: () async { - await playLocalTracks( - ref, - sortedTracks, - currentTrack: track, - ); - }, - ); - }, - ), - ), - ), - ), - ); - }, - loading: () => Expanded( - child: Skeletonizer( - enabled: true, - child: ListView.builder( - itemCount: 5, - itemBuilder: (context, index) => - TrackTile(track: FakeData.track, index: index), - ), - ), - ), - error: (error, stackTrace) => - Text(error.toString() + stackTrace.toString()), - ) - ], - ); - } -} diff --git a/lib/components/library/user_playlists.dart b/lib/components/library/user_playlists.dart deleted file mode 100644 index 32e91ed6..00000000 --- a/lib/components/library/user_playlists.dart +++ /dev/null @@ -1,163 +0,0 @@ -import 'package:flutter/material.dart' hide Image; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:fuzzywuzzy/fuzzywuzzy.dart'; -import 'package:collection/collection.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; - -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/playlist/playlist_create_dialog.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/components/playlist/playlist_card.dart'; -import 'package:spotube/components/shared/waypoint.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; - -class UserPlaylists extends HookConsumerWidget { - const UserPlaylists({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final searchText = useState(''); - - final auth = ref.watch(AuthenticationNotifier.provider); - - final playlistsQuery = useQueries.playlist.ofMine(ref); - - final pagePlaylists = useMemoized( - () => playlistsQuery.pages - .expand((page) => page.items?.toList() ?? []), - [playlistsQuery.pages], - ); - - final likedTracksPlaylist = useMemoized( - () => PlaylistSimple() - ..name = context.l10n.liked_tracks - ..description = context.l10n.liked_tracks_description - ..type = "playlist" - ..collaborative = false - ..public = false - ..id = "user-liked-tracks" - ..images = [ - Image() - ..height = 300 - ..width = 300 - ..url = "assets/liked-tracks.jpg" - ], - [context.l10n], - ); - - final playlists = useMemoized( - () { - if (searchText.value.isEmpty) { - return [ - likedTracksPlaylist, - ...pagePlaylists, - ]; - } - return [ - likedTracksPlaylist, - ...pagePlaylists, - ] - .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(); - }, - [pagePlaylists, searchText.value], - ); - - final controller = useScrollController(); - - if (auth == null) { - return const AnonymousFallback(); - } - - return RefreshIndicator( - onRefresh: playlistsQuery.refresh, - child: SafeArea( - child: InterScrollbar( - controller: controller, - child: CustomScrollView( - controller: controller, - slivers: [ - SliverToBoxAdapter( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(10), - child: SearchBar( - onChanged: (value) => searchText.value = value, - hintText: context.l10n.filter_playlists, - leading: const Icon(SpotubeIcons.filter), - ), - ), - Row( - children: [ - const SizedBox(width: 10), - const PlaylistCreateDialogButton(), - const SizedBox(width: 10), - ElevatedButton.icon( - icon: const Icon(SpotubeIcons.magic), - label: Text(context.l10n.generate_playlist), - onPressed: () { - GoRouter.of(context).push("/library/generate"); - }, - ), - const SizedBox(width: 10), - ], - ), - ], - ), - ), - const SliverToBoxAdapter( - child: SizedBox(height: 10), - ), - SliverLayoutBuilder(builder: (context, constrains) { - return SliverGrid.builder( - itemCount: playlists.isEmpty ? 6 : playlists.length + 1, - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - mainAxisExtent: constrains.smAndDown ? 225 : 250, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - itemBuilder: (context, index) { - if (playlists.isNotEmpty && index == playlists.length) { - if (!playlistsQuery.hasNextPage) { - return const SizedBox.shrink(); - } - - return Waypoint( - controller: controller, - isGrid: true, - onTouchEdge: playlistsQuery.fetchNext, - child: Skeletonizer( - enabled: true, - child: PlaylistCard(FakeData.playlistSimple), - ), - ); - } - - return PlaylistCard( - playlists.elementAtOrNull(index) ?? - FakeData.playlistSimple, - ); - }, - ); - }) - ], - ), - ), - ), - ); - } -} diff --git a/lib/components/shared/links/anchor_button.dart b/lib/components/links/anchor_button.dart similarity index 89% rename from lib/components/shared/links/anchor_button.dart rename to lib/components/links/anchor_button.dart index b1b1cfea..a0b3fa73 100644 --- a/lib/components/shared/links/anchor_button.dart +++ b/lib/components/links/anchor_button.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; class AnchorButton extends HookWidget { final String text; @@ -11,13 +11,13 @@ class AnchorButton extends HookWidget { const AnchorButton( this.text, { - Key? key, + super.key, this.onTap, this.textAlign, this.overflow, this.maxLines, this.style = const TextStyle(), - }) : super(key: key); + }); @override Widget build(BuildContext context) { @@ -29,7 +29,7 @@ class AnchorButton extends HookWidget { onTapUp: (event) => tap.value = false, onTap: onTap, child: MouseRegion( - cursor: MaterialStateMouseCursor.clickable, + cursor: WidgetStateMouseCursor.clickable, child: Text( text, style: style.copyWith( diff --git a/lib/components/links/artist_link.dart b/lib/components/links/artist_link.dart new file mode 100644 index 00000000..9467cb38 --- /dev/null +++ b/lib/components/links/artist_link.dart @@ -0,0 +1,76 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/components/links/anchor_button.dart'; +import 'package:spotube/extensions/context.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) { + if (artist.value.name == null) { + return Text("Spotify", style: textStyle); + } + 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/shared/links/hyper_link.dart b/lib/components/links/hyper_link.dart similarity index 84% rename from lib/components/shared/links/hyper_link.dart rename to lib/components/links/hyper_link.dart index fd31298e..647edaca 100644 --- a/lib/components/shared/links/hyper_link.dart +++ b/lib/components/links/hyper_link.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; -import 'package:spotube/components/shared/links/anchor_button.dart'; +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 { @@ -13,12 +13,12 @@ class Hyperlink extends StatelessWidget { const Hyperlink( this.text, this.url, { - Key? key, + super.key, this.textAlign, this.overflow, this.style = const TextStyle(), this.maxLines, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/links/link_text.dart b/lib/components/links/link_text.dart similarity index 63% rename from lib/components/shared/links/link_text.dart rename to lib/components/links/link_text.dart index d7b00b72..c64ae93d 100644 --- a/lib/components/shared/links/link_text.dart +++ b/lib/components/links/link_text.dart @@ -1,28 +1,26 @@ -import 'package:flutter/material.dart'; -import 'package:spotube/components/shared/links/anchor_button.dart'; -import 'package:spotube/utils/service_utils.dart'; +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 String route; + final PageRouteInfo route; final int? maxLines; - final T? extra; final bool push; const LinkText( this.text, this.route, { - Key? key, + super.key, this.textAlign, - this.extra, this.overflow, this.style = const TextStyle(), this.maxLines, this.push = false, - }) : super(key: key); + }); @override Widget build(BuildContext context) { @@ -30,9 +28,9 @@ class LinkText extends StatelessWidget { text, onTap: () { if (push) { - ServiceUtils.push(context, route, extra: extra); + context.navigateTo(route); } else { - ServiceUtils.navigate(context, route, extra: extra); + context.navigateTo(route); } }, key: key, diff --git a/lib/components/playbutton_view/playbutton_card.dart b/lib/components/playbutton_view/playbutton_card.dart new file mode 100644 index 00000000..ea28c738 --- /dev/null +++ b/lib/components/playbutton_view/playbutton_card.dart @@ -0,0 +1,170 @@ +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 new file mode 100644 index 00000000..7470105d --- /dev/null +++ b/lib/components/playbutton_view/playbutton_tile.dart @@ -0,0 +1,115 @@ +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 new file mode 100644 index 00000000..7880bb8c --- /dev/null +++ b/lib/components/playbutton_view/playbutton_view.dart @@ -0,0 +1,204 @@ +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/player/player.dart b/lib/components/player/player.dart deleted file mode 100644 index 458676e3..00000000 --- a/lib/components/player/player.dart +++ /dev/null @@ -1,390 +0,0 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'package:spotify/spotify.dart' hide Offset; -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/player/player_actions.dart'; -import 'package:spotube/components/player/player_controls.dart'; -import 'package:spotube/components/player/player_queue.dart'; -import 'package:spotube/components/player/volume_slider.dart'; -import 'package:spotube/components/shared/animated_gradient.dart'; -import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/panels/sliding_up_panel.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart'; -import 'package:spotube/hooks/utils/use_palette_color.dart'; -import 'package:spotube/models/local_track.dart'; -import 'package:spotube/pages/lyrics/lyrics.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/services/sourced_track/sources/youtube.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; -import 'package:url_launcher/url_launcher_string.dart'; - -class PlayerView extends HookConsumerWidget { - final PanelController panelController; - final ScrollController scrollController; - const PlayerView({ - Key? key, - required this.panelController, - required this.scrollController, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final auth = ref.watch(AuthenticationNotifier.provider); - final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select( - (value) => value.activeTrack, - )); - final isLocalTrack = ref.watch(ProxyPlaylistNotifier.provider.select( - (value) => value.activeTrack is LocalTrack, - )); - final mediaQuery = MediaQuery.of(context); - - useEffect(() { - if (mediaQuery.lgAndUp) { - WidgetsBinding.instance.addPostFrameCallback((_) { - panelController.close(); - }); - } - return null; - }, [mediaQuery.lgAndUp]); - - String albumArt = useMemoized( - () => TypeConversionUtils.image_X_UrlString( - currentTrack?.album?.images, - placeholder: ImagePlaceholder.albumArt, - ), - [currentTrack?.album?.images], - ); - - final palette = usePaletteGenerator(albumArt); - final titleTextColor = palette.dominantColor?.titleTextColor; - final bodyTextColor = palette.dominantColor?.bodyTextColor; - - final bgColor = palette.dominantColor?.color ?? theme.colorScheme.primary; - - final GlobalKey scaffoldKey = - useMemoized(() => GlobalKey(), []); - - useEffect(() { - for (final renderView in WidgetsBinding.instance.renderViews) { - renderView.automaticSystemUiAdjustment = false; - } - - return () { - for (final renderView in WidgetsBinding.instance.renderViews) { - renderView.automaticSystemUiAdjustment = true; - } - }; - }, [panelController.isPanelOpen]); - - useCustomStatusBarColor( - bgColor, - panelController.isPanelOpen, - noSetBGColor: true, - automaticSystemUiAdjustment: false, - ); - - final topPadding = MediaQueryData.fromView(View.of(context)).padding.top; - - return WillPopScope( - onWillPop: () async { - await panelController.close(); - return false; - }, - child: IconTheme( - data: theme.iconTheme.copyWith(color: bodyTextColor), - child: AnimateGradient( - animateAlignments: true, - primaryBegin: Alignment.topLeft, - primaryEnd: Alignment.bottomLeft, - secondaryBegin: Alignment.bottomRight, - secondaryEnd: Alignment.topRight, - duration: const Duration(seconds: 15), - primaryColors: [ - palette.dominantColor?.color ?? theme.colorScheme.primary, - palette.mutedColor?.color ?? theme.colorScheme.secondary, - ], - secondaryColors: [ - (palette.darkVibrantColor ?? palette.lightVibrantColor)?.color ?? - theme.colorScheme.primaryContainer, - (palette.darkMutedColor ?? palette.lightMutedColor)?.color ?? - theme.colorScheme.secondaryContainer, - ], - child: Scaffold( - key: scaffoldKey, - backgroundColor: Colors.transparent, - appBar: PreferredSize( - preferredSize: Size.fromHeight( - kToolbarHeight + topPadding, - ), - child: ForceDraggableWidget( - child: Padding( - padding: EdgeInsets.only(top: topPadding), - child: PageWindowTitleBar( - backgroundColor: Colors.transparent, - foregroundColor: titleTextColor, - toolbarOpacity: 1, - leading: IconButton( - icon: const Icon(SpotubeIcons.angleDown, size: 18), - onPressed: panelController.close, - ), - actions: [ - if (currentTrack is YoutubeSourcedTrack) - TextButton.icon( - icon: Assets.logos.songlinkTransparent.image( - width: 20, - height: 20, - color: bodyTextColor, - ), - label: Text(context.l10n.song_link), - style: TextButton.styleFrom( - foregroundColor: bodyTextColor, - padding: EdgeInsets.zero, - ), - onPressed: () { - final url = - "https://song.link/s/${currentTrack.id}"; - - launchUrlString(url); - }, - ), - IconButton( - icon: const Icon(SpotubeIcons.info, size: 18), - tooltip: context.l10n.details, - style: IconButton.styleFrom( - foregroundColor: bodyTextColor, - ), - onPressed: currentTrack == null - ? null - : () { - showDialog( - context: context, - builder: (context) { - return TrackDetailsDialog( - track: currentTrack, - ); - }); - }, - ) - ], - ), - ), - ), - ), - extendBodyBehindAppBar: true, - body: SingleChildScrollView( - controller: scrollController, - child: Container( - alignment: Alignment.center, - width: double.infinity, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 580), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: [ - ForceDraggableWidget( - child: Container( - margin: const EdgeInsets.all(8), - constraints: const BoxConstraints( - maxHeight: 300, maxWidth: 300), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - spreadRadius: 2, - blurRadius: 10, - offset: Offset(0, 0), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: UniversalImage( - path: albumArt, - placeholder: Assets.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( - currentTrack?.name ?? "Not playing", - style: TextStyle( - color: titleTextColor, - fontSize: 22, - ), - maxFontSize: 22, - maxLines: 1, - textAlign: TextAlign.start, - ), - if (isLocalTrack) - Text( - TypeConversionUtils.artists_X_String< - Artist>( - currentTrack?.artists ?? [], - ), - style: theme.textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.bold, - color: bodyTextColor, - ), - ) - else - TypeConversionUtils - .artists_X_ClickableArtists( - currentTrack?.artists ?? [], - textStyle: - theme.textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.bold, - color: bodyTextColor, - ), - onRouteChange: (route) { - panelController.close(); - GoRouter.of(context).push(route); - }, - ), - ], - ), - ), - const SizedBox(height: 10), - PlayerControls(palette: palette), - const SizedBox(height: 25), - PlayerActions( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - showQueue: false, - ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - const SizedBox(width: 10), - Expanded( - child: OutlinedButton.icon( - icon: const Icon(SpotubeIcons.queue), - label: Text(context.l10n.queue), - style: OutlinedButton.styleFrom( - foregroundColor: bodyTextColor, - side: BorderSide( - color: bodyTextColor ?? Colors.white, - ), - ), - onPressed: currentTrack != null - ? () { - showModalBottomSheet( - context: context, - isDismissible: true, - enableDrag: true, - isScrollControlled: true, - backgroundColor: Colors.black12, - barrierColor: Colors.black12, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(10), - ), - constraints: BoxConstraints( - maxHeight: - MediaQuery.of(context) - .size - .height * - .7, - ), - builder: (context) { - return const PlayerQueue( - floating: false); - }, - ); - } - : null), - ), - if (auth != null) const SizedBox(width: 10), - if (auth != null) - Expanded( - child: OutlinedButton.icon( - label: Text(context.l10n.lyrics), - icon: const Icon(SpotubeIcons.music), - style: OutlinedButton.styleFrom( - foregroundColor: bodyTextColor, - side: BorderSide( - color: bodyTextColor ?? Colors.white, - ), - ), - onPressed: () { - showModalBottomSheet( - context: context, - isDismissible: true, - enableDrag: true, - isScrollControlled: true, - backgroundColor: Colors.black38, - barrierColor: Colors.black12, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(20), - ), - ), - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context) - .size - .height * - 0.8, - ), - builder: (context) => - const LyricsPage(isModal: true), - ); - }, - ), - ), - const SizedBox(width: 10), - ], - ), - const SizedBox(height: 25), - SliderTheme( - data: theme.sliderTheme.copyWith( - activeTrackColor: titleTextColor, - inactiveTrackColor: bodyTextColor, - thumbColor: titleTextColor, - overlayColor: titleTextColor?.withOpacity(0.2), - trackHeight: 2, - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 8, - ), - ), - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: VolumeSlider( - fullWidth: true, - ), - ), - ), - ], - ), - ), - ), - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/components/player/player_actions.dart b/lib/components/player/player_actions.dart deleted file mode 100644 index 7a248aa5..00000000 --- a/lib/components/player/player_actions.dart +++ /dev/null @@ -1,199 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'package:spotify/spotify.dart' hide Offset; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/player/sibling_tracks_sheet.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; -import 'package:spotube/components/shared/heart_button.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/duration.dart'; -import 'package:spotube/models/local_track.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/sleep_timer_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class PlayerActions extends HookConsumerWidget { - final MainAxisAlignment mainAxisAlignment; - final bool floatingQueue; - final bool showQueue; - final List? extraActions; - PlayerActions({ - this.mainAxisAlignment = MainAxisAlignment.center, - this.floatingQueue = true, - this.showQueue = true, - this.extraActions, - Key? key, - }) : super(key: key); - final logger = getLogger(PlayerActions); - - @override - Widget build(BuildContext context, ref) { - final mediaQuery = MediaQuery.of(context); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final isLocalTrack = playlist.activeTrack is LocalTrack; - ref.watch(downloadManagerProvider); - final downloader = ref.watch(downloadManagerProvider.notifier); - final isInQueue = useMemoized(() { - if (playlist.activeTrack == null) return false; - return downloader.isActive(playlist.activeTrack!); - }, [ - playlist.activeTrack, - downloader, - ]); - - final localTracks = [] /* ref.watch(localTracksProvider).value */; - final auth = ref.watch(AuthenticationNotifier.provider); - final sleepTimer = ref.watch(SleepTimerNotifier.provider); - final sleepTimerNotifier = ref.watch(SleepTimerNotifier.notifier); - - final isDownloaded = useMemoized(() { - return localTracks.any( - (element) => - element.name == playlist.activeTrack?.name && - element.album?.name == playlist.activeTrack?.album?.name && - TypeConversionUtils.artists_X_String( - element.artists ?? []) == - TypeConversionUtils.artists_X_String( - playlist.activeTrack?.artists ?? []), - ) == - 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) - IconButton( - icon: const Icon(SpotubeIcons.queue), - tooltip: context.l10n.queue, - onPressed: playlist.activeTrack != null - ? () { - Scaffold.of(context).openEndDrawer(); - } - : null, - ), - if (!isLocalTrack) - IconButton( - icon: const Icon(SpotubeIcons.alternativeRoute), - tooltip: context.l10n.alternative_track_sources, - onPressed: playlist.activeTrack != null - ? () { - showModalBottomSheet( - context: context, - isDismissible: true, - enableDrag: true, - isScrollControlled: true, - backgroundColor: Colors.black12, - barrierColor: Colors.black12, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - builder: (context) { - return SiblingTracksSheet(floating: floatingQueue); - }, - ); - } - : null, - ), - if (!kIsWeb && !isLocalTrack) - if (isInQueue) - const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - else - IconButton( - tooltip: context.l10n.download_track, - icon: Icon( - isDownloaded ? SpotubeIcons.done : SpotubeIcons.download, - ), - onPressed: playlist.activeTrack != null - ? () => downloader.addToQueue(playlist.activeTrack!) - : null, - ), - if (playlist.activeTrack != null && !isLocalTrack && auth != null) - TrackHeartButton(track: playlist.activeTrack!), - AdaptivePopSheetList( - 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); - } - }, - children: [ - for (final entry in sleepTimerEntries.entries) - PopSheetEntry( - value: entry.value, - enabled: sleepTimer != entry.value, - title: Text(entry.key), - ), - PopSheetEntry( - title: Text( - customHoursEnabled - ? context.l10n.custom_hours - : sleepTimer.format(abbreviated: true), - ), - // only enabled when there's no preset timers selected - enabled: customHoursEnabled, - onTap: () async { - final currentTime = TimeOfDay.now(); - final time = await showTimePicker( - context: context, - initialTime: currentTime, - ); - - if (time != null) { - sleepTimerNotifier.setSleepTimer( - Duration( - hours: (time.hour - currentTime.hour).abs(), - minutes: (time.minute - currentTime.minute).abs(), - ), - ); - } - }, - ), - PopSheetEntry( - value: Duration.zero, - enabled: sleepTimer != Duration.zero && sleepTimer != null, - textColor: Colors.green, - title: Text(context.l10n.cancel), - ), - ], - ), - ...(extraActions ?? []) - ], - ); - } -} diff --git a/lib/components/player/player_controls.dart b/lib/components/player/player_controls.dart deleted file mode 100644 index 1000af18..00000000 --- a/lib/components/player/player_controls.dart +++ /dev/null @@ -1,285 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:palette_generator/palette_generator.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/collections/intents.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/duration.dart'; -import 'package:spotube/components/player/use_progress.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/audio_player/loop_mode.dart'; - -class PlayerControls extends HookConsumerWidget { - final PaletteGenerator? palette; - final bool compact; - - PlayerControls({ - this.palette, - this.compact = false, - Key? key, - }) : super(key: key); - - final logger = getLogger(PlayerControls); - - 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 playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - - final playing = - useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; - final theme = Theme.of(context); - - final isDominantColorDark = ThemeData.estimateBrightnessForColor( - palette?.dominantColor?.color ?? theme.colorScheme.primary, - ) == - Brightness.dark; - - final dominantColor = isDominantColorDark - ? palette?.mutedColor ?? palette?.dominantColor - : palette?.dominantColor; - - final sliderColor = - palette?.dominantColor?.titleTextColor ?? theme.colorScheme.primary; - - final buttonStyle = IconButton.styleFrom( - backgroundColor: dominantColor?.color.withOpacity(0.2) ?? - theme.colorScheme.surface.withOpacity(0.4), - minimumSize: const Size(28, 28), - ); - - final activeButtonStyle = IconButton.styleFrom( - backgroundColor: - dominantColor?.titleTextColor ?? theme.colorScheme.primaryContainer, - foregroundColor: - dominantColor?.color ?? theme.colorScheme.onPrimaryContainer, - minimumSize: const Size(28, 28), - ); - - final accentColor = palette?.lightVibrantColor ?? - palette?.darkVibrantColor ?? - dominantColor; - - final resumePauseStyle = IconButton.styleFrom( - backgroundColor: accentColor?.color ?? theme.colorScheme.primary, - foregroundColor: - accentColor?.titleTextColor ?? theme.colorScheme.onPrimary, - padding: EdgeInsets.all(compact ? 10 : 12), - iconSize: compact ? 18 : 24, - ); - - 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 ( - :bufferProgress, - :duration, - :position, - :progressStatic - ) = useProgress(ref); - - final progress = useState( - useMemoized(() => progressStatic, []), - ); - - useEffect(() { - progress.value = progressStatic; - return null; - }, [progressStatic]); - - return Column( - children: [ - Tooltip( - message: context.l10n.slide_to_seek, - child: Slider( - // cannot divide by zero - // there's an edge case for value being bigger - // than total duration. Keeping it resolved - value: progress.value.toDouble(), - secondaryTrackValue: bufferProgress, - onChanged: playlist.isFetching == true - ? null - : (v) { - progress.value = v; - }, - onChangeEnd: (value) async { - await audioPlayer.seek( - Duration( - seconds: (value * duration.inSeconds).toInt(), - ), - ); - }, - activeColor: sliderColor, - secondaryActiveColor: sliderColor.withOpacity(0.2), - inactiveColor: sliderColor.withOpacity(0.15), - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - ), - child: DefaultTextStyle( - style: theme.textTheme.bodySmall!.copyWith( - color: palette?.dominantColor?.bodyTextColor, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(position.toHumanReadableString()), - Text(duration.toHumanReadableString()), - ], - ), - ), - ), - ], - ); - }, - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - StreamBuilder( - stream: audioPlayer.shuffledStream, - builder: (context, snapshot) { - final shuffled = snapshot.data ?? false; - return IconButton( - tooltip: shuffled - ? context.l10n.unshuffle_playlist - : context.l10n.shuffle_playlist, - icon: const Icon(SpotubeIcons.shuffle), - style: shuffled ? activeButtonStyle : buttonStyle, - onPressed: playlist.isFetching == true - ? null - : () { - if (shuffled) { - audioPlayer.setShuffle(false); - } else { - audioPlayer.setShuffle(true); - } - }, - ); - }), - IconButton( - tooltip: context.l10n.previous_track, - icon: const Icon(SpotubeIcons.skipBack), - style: buttonStyle, - onPressed: playlist.isFetching == true - ? null - : playlistNotifier.previous, - ), - IconButton( - tooltip: playing - ? context.l10n.pause_playback - : context.l10n.resume_playback, - icon: playlist.isFetching == true - ? SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - color: accentColor?.titleTextColor ?? - theme.colorScheme.onPrimary, - ), - ) - : Icon( - playing ? SpotubeIcons.pause : SpotubeIcons.play, - ), - style: resumePauseStyle, - onPressed: playlist.isFetching == true - ? null - : Actions.handler( - context, - PlayPauseIntent(ref), - ), - ), - IconButton( - tooltip: context.l10n.next_track, - icon: const Icon(SpotubeIcons.skipForward), - style: buttonStyle, - onPressed: playlist.isFetching == true - ? null - : playlistNotifier.next, - ), - StreamBuilder( - stream: audioPlayer.loopModeStream, - builder: (context, snapshot) { - final loopMode = snapshot.data ?? PlaybackLoopMode.none; - return IconButton( - tooltip: loopMode == PlaybackLoopMode.one - ? context.l10n.loop_track - : loopMode == PlaybackLoopMode.all - ? context.l10n.repeat_playlist - : null, - icon: Icon( - loopMode == PlaybackLoopMode.one - ? SpotubeIcons.repeatOne - : SpotubeIcons.repeat, - ), - style: loopMode == PlaybackLoopMode.one || - loopMode == PlaybackLoopMode.all - ? activeButtonStyle - : buttonStyle, - onPressed: playlist.isFetching == true - ? null - : () async { - switch (await audioPlayer.loopMode) { - case PlaybackLoopMode.all: - audioPlayer - .setLoopMode(PlaybackLoopMode.one); - break; - case PlaybackLoopMode.one: - audioPlayer - .setLoopMode(PlaybackLoopMode.none); - break; - case PlaybackLoopMode.none: - audioPlayer - .setLoopMode(PlaybackLoopMode.all); - break; - } - }, - ); - }), - ], - ), - const SizedBox(height: 5) - ], - ), - ), - ), - ); - } -} diff --git a/lib/components/player/player_overlay.dart b/lib/components/player/player_overlay.dart deleted file mode 100644 index 2d63811e..00000000 --- a/lib/components/player/player_overlay.dart +++ /dev/null @@ -1,204 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'package:spotube/components/player/player_track_details.dart'; -import 'package:spotube/components/root/spotube_navigation_bar.dart'; -import 'package:spotube/components/shared/panels/sliding_up_panel.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/collections/intents.dart'; -import 'package:spotube/components/player/use_progress.dart'; -import 'package:spotube/components/player/player.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; - -class PlayerOverlay extends HookConsumerWidget { - final String albumArt; - - const PlayerOverlay({ - required this.albumArt, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final canShow = ref.watch( - ProxyPlaylistNotifier.provider.select((s) => s.active != null), - ); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playing = - useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; - - final theme = Theme.of(context); - final textColor = theme.colorScheme.primary; - - const radius = BorderRadius.only( - topLeft: Radius.circular(10), - topRight: Radius.circular(10), - ); - - final mediaQuery = MediaQuery.of(context); - - final panelController = useMemoized(() => PanelController(), []); - final scrollController = useScrollController(); - - useEffect(() { - return () { - panelController.dispose(); - }; - }, []); - - return SlidingUpPanel( - maxHeight: mediaQuery.size.height, - backdropEnabled: false, - minHeight: canShow ? 53 : 0, - onPanelSlide: (position) { - final invertedPosition = 1 - position; - ref.read(navigationPanelHeight.notifier).state = 50 * invertedPosition; - }, - controller: panelController, - collapsed: ClipRRect( - borderRadius: radius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: AnimatedContainer( - duration: const Duration(milliseconds: 250), - width: mediaQuery.size.width, - decoration: BoxDecoration( - color: theme.colorScheme.secondaryContainer.withOpacity(.8), - borderRadius: radius, - ), - child: AnimatedOpacity( - duration: const Duration(milliseconds: 250), - opacity: canShow ? 1 : 0, - child: Material( - type: MaterialType.transparency, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - HookBuilder( - builder: (context) { - final progress = useProgress(ref); - // animated - return TweenAnimationBuilder( - duration: const Duration(milliseconds: 250), - tween: Tween( - begin: 0, - end: progress.progressStatic, - ), - builder: (context, value, child) { - return LinearProgressIndicator( - value: value, - minHeight: 2, - backgroundColor: Colors.transparent, - valueColor: AlwaysStoppedAnimation( - theme.colorScheme.primary, - ), - ); - }, - ); - }, - ), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: GestureDetector( - onTap: () { - panelController.open(); - }, - child: Container( - width: double.infinity, - color: Colors.transparent, - child: PlayerTrackDetails( - albumArt: albumArt, - color: textColor, - ), - ), - ), - ), - Row( - children: [ - IconButton( - icon: Icon( - SpotubeIcons.skipBack, - color: textColor, - ), - onPressed: playlist.isFetching - ? null - : playlistNotifier.previous, - ), - Consumer( - builder: (context, ref, _) { - return IconButton( - icon: playlist.isFetching - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(), - ) - : Icon( - playing - ? SpotubeIcons.pause - : SpotubeIcons.play, - color: textColor, - ), - onPressed: Actions.handler( - context, - PlayPauseIntent(ref), - ), - ); - }, - ), - IconButton( - icon: Icon( - SpotubeIcons.skipForward, - color: textColor, - ), - onPressed: playlist.isFetching - ? null - : playlistNotifier.next, - ), - ], - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), - ), - scrollController: scrollController, - panelBuilder: (position) { - // this is the reason we're getting an update - final navigationHeight = ref.watch(navigationPanelHeight); - - if (navigationHeight == 50) return const SizedBox(); - - return IgnorePointer( - ignoring: !panelController.isPanelOpen, - child: AnimatedContainer( - clipBehavior: Clip.antiAlias, - duration: const Duration(milliseconds: 250), - decoration: navigationHeight == 0 - ? const BoxDecoration(borderRadius: BorderRadius.zero) - : const BoxDecoration(borderRadius: radius), - child: IgnoreDraggableWidget( - child: PlayerView( - panelController: panelController, - scrollController: scrollController, - ), - ), - ), - ); - }, - ); - } -} diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart deleted file mode 100644 index 2784fb5f..00000000 --- a/lib/components/player/player_queue.dart +++ /dev/null @@ -1,283 +0,0 @@ -import 'dart:ui'; - -import 'package:collection/collection.dart'; -import 'package:flutter/material.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:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/fallbacks/not_found.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/track_tile/track_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/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class PlayerQueue extends HookConsumerWidget { - final bool floating; - const PlayerQueue({ - this.floating = true, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final controller = useAutoScrollController(); - final searchText = useState(''); - - final isSearching = useState(false); - - final tracks = playlist.tracks; - final borderRadius = floating - ? const BorderRadius.only( - topLeft: Radius.circular(10), - ) - : const BorderRadius.only( - topLeft: Radius.circular(10), - topRight: Radius.circular(10), - ); - final theme = Theme.of(context); - final mediaQuery = MediaQuery.of(context); - final headlineColor = theme.textTheme.headlineSmall?.color; - - final filteredTracks = useMemoized( - () { - if (searchText.value.isEmpty) { - return tracks; - } - return tracks - .map((e) => ( - weightedRatio( - '${e.name!} - ${TypeConversionUtils.artists_X_String(e.artists!)}', - searchText.value, - ), - e - )) - .sorted((a, b) => b.$1.compareTo(a.$1)) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList(); - }, - [tracks, searchText.value], - ); - - useEffect(() { - if (playlist.active == null) return null; - - if (playlist.active! < 0) return; - controller.scrollToIndex( - playlist.active!, - preferPosition: AutoScrollPosition.middle, - ); - return null; - }, []); - - if (tracks.isEmpty) { - return const NotFound(vertical: true); - } - - return ClipRRect( - borderRadius: borderRadius, - clipBehavior: Clip.hardEdge, - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 15, - sigmaY: 15, - ), - child: Container( - padding: const EdgeInsets.only( - top: 5.0, - ), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceVariant.withOpacity(0.5), - borderRadius: borderRadius, - ), - child: CallbackShortcuts( - bindings: { - LogicalKeySet(LogicalKeyboardKey.escape): () { - if (!isSearching.value) { - Navigator.of(context).pop(); - } - isSearching.value = false; - searchText.value = ''; - } - }, - child: Column( - children: [ - if (!floating) - Container( - height: 5, - width: 100, - margin: const EdgeInsets.only(bottom: 5, top: 2), - decoration: BoxDecoration( - color: headlineColor, - borderRadius: BorderRadius.circular(20), - ), - ), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (mediaQuery.mdAndUp || !isSearching.value) ...[ - const SizedBox(width: 10), - Text( - context.l10n.tracks_in_queue(tracks.length), - style: TextStyle( - color: headlineColor, - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - const Spacer(), - ], - if (mediaQuery.mdAndUp || isSearching.value) - TextField( - onChanged: (value) { - searchText.value = value; - }, - decoration: InputDecoration( - hintText: context.l10n.search, - isDense: true, - prefixIcon: mediaQuery.smAndDown - ? IconButton( - icon: const Icon( - Icons.arrow_back_ios_new_outlined, - ), - onPressed: () { - isSearching.value = false; - searchText.value = ''; - }, - style: IconButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: const Size.square(20), - ), - ) - : const Icon(SpotubeIcons.filter), - constraints: BoxConstraints( - maxHeight: 40, - maxWidth: mediaQuery.smAndDown - ? mediaQuery.size.width - 40 - : 300, - ), - ), - ) - else - IconButton.filledTonal( - icon: const Icon(SpotubeIcons.filter), - onPressed: () { - isSearching.value = !isSearching.value; - }, - ), - if (mediaQuery.mdAndUp || !isSearching.value) ...[ - const SizedBox(width: 10), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: - theme.scaffoldBackgroundColor.withOpacity(0.5), - foregroundColor: theme.textTheme.headlineSmall?.color, - ), - child: Row( - children: [ - const Icon(SpotubeIcons.playlistRemove), - const SizedBox(width: 5), - Text(context.l10n.clear_all), - ], - ), - onPressed: () { - playlistNotifier.stop(); - Navigator.of(context).pop(); - }, - ), - const SizedBox(width: 10), - ], - ], - ), - const SizedBox(height: 10), - if (!isSearching.value && searchText.value.isEmpty) - Flexible( - child: ReorderableListView.builder( - onReorder: (oldIndex, newIndex) { - playlistNotifier.moveTrack(oldIndex, newIndex); - }, - scrollController: controller, - itemCount: tracks.length, - shrinkWrap: true, - buildDefaultDragHandles: false, - onReorderStart: (index) { - HapticFeedback.selectionClick(); - }, - onReorderEnd: (index) { - HapticFeedback.selectionClick(); - }, - itemBuilder: (context, i) { - final track = tracks.elementAt(i); - return AutoScrollTag( - key: ValueKey(i), - controller: controller, - index: i, - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8.0), - child: TrackTile( - index: i, - track: track, - onTap: () async { - if (playlist.activeTrack?.id == track.id) { - return; - } - await playlistNotifier.jumpToTrack(track); - }, - leadingActions: [ - ReorderableDragStartListener( - index: i, - child: const Icon(SpotubeIcons.dragHandle), - ), - ], - ), - ), - ); - }, - ), - ) - else - Flexible( - child: InterScrollbar( - controller: controller, - child: ListView.builder( - controller: controller, - itemCount: filteredTracks.length, - itemBuilder: (context, i) { - final track = filteredTracks.elementAt(i); - return Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8.0), - child: TrackTile( - index: i, - track: track, - onTap: () async { - if (playlist.activeTrack?.id == track.id) { - return; - } - await playlistNotifier.jumpToTrack(track); - }, - ), - ); - }, - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart deleted file mode 100644 index 58b1ca8c..00000000 --- a/lib/components/player/sibling_tracks_sheet.dart +++ /dev/null @@ -1,324 +0,0 @@ -import 'dart:ui'; - -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart' hide Offset; -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/collections/spotube_icons.dart'; - -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/duration.dart'; -import 'package:spotube/hooks/utils/use_debounce.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; -import 'package:spotube/services/sourced_track/models/source_info.dart'; -import 'package:spotube/services/sourced_track/models/video_info.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/services/sourced_track/sources/jiosaavn.dart'; -import 'package:spotube/services/sourced_track/sources/piped.dart'; -import 'package:spotube/services/sourced_track/sources/youtube.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -final sourceInfoToIconMap = { - YoutubeSourceInfo: const Icon(SpotubeIcons.youtube, color: Color(0xFFFF0000)), - JioSaavnSourceInfo: Container( - height: 30, - width: 30, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(90), - image: DecorationImage( - image: Assets.jiosaavn.provider(), - fit: BoxFit.cover, - ), - ), - ), - PipedSourceInfo: const Icon(SpotubeIcons.piped), -}; - -class SiblingTracksSheet extends HookConsumerWidget { - final bool floating; - const SiblingTracksSheet({ - Key? key, - this.floating = true, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final preferences = ref.watch(userPreferencesProvider); - - final isSearching = useState(false); - final searchMode = useState(preferences.searchMode); - - final title = ServiceUtils.getTitle( - playlist.activeTrack?.name ?? "", - artists: - playlist.activeTrack?.artists?.map((e) => e.name!).toList() ?? [], - onlyCleanArtist: true, - ).trim(); - - final defaultSearchTerm = - "$title - ${TypeConversionUtils.artists_X_String(playlist.activeTrack?.artists ?? [])}"; - final searchController = useTextEditingController( - text: defaultSearchTerm, - ); - - final searchTerm = useDebounce( - useValueListenable(searchController).text, - ); - - final controller = useScrollController(); - - final searchRequest = useMemoized(() async { - if (searchTerm.trim().isEmpty) { - return []; - } - if (preferences.audioSource == AudioSource.jiosaavn) { - final resultsJioSaavn = - await jiosaavnClient.search.songs(searchTerm.trim()); - final results = await Future.wait( - resultsJioSaavn.results.mapIndexed((i, song) async { - final siblingType = JioSaavnSourcedTrack.toSiblingType(song); - return siblingType.info; - })); - - final activeSourceInfo = - (playlist.activeTrack! as SourcedTrack).sourceInfo; - - return results - ..removeWhere((element) => element.id == activeSourceInfo.id) - ..insert( - 0, - activeSourceInfo, - ); - } else { - final resultsYt = await youtubeClient.search.search(searchTerm.trim()); - - final searchResults = await Future.wait( - resultsYt - .map(YoutubeVideoInfo.fromVideo) - .mapIndexed((i, video) async { - final siblingType = - await YoutubeSourcedTrack.toSiblingType(i, video); - return siblingType.info; - }), - ); - final activeSourceInfo = - (playlist.activeTrack! as SourcedTrack).sourceInfo; - return searchResults - ..removeWhere((element) => element.id == activeSourceInfo.id) - ..insert( - 0, - activeSourceInfo, - ); - } - }, [ - searchTerm, - searchMode.value, - playlist.activeTrack, - preferences.audioSource, - ]); - - final siblings = useMemoized( - () => playlist.isFetching == false - ? [ - (playlist.activeTrack as SourcedTrack).sourceInfo, - ...(playlist.activeTrack as SourcedTrack).siblings, - ] - : [], - [playlist.isFetching, playlist.activeTrack], - ); - - final borderRadius = floating - ? BorderRadius.circular(10) - : const BorderRadius.only( - topLeft: Radius.circular(10), - topRight: Radius.circular(10), - ); - - useEffect(() { - if (playlist.activeTrack is SourcedTrack && - (playlist.activeTrack as SourcedTrack).siblings.isEmpty) { - playlistNotifier.populateSibling(); - } - return null; - }, [playlist.activeTrack]); - - final itemBuilder = useCallback( - (SourceInfo sourceInfo) { - final icon = sourceInfoToIconMap[sourceInfo.runtimeType]; - return ListTile( - title: Text(sourceInfo.title), - leading: Padding( - padding: const EdgeInsets.all(8.0), - child: UniversalImage( - path: sourceInfo.thumbnail, - height: 60, - width: 60, - ), - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - ), - trailing: Text(sourceInfo.duration.toHumanReadableString()), - subtitle: Row( - children: [ - if (icon != null) icon, - Text(" • ${sourceInfo.artist}"), - ], - ), - enabled: playlist.isFetching != true, - selected: playlist.isFetching != true && - sourceInfo.id == - (playlist.activeTrack as SourcedTrack).sourceInfo.id, - selectedTileColor: theme.popupMenuTheme.color, - onTap: () { - if (playlist.isFetching == false && - sourceInfo.id != - (playlist.activeTrack as SourcedTrack).sourceInfo.id) { - playlistNotifier.swapSibling(sourceInfo); - Navigator.of(context).pop(); - } - }, - ); - }, - [playlist.isFetching, playlist.activeTrack, siblings], - ); - - final mediaQuery = MediaQuery.of(context); - return SafeArea( - child: ClipRRect( - borderRadius: borderRadius, - clipBehavior: Clip.hardEdge, - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 12.0, - sigmaY: 12.0, - ), - child: AnimatedSize( - duration: const Duration(milliseconds: 300), - child: Container( - height: isSearching.value && mediaQuery.smAndDown - ? mediaQuery.size.height - 50 - : mediaQuery.size.height * .6, - decoration: BoxDecoration( - borderRadius: borderRadius, - color: theme.colorScheme.surfaceVariant.withOpacity(.5), - ), - child: Scaffold( - backgroundColor: Colors.transparent, - appBar: AppBar( - centerTitle: true, - title: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: !isSearching.value - ? Text( - context.l10n.alternative_track_sources, - style: theme.textTheme.headlineSmall, - ) - : TextField( - autofocus: true, - controller: searchController, - decoration: InputDecoration( - hintText: context.l10n.search, - hintStyle: theme.textTheme.headlineSmall, - border: InputBorder.none, - ), - style: theme.textTheme.headlineSmall, - ), - ), - automaticallyImplyLeading: false, - backgroundColor: Colors.transparent, - actions: [ - if (!isSearching.value) - IconButton( - icon: const Icon(SpotubeIcons.search, size: 18), - onPressed: () { - isSearching.value = true; - }, - ) - else ...[ - if (preferences.audioSource == AudioSource.piped) - PopupMenuButton( - icon: const Icon(SpotubeIcons.filter, size: 18), - onSelected: (SearchMode mode) { - searchMode.value = mode; - }, - initialValue: searchMode.value, - itemBuilder: (context) => SearchMode.values - .map( - (e) => PopupMenuItem( - value: e, - child: Text(e.label), - ), - ) - .toList(), - ), - IconButton( - icon: const Icon(SpotubeIcons.close, size: 18), - onPressed: () { - isSearching.value = false; - }, - ), - ] - ], - ), - body: Padding( - padding: const EdgeInsets.all(8.0), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - transitionBuilder: (child, animation) => - FadeTransition(opacity: animation, child: child), - child: InterScrollbar( - controller: controller, - child: switch (isSearching.value) { - false => ListView.builder( - controller: controller, - itemCount: siblings.length, - itemBuilder: (context, index) => - itemBuilder(siblings[index]), - ), - true => FutureBuilder( - future: searchRequest, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Center( - child: Text(snapshot.error.toString()), - ); - } else if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator()); - } - - return InterScrollbar( - controller: controller, - child: ListView.builder( - controller: controller, - itemCount: snapshot.data!.length, - itemBuilder: (context, index) => - itemBuilder(snapshot.data![index]), - ), - ); - }, - ), - }, - ), - ), - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart deleted file mode 100644 index f429a0ab..00000000 --- a/lib/components/playlist/playlist_card.dart +++ /dev/null @@ -1,135 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/playbutton_card.dart'; -import 'package:spotube/extensions/infinite_query.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class PlaylistCard extends HookConsumerWidget { - final PlaylistSimple playlist; - const PlaylistCard( - this.playlist, { - Key? key, - }) : super(key: key); - @override - Widget build(BuildContext context, ref) { - final playlistQueue = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final playing = - useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; - final queryClient = QueryClient.of(context); - final tracks = useState?>(null); - bool isPlaylistPlaying = useMemoized( - () => playlistQueue.containsCollection(playlist.id!), - [playlistQueue, playlist.id], - ); - - final updating = useState(false); - final spotify = ref.watch(spotifyProvider); - final me = useQueries.user.me(ref); - - Future> fetchAllTracks() async { - if (playlist.id == 'user-liked-tracks') { - return await queryClient.fetchQuery( - "user-liked-tracks", - () => useQueries.playlist.likedTracks(spotify), - ) ?? - []; - } - - final query = queryClient.createInfiniteQuery, dynamic, int>( - "playlist-tracks/${playlist.id}", - (page) => useQueries.playlist.tracksOf(page, spotify, playlist.id!), - initialPage: 0, - nextPage: useQueries.playlist.tracksOfQueryNextPage, - ); - - return await query.fetchAllTracks( - getAllTracks: () async { - final res = - await spotify.playlists.getTracksByPlaylistId(playlist.id!).all(); - return res.toList(); - }, - ); - } - - return PlaybuttonCard( - margin: const EdgeInsets.symmetric(horizontal: 10), - title: playlist.name!, - description: playlist.description, - imageUrl: TypeConversionUtils.image_X_UrlString( - playlist.images, - placeholder: ImagePlaceholder.collection, - ), - isPlaying: isPlaylistPlaying, - isLoading: - (isPlaylistPlaying && playlistQueue.isFetching) || updating.value, - isOwner: playlist.owner?.id == me.data?.id && me.data?.id != null, - onTap: () { - ServiceUtils.push( - context, - "/playlist/${playlist.id}", - extra: playlist, - ); - }, - onPlaybuttonPressed: () async { - try { - updating.value = true; - if (isPlaylistPlaying && playing) { - return audioPlayer.pause(); - } else if (isPlaylistPlaying && !playing) { - return audioPlayer.resume(); - } - - List fetchedTracks = await fetchAllTracks(); - - if (fetchedTracks.isEmpty) return; - - await playlistNotifier.load(fetchedTracks, autoPlay: true); - playlistNotifier.addCollection(playlist.id!); - tracks.value = fetchedTracks; - } finally { - if (context.mounted) { - updating.value = false; - } - } - }, - onAddToQueuePressed: () async { - updating.value = true; - try { - if (isPlaylistPlaying) return; - - final fetchedTracks = await fetchAllTracks(); - - if (fetchedTracks.isEmpty) return; - - playlistNotifier.addTracks(fetchedTracks); - playlistNotifier.addCollection(playlist.id!); - tracks.value = fetchedTracks; - if (context.mounted) { - final snackbar = SnackBar( - content: Text("Added ${tracks.value?.length} tracks to queue"), - action: SnackBarAction( - label: "Undo", - onPressed: () { - playlistNotifier - .removeTracks(fetchedTracks.map((e) => e.id!)); - }, - ), - ); - ScaffoldMessenger.maybeOf(context)?.showSnackBar(snackbar); - } - } finally { - updating.value = false; - } - }, - ); - } -} diff --git a/lib/components/playlist/playlist_create_dialog.dart b/lib/components/playlist/playlist_create_dialog.dart deleted file mode 100644 index 2e11a209..00000000 --- a/lib/components/playlist/playlist_create_dialog.dart +++ /dev/null @@ -1,311 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:form_validator/form_validator.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:spotify/spotify.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/services/mutations/playlist.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class PlaylistCreateDialog extends HookConsumerWidget { - /// Track ids to add to the playlist - final List trackIds; - final String? playlistId; - PlaylistCreateDialog({ - Key? key, - this.trackIds = const [], - this.playlistId, - }) : super(key: key); - - final formKey = GlobalKey(); - - @override - Widget build(BuildContext context, ref) { - return ScaffoldMessenger( - child: Scaffold( - backgroundColor: Colors.transparent, - body: HookBuilder(builder: (context) { - final userPlaylists = useQueries.playlist.ofMine(ref); - final updatingPlaylist = useMemoized( - () => userPlaylists.pages - .expand((p) => p.items ?? []) - .firstWhereOrNull((playlist) => playlist.id == playlistId), - [ - userPlaylists.pages, - playlistId, - ], - ); - - final playlistName = useTextEditingController( - text: updatingPlaylist?.name, - ); - final description = useTextEditingController( - text: updatingPlaylist?.description, - ); - final public = useState( - updatingPlaylist?.public ?? false, - ); - final collaborative = useState( - updatingPlaylist?.collaborative ?? false, - ); - final image = useState(null); - - final isUpdatingPlaylist = playlistId != null; - - final l10n = context.l10n; - final theme = Theme.of(context); - final scaffold = ScaffoldMessenger.of(context); - - final onError = useCallback((error) { - if (error is SpotifyError || error is SpotifyException) { - scaffold.showSnackBar( - SnackBar( - content: Text( - l10n.error(error.message ?? "Epic failure!"), - style: theme.textTheme.bodyMedium!.copyWith( - color: theme.colorScheme.onError, - ), - ), - backgroundColor: theme.colorScheme.error, - ), - ); - } - }, [scaffold, l10n, theme]); - - final playlistCreateMutation = useMutations.playlist.create( - ref, - trackIds: trackIds, - onData: (value) { - Navigator.pop(context); - }, - onError: onError, - ); - - final playlistUpdateMutation = useMutations.playlist.update( - ref, - playlistId: playlistId, - onData: (value) { - Navigator.pop(context); - }, - onError: onError, - ); - - Future onCreate() async { - if (!formKey.currentState!.validate()) return; - - final PlaylistCRUDVariables payload = ( - playlistName: playlistName.text, - collaborative: collaborative.value, - public: public.value, - description: description.text, - base64Image: image.value?.path != null - ? await image.value! - .readAsBytes() - .then((bytes) => base64Encode(bytes)) - : null, - ); - - if (isUpdatingPlaylist) { - await playlistUpdateMutation.mutate(payload); - } else { - await playlistCreateMutation.mutate(payload); - } - } - - return AlertDialog( - title: Text( - isUpdatingPlaylist - ? context.l10n.update_playlist - : context.l10n.create_a_playlist, - ), - actions: [ - OutlinedButton( - child: Text(context.l10n.cancel), - onPressed: () { - Navigator.pop(context); - }, - ), - FilledButton( - onPressed: onCreate, - child: Text( - isUpdatingPlaylist - ? context.l10n.update - : context.l10n.create, - ), - ), - ], - insetPadding: const EdgeInsets.all(8), - content: Container( - width: MediaQuery.of(context).size.width, - constraints: const BoxConstraints(maxWidth: 500), - child: Form( - key: formKey, - child: ListView( - shrinkWrap: true, - children: [ - FormField( - initialValue: image.value, - onSaved: (newValue) { - image.value = newValue; - }, - validator: (value) { - if (value == null) return null; - final file = File(value.path); - - if (file.lengthSync() > 256000) { - return "Image size should be less than 256kb"; - } - return null; - }, - builder: (field) { - return Column( - children: [ - UniversalImage( - path: field.value?.path ?? - TypeConversionUtils.image_X_UrlString( - updatingPlaylist?.images, - placeholder: ImagePlaceholder.collection, - ), - height: 200, - ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FilledButton.icon( - icon: const Icon(SpotubeIcons.edit), - label: 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.filled( - icon: const Icon(SpotubeIcons.trash), - style: IconButton.styleFrom( - backgroundColor: - theme.colorScheme.errorContainer, - foregroundColor: theme.colorScheme.error, - ), - onPressed: field.value == null - ? null - : () { - field.didChange(null); - field.validate(); - field.save(); - }, - ), - ], - ), - if (field.hasError) - Text( - field.errorText ?? "", - style: theme.textTheme.bodyMedium!.copyWith( - color: theme.colorScheme.error, - ), - ) - ], - ); - }), - const SizedBox(height: 10), - TextFormField( - controller: playlistName, - decoration: InputDecoration( - hintText: context.l10n.name_of_playlist, - labelText: context.l10n.name_of_playlist, - ), - validator: ValidationBuilder().required().build(), - ), - const SizedBox(height: 10), - TextFormField( - controller: description, - decoration: InputDecoration( - hintText: context.l10n.description, - ), - keyboardType: TextInputType.multiline, - validator: ValidationBuilder().required().build(), - maxLines: 5, - ), - const SizedBox(height: 10), - CheckboxListTile( - title: Text(context.l10n.public), - value: public.value, - onChanged: (val) => public.value = val ?? false, - ), - const SizedBox(height: 10), - CheckboxListTile( - title: Text(context.l10n.collaborative), - value: collaborative.value, - onChanged: (val) => collaborative.value = val ?? false, - ), - ], - ), - ), - ), - ); - }), - ), - ); - } -} - -class PlaylistCreateDialogButton extends HookConsumerWidget { - const PlaylistCreateDialogButton({Key? key}) : super(key: key); - - showPlaylistDialog(BuildContext context, SpotifyApi spotify) { - showDialog( - context: context, - builder: (context) => PlaylistCreateDialog(), - ); - } - - @override - Widget build(BuildContext context, ref) { - final mediaQuery = MediaQuery.of(context); - final spotify = ref.watch(spotifyProvider); - - if (mediaQuery.smAndDown) { - return ElevatedButton( - style: FilledButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.primary, - ), - child: const Icon(SpotubeIcons.addFilled), - onPressed: () => showPlaylistDialog(context, spotify), - ); - } - - return FilledButton.tonalIcon( - style: FilledButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.primary, - ), - icon: const Icon(SpotubeIcons.addFilled), - label: Text(context.l10n.create_playlist), - onPressed: () => showPlaylistDialog(context, spotify), - ); - } -} diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart deleted file mode 100644 index 617e760b..00000000 --- a/lib/components/root/bottom_player.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/player/player_actions.dart'; -import 'package:spotube/components/player/player_overlay.dart'; -import 'package:spotube/components/player/player_track_details.dart'; -import 'package:spotube/components/player/player_controls.dart'; -import 'package:spotube/components/player/volume_slider.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/utils/use_brightness_value.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:flutter/material.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class BottomPlayer extends HookConsumerWidget { - BottomPlayer({Key? key}) : super(key: key); - - final logger = getLogger(BottomPlayer); - @override - Widget build(BuildContext context, ref) { - final auth = ref.watch(AuthenticationNotifier.provider); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final layoutMode = - ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); - - final mediaQuery = MediaQuery.of(context); - - String albumArt = useMemoized( - () => playlist.activeTrack?.album?.images?.isNotEmpty == true - ? TypeConversionUtils.image_X_UrlString( - playlist.activeTrack?.album?.images, - index: (playlist.activeTrack?.album?.images?.length ?? 1) - 1, - placeholder: ImagePlaceholder.albumArt, - ) - : Assets.albumPlaceholder.path, - [playlist.activeTrack?.album?.images], - ); - - final theme = Theme.of(context); - final bg = theme.colorScheme.surfaceVariant; - - final bgColor = useBrightnessValue( - Color.lerp(bg, Colors.white, 0.7), - Color.lerp(bg, Colors.black, 0.45)!, - ); - - // 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 ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: DecoratedBox( - decoration: BoxDecoration(color: bgColor?.withOpacity(0.8)), - child: Material( - type: MaterialType.transparency, - textStyle: theme.textTheme.bodyMedium!, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded(child: PlayerTrackDetails(albumArt: albumArt)), - // controls - Flexible( - flex: 3, - child: Padding( - padding: const EdgeInsets.only(top: 5), - child: PlayerControls(), - ), - ), - // add to saved tracks - Column( - children: [ - PlayerActions( - extraActions: [ - if (auth != null) - IconButton( - tooltip: context.l10n.mini_player, - icon: const Icon(SpotubeIcons.miniPlayer), - onPressed: () async { - final prevSize = - await DesktopTools.window.getSize(); - await DesktopTools.window.setMinimumSize( - const Size(300, 300), - ); - await DesktopTools.window.setAlwaysOnTop(true); - if (!kIsLinux) { - await DesktopTools.window.setHasShadow(false); - } - await DesktopTools.window - .setAlignment(Alignment.topRight); - await DesktopTools.window - .setSize(const Size(400, 500)); - await Future.delayed( - const Duration(milliseconds: 100), - () async { - GoRouter.of(context).go( - '/mini-player', - extra: prevSize, - ); - }, - ); - }, - ), - ], - ), - Container( - height: 40, - constraints: const BoxConstraints(maxWidth: 250), - child: const VolumeSlider(), - ) - ], - ) - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart deleted file mode 100644 index a55ef947..00000000 --- a/lib/components/root/sidebar.dart +++ /dev/null @@ -1,307 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:flutter/material.dart'; -import 'package:sidebarx/sidebarx.dart'; - -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/collections/side_bar_tiles.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/utils/use_brightness_value.dart'; -import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/authentication_provider.dart'; - -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class Sidebar extends HookConsumerWidget { - final int? selectedIndex; - final void Function(int) onSelectedIndexChanged; - final Widget child; - - const Sidebar({ - required this.selectedIndex, - required this.onSelectedIndexChanged, - required this.child, - Key? key, - }) : super(key: key); - - static Widget brandLogo() { - return Container( - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(50), - ), - child: Assets.spotubeLogoPng.image(height: 50), - ); - } - - static void goToSettings(BuildContext context) { - GoRouter.of(context).go("/settings"); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final mediaQuery = MediaQuery.of(context); - - final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; - - final layoutMode = - ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); - - final controller = useSidebarXController( - selectedIndex: selectedIndex ?? 0, - extended: mediaQuery.lgAndUp, - ); - - final theme = Theme.of(context); - final bg = theme.colorScheme.surfaceVariant; - - final bgColor = useBrightnessValue( - Color.lerp(bg, Colors.white, 0.7), - Color.lerp(bg, Colors.black, 0.45)!, - ); - - final sidebarTileList = useMemoized( - () => getSidebarTileList(context.l10n), - [context.l10n], - ); - - useEffect(() { - if (controller.selectedIndex != selectedIndex && selectedIndex != null) { - controller.selectIndex(selectedIndex!); - } - return null; - }, [selectedIndex]); - - useEffect(() { - void listener() { - onSelectedIndexChanged(controller.selectedIndex); - } - - controller.addListener(listener); - return () { - controller.removeListener(listener); - }; - }, [controller]); - - useEffect(() { - if (!context.mounted) return; - if (mediaQuery.lgAndUp && !controller.extended) { - controller.setExtended(true); - } else if (mediaQuery.mdAndDown && controller.extended) { - controller.setExtended(false); - } - return null; - }, [mediaQuery, controller]); - - if (layoutMode == LayoutMode.compact || - (mediaQuery.smAndDown && layoutMode == LayoutMode.adaptive)) { - return Scaffold(body: child); - } - - return Row( - children: [ - SafeArea( - child: SidebarX( - controller: controller, - items: sidebarTileList.mapIndexed( - (index, e) { - return SidebarXItem( - iconWidget: Badge( - backgroundColor: theme.colorScheme.primary, - isLabelVisible: e.title == "Library" && downloadCount > 0, - label: Text( - downloadCount.toString(), - style: const TextStyle( - color: Colors.white, - fontSize: 10, - ), - ), - child: Icon( - e.icon, - color: selectedIndex == index - ? theme.colorScheme.primary - : null, - ), - ), - label: e.title, - ); - }, - ).toList(), - headerBuilder: (_, __) => const SidebarHeader(), - footerBuilder: (_, __) => const Padding( - padding: EdgeInsets.only(bottom: 5), - child: SidebarFooter(), - ), - showToggleButton: false, - theme: SidebarXTheme( - width: 50, - margin: EdgeInsets.only(bottom: 10, top: kIsMacOS ? 35 : 5), - selectedItemDecoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: theme.colorScheme.primary.withOpacity(0.1), - ), - selectedIconTheme: IconThemeData( - color: theme.colorScheme.primary, - ), - ), - extendedTheme: SidebarXTheme( - width: 250, - margin: EdgeInsets.only( - bottom: 10, - left: 0, - top: kIsMacOS ? 0 : 5, - ), - padding: const EdgeInsets.symmetric(horizontal: 6), - decoration: BoxDecoration( - color: bgColor?.withOpacity(0.8), - borderRadius: const BorderRadius.only( - topRight: Radius.circular(10), - bottomRight: Radius.circular(10), - ), - ), - selectedItemDecoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: theme.colorScheme.primary.withOpacity(0.1), - ), - selectedIconTheme: IconThemeData( - color: theme.colorScheme.primary, - ), - selectedTextStyle: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w600, - ), - itemTextPadding: const EdgeInsets.only(left: 10), - selectedItemTextPadding: const EdgeInsets.only(left: 10), - hoverTextStyle: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.primary, - ), - ), - ), - ), - Expanded(child: child) - ], - ); - } -} - -class SidebarHeader extends HookWidget { - const SidebarHeader({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final mediaQuery = MediaQuery.of(context); - final theme = Theme.of(context); - - if (mediaQuery.mdAndDown) { - return Container( - height: 40, - width: 40, - margin: const EdgeInsets.only(bottom: 5), - child: Sidebar.brandLogo(), - ); - } - - return Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: [ - if (kIsMacOS) const SizedBox(height: 25), - Row( - children: [ - Sidebar.brandLogo(), - const SizedBox(width: 10), - Text( - "Spotube", - style: theme.textTheme.titleLarge, - ), - ], - ), - ], - ), - ); - } -} - -class SidebarFooter extends HookConsumerWidget { - const SidebarFooter({ - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final mediaQuery = MediaQuery.of(context); - final me = useQueries.user.me(ref); - final data = me.data; - - final avatarImg = TypeConversionUtils.image_X_UrlString( - data?.images, - index: (data?.images?.length ?? 1) - 1, - placeholder: ImagePlaceholder.artist, - ); - - final auth = ref.watch(AuthenticationNotifier.provider); - - if (mediaQuery.mdAndDown) { - return IconButton( - icon: const Icon(SpotubeIcons.settings), - onPressed: () => Sidebar.goToSettings(context), - ); - } - - return Container( - padding: const EdgeInsets.only(left: 12), - width: 250, - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (auth != null && data == null) - const CircularProgressIndicator() - else if (data != null) - Flexible( - child: Row( - children: [ - CircleAvatar( - backgroundImage: UniversalImage.imageProvider(avatarImg), - onBackgroundImageError: (exception, stackTrace) => - Assets.userPlaceholder.image( - height: 16, - width: 16, - ), - ), - const SizedBox(width: 10), - Flexible( - child: Text( - data.displayName ?? context.l10n.guest, - maxLines: 1, - softWrap: false, - overflow: TextOverflow.fade, - style: theme.textTheme.bodyMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(SpotubeIcons.settings), - onPressed: () { - Sidebar.goToSettings(context); - }, - ), - ], - ), - ); - } -} diff --git a/lib/components/root/spotube_navigation_bar.dart b/lib/components/root/spotube_navigation_bar.dart deleted file mode 100644 index 0853c60c..00000000 --- a/lib/components/root/spotube_navigation_bar.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'dart:ui'; - -import 'package:curved_navigation_bar/curved_navigation_bar.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'package:spotube/collections/side_bar_tiles.dart'; -import 'package:spotube/components/root/sidebar.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/utils/use_brightness_value.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; - -final navigationPanelHeight = StateProvider((ref) => 50); - -class SpotubeNavigationBar extends HookConsumerWidget { - final int? selectedIndex; - final void Function(int) onSelectedIndexChanged; - - const SpotubeNavigationBar({ - required this.selectedIndex, - required this.onSelectedIndexChanged, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; - final mediaQuery = MediaQuery.of(context); - final layoutMode = - ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); - - final insideSelectedIndex = useState(selectedIndex ?? 0); - - final buttonColor = useBrightnessValue( - theme.colorScheme.inversePrimary, - theme.colorScheme.primary.withOpacity(0.2), - ); - - final navbarTileList = - useMemoized(() => getNavbarTileList(context.l10n), [context.l10n]); - - final panelHeight = ref.watch(navigationPanelHeight); - - useEffect(() { - if (selectedIndex != null) { - insideSelectedIndex.value = selectedIndex!; - } - return null; - }, [selectedIndex]); - - if (layoutMode == LayoutMode.extended || - (mediaQuery.mdAndUp && layoutMode == LayoutMode.adaptive) || - panelHeight < 10) { - return const SizedBox(); - } - - return AnimatedContainer( - duration: const Duration(milliseconds: 100), - height: panelHeight, - child: ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: CurvedNavigationBar( - backgroundColor: - theme.colorScheme.secondaryContainer.withOpacity(0.72), - buttonBackgroundColor: buttonColor, - color: theme.colorScheme.background, - height: panelHeight, - animationDuration: const Duration(milliseconds: 350), - items: navbarTileList.map( - (e) { - /// Using this [Builder] as an workaround for the first item's - /// icon color not updating unless navigating to another page - return Builder(builder: (context) { - return MouseRegion( - cursor: SystemMouseCursors.click, - child: Badge( - isLabelVisible: e.id == "library" && downloadCount > 0, - label: Text(downloadCount.toString()), - child: Icon( - e.icon, - color: Theme.of(context).colorScheme.primary, - ), - ), - ); - }); - }, - ).toList(), - index: insideSelectedIndex.value, - onTap: (i) { - insideSelectedIndex.value = i; - if (navbarTileList[i].id == "settings") { - Sidebar.goToSettings(context); - return; - } - onSelectedIndexChanged(i); - }, - ), - ), - ), - ); - } -} diff --git a/lib/components/settings/color_scheme_picker_dialog.dart b/lib/components/settings/color_scheme_picker_dialog.dart deleted file mode 100644 index e0c3d618..00000000 --- a/lib/components/settings/color_scheme_picker_dialog.dart +++ /dev/null @@ -1,229 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:system_theme/system_theme.dart'; - -class SpotubeColor extends Color { - final String name; - - const SpotubeColor(int color, {required this.name}) : super(color); - - const SpotubeColor.from(int value, {required this.name}) : super(value); - - factory SpotubeColor.fromString(String string) { - final slices = string.split(":"); - return SpotubeColor(int.parse(slices.last), name: slices.first); - } - - @override - String toString() { - return "$name:$value"; - } -} - -final Set colorsMap = { - SpotubeColor(SystemTheme.accentColor.accent.value, name: "System"), - SpotubeColor(Colors.red.value, name: "Red"), - SpotubeColor(Colors.pink.value, name: "Pink"), - SpotubeColor(Colors.purple.value, name: "Purple"), - SpotubeColor(Colors.deepPurple.value, name: "DeepPurple"), - SpotubeColor(Colors.indigo.value, name: "Indigo"), - SpotubeColor(Colors.blue.value, name: "Blue"), - SpotubeColor(Colors.lightBlue.value, name: "LightBlue"), - SpotubeColor(Colors.cyan.value, name: "Cyan"), - SpotubeColor(Colors.teal.value, name: "Teal"), - SpotubeColor(Colors.green.value, name: "Green"), - SpotubeColor(Colors.lightGreen.value, name: "LightGreen"), - SpotubeColor(Colors.yellow.value, name: "Yellow"), - SpotubeColor(Colors.amber.value, name: "Amber"), - SpotubeColor(Colors.orange.value, name: "Orange"), - SpotubeColor(Colors.deepOrange.value, name: "DeepOrange"), - SpotubeColor(Colors.brown.value, name: "Brown"), -}; - -class ColorSchemePickerDialog extends HookConsumerWidget { - const ColorSchemePickerDialog({Key? key}) : super(key: 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.firstWhere( - (element) { - return scheme.name == element.name; - }, - ).name); - - onOk() { - preferencesNotifier.setAccentColorScheme( - colorsMap.firstWhere( - (element) { - return element.name == active.value; - }, - ), - ); - Navigator.pop(context); - } - - return AlertDialog( - title: const Text("Pick color scheme"), - actions: [ - OutlinedButton( - child: const Text("Cancel"), - onPressed: () { - Navigator.pop(context); - }, - ), - FilledButton( - onPressed: onOk, - child: const Text("Save"), - ), - ], - content: SizedBox( - height: 200, - width: 400, - child: ListView.separated( - separatorBuilder: (context, index) { - return const SizedBox(height: 10); - }, - itemCount: colorsMap.length, - itemBuilder: (context, index) { - final color = colorsMap.elementAt(index); - return ColorTile( - color: color, - isActive: active.value == color.name, - onPressed: () { - active.value = color.name; - }, - tooltip: color.name, - ); - }, - ), - ), - ); - } -} - -class ColorTile extends StatelessWidget { - final Color color; - final bool isActive; - final void Function()? onPressed; - final String? tooltip; - final bool isCompact; - const ColorTile({ - required this.color, - this.isActive = false, - this.onPressed, - this.tooltip = "", - this.isCompact = false, - Key? key, - }) : super(key: key); - - factory ColorTile.compact({ - required Color color, - bool isActive = false, - void Function()? onPressed, - String? tooltip = "", - Key? key, - }) { - return ColorTile( - color: color, - isActive: isActive, - onPressed: onPressed, - tooltip: tooltip, - isCompact: true, - key: key, - ); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - final lead = Container( - height: 40, - width: 40, - decoration: BoxDecoration( - border: isActive - ? Border.fromBorderSide( - BorderSide( - color: Color.lerp( - theme.colorScheme.primary, - theme.colorScheme.onPrimary, - 0.5, - )!, - width: 4, - ), - ) - : null, - borderRadius: BorderRadius.circular(15), - color: color, - ), - ); - - if (isCompact) { - return GestureDetector( - onTap: onPressed, - child: lead, - ); - } - - final colorScheme = ColorScheme.fromSeed(seedColor: color); - - final palette = [ - colorScheme.primary, - colorScheme.inversePrimary, - colorScheme.primaryContainer, - colorScheme.secondary, - colorScheme.secondaryContainer, - colorScheme.background, - colorScheme.surface, - colorScheme.surfaceVariant, - colorScheme.onPrimary, - colorScheme.onSurface, - ]; - - return GestureDetector( - onTap: onPressed, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - lead, - const SizedBox(width: 10), - Text( - tooltip!, - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - const SizedBox(height: 10), - Wrap( - alignment: WrapAlignment.start, - spacing: 10, - runSpacing: 10, - children: [ - ...palette.map( - (e) => Container( - height: 20, - width: 20, - decoration: BoxDecoration( - color: e, - borderRadius: BorderRadius.circular(5), - ), - ), - ), - ], - ), - ], - ), - ); - } -} diff --git a/lib/components/settings/section_card_with_heading.dart b/lib/components/settings/section_card_with_heading.dart deleted file mode 100644 index 87060579..00000000 --- a/lib/components/settings/section_card_with_heading.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flutter/material.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 Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Text( - heading, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Card( - clipBehavior: Clip.antiAliasWithSaveLayer, - child: Column(mainAxisSize: MainAxisSize.min, children: children), - ), - ), - ], - ); - } -} diff --git a/lib/components/shared/adaptive/adaptive_list_tile.dart b/lib/components/shared/adaptive/adaptive_list_tile.dart deleted file mode 100644 index 33df44c1..00000000 --- a/lib/components/shared/adaptive/adaptive_list_tile.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.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 ListTile( - title: title, - subtitle: subtitle, - trailing: breakOn ?? mediaQuery.smAndDown - ? null - : trailing?.call(context, null), - leading: leading, - onTap: breakOn ?? mediaQuery.smAndDown - ? () { - onTap?.call(); - showDialog( - context: context, - barrierDismissible: true, - builder: (context) { - return StatefulBuilder(builder: (context, update) { - return AlertDialog( - title: title != null - ? Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (leading != null) ...[ - leading!, - const SizedBox(width: 5) - ], - Flexible(child: title!), - ], - ) - : Container(), - content: trailing?.call(context, update), - ); - }); - }, - ); - } - : null, - ); - } -} diff --git a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart b/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart deleted file mode 100644 index 21f56a22..00000000 --- a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart +++ /dev/null @@ -1,233 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/extensions/constrains.dart'; - -_emptyCB() {} - -class PopSheetEntry extends ListTile { - final T? value; - const PopSheetEntry({ - this.value, - super.key, - super.leading, - super.title, - super.subtitle, - super.trailing, - super.isThreeLine = false, - super.dense, - super.visualDensity, - super.shape, - super.style, - super.selectedColor, - super.iconColor, - super.textColor, - super.titleTextStyle, - super.subtitleTextStyle, - super.leadingAndTrailingTextStyle, - super.contentPadding, - super.enabled = true, - super.onTap = _emptyCB, - super.onLongPress, - super.onFocusChange, - super.mouseCursor, - super.selected = false, - super.focusColor, - super.hoverColor, - super.splashColor, - super.focusNode, - super.autofocus = false, - super.tileColor, - super.selectedTileColor, - super.enableFeedback, - super.horizontalTitleGap, - super.minVerticalPadding, - super.minLeadingWidth, - super.titleAlignment, - }); -} - -/// An adaptive widget that shows a [PopupMenuButton] when screen size is above -/// or equal to 640px -/// In smaller screen, a [IconButton] with a [showModalBottomSheet] is shown -class AdaptivePopSheetList extends StatelessWidget { - final List> children; - final Widget? icon; - final Widget? child; - final bool useRootNavigator; - - final List? headings; - final String? tooltip; - final ValueChanged? onSelected; - - final BorderRadius borderRadius; - final Offset offset; - - const AdaptivePopSheetList({ - super.key, - required this.children, - this.icon, - this.child, - this.useRootNavigator = true, - this.headings, - this.onSelected, - this.borderRadius = const BorderRadius.all(Radius.circular(999)), - this.tooltip, - this.offset = Offset.zero, - }) : assert( - !(icon != null && child != null), - 'Either icon or child must be provided', - ); - - Future showPopupMenu(BuildContext context, RelativeRect position) { - final mediaQuery = MediaQuery.of(context); - - return showMenu( - context: context, - useRootNavigator: useRootNavigator, - constraints: BoxConstraints( - maxHeight: mediaQuery.size.height * 0.6, - ), - position: position, - items: children - .map( - (item) => PopupMenuItem( - padding: EdgeInsets.zero, - enabled: false, - child: _AdaptivePopSheetListItem( - item: item, - onSelected: onSelected, - ), - ), - ) - .toList(), - ); - } - - @override - Widget build(BuildContext context) { - final mediaQuery = MediaQuery.of(context); - final theme = Theme.of(context); - - if (mediaQuery.mdAndUp) { - return PopupMenuButton( - icon: icon, - tooltip: tooltip, - offset: offset, - child: child == null ? null : IgnorePointer(child: child), - itemBuilder: (context) => children - .map( - (item) => PopupMenuItem( - padding: EdgeInsets.zero, - enabled: false, - child: _AdaptivePopSheetListItem( - item: item, - onSelected: onSelected, - ), - ), - ) - .toList(), - ); - } - - void showSheet() { - showModalBottomSheet( - context: context, - useRootNavigator: useRootNavigator, - isScrollControlled: true, - showDragHandle: true, - constraints: BoxConstraints( - maxHeight: mediaQuery.size.height * 0.6, - ), - builder: (context) { - return Padding( - padding: const EdgeInsets.all(8.0).copyWith(top: 0), - child: DefaultTextStyle( - style: theme.textTheme.titleMedium!, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (headings != null) ...[ - ...headings!, - const SizedBox(height: 8), - Divider( - color: theme.colorScheme.primary, - thickness: 0.3, - endIndent: 16, - indent: 16, - ), - ], - ...children.map( - (item) => _AdaptivePopSheetListItem( - item: item, - onSelected: onSelected, - ), - ) - ], - ), - ), - ), - ); - }, - ); - } - - if (child != null) { - return Tooltip( - message: tooltip ?? '', - child: InkWell( - onTap: showSheet, - borderRadius: borderRadius, - child: IgnorePointer(child: child), - ), - ); - } - - return IconButton( - icon: icon ?? const Icon(SpotubeIcons.moreVertical), - tooltip: tooltip, - style: theme.iconButtonTheme.style?.copyWith( - shape: MaterialStatePropertyAll( - RoundedRectangleBorder( - borderRadius: borderRadius, - ), - ), - ), - onPressed: showSheet, - ); - } -} - -class _AdaptivePopSheetListItem extends StatelessWidget { - final PopSheetEntry item; - final ValueChanged? onSelected; - const _AdaptivePopSheetListItem({ - super.key, - required this.item, - this.onSelected, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return InkWell( - borderRadius: (theme.listTileTheme.shape as RoundedRectangleBorder?) - ?.borderRadius as BorderRadius? ?? - const BorderRadius.all(Radius.circular(10)), - onTap: !item.enabled - ? null - : () { - item.onTap?.call(); - if (item.value != null) { - Navigator.pop(context); - onSelected?.call(item.value as T); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: IgnorePointer(child: item), - ), - ); - } -} diff --git a/lib/components/shared/adaptive/adaptive_popup_menu_button.dart b/lib/components/shared/adaptive/adaptive_popup_menu_button.dart deleted file mode 100644 index 45f22825..00000000 --- a/lib/components/shared/adaptive/adaptive_popup_menu_button.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -import 'package:popover/popover.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/extensions/constrains.dart'; - -class Action extends StatelessWidget { - final Widget text; - final Widget icon; - final void Function() onPressed; - final bool isExpanded; - final Color? backgroundColor; - const Action({ - Key? key, - required this.icon, - required this.text, - required this.onPressed, - this.isExpanded = true, - this.backgroundColor, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - if (isExpanded != true) { - return IconButton( - icon: icon, - onPressed: onPressed, - style: IconButton.styleFrom( - backgroundColor: backgroundColor, - ), - tooltip: text is Text - ? (text as Text).data - : text.toStringShallow().split(",").last.replaceAll( - "\"", - "", - ), - ); - } - - return ListTile( - tileColor: backgroundColor, - onTap: onPressed, - leading: icon, - title: text, - ); - } -} - -class AdaptiveActions extends HookWidget { - final List actions; - final bool? breakOn; - const AdaptiveActions({ - required this.actions, - this.breakOn, - super.key, - }); - - @override - Widget build(BuildContext context) { - final mediaQuery = MediaQuery.of(context); - - if (breakOn ?? mediaQuery.lgAndUp) { - return IconButton( - icon: const Icon(SpotubeIcons.moreHorizontal), - onPressed: () { - showPopover( - context: context, - direction: PopoverDirection.left, - bodyBuilder: (context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: actions - .map( - (action) => SizedBox( - width: 200, - child: Row( - children: [ - Expanded(child: action), - ], - ), - ), - ) - .toList(), - ); - }, - backgroundColor: Theme.of(context).cardColor, - ); - }, - ); - } - - return Row( - children: actions.map((action) { - return Action( - icon: action.icon, - onPressed: action.onPressed, - text: action.text, - backgroundColor: action.backgroundColor, - isExpanded: false, - ); - }).toList(), - ); - } -} diff --git a/lib/components/shared/adaptive/adaptive_select_tile.dart b/lib/components/shared/adaptive/adaptive_select_tile.dart deleted file mode 100644 index 58666e46..00000000 --- a/lib/components/shared/adaptive/adaptive_select_tile.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:spotube/extensions/constrains.dart'; - -class AdaptiveSelectTile extends HookWidget { - final Widget title; - final Widget? subtitle; - final Widget? secondary; - 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; - - const AdaptiveSelectTile({ - required this.title, - required this.value, - required this.onChanged, - required this.options, - this.controlAffinity = ListTileControlAffinity.trailing, - this.subtitle, - this.secondary, - this.breakLayout, - this.showValueWhenUnfolded = true, - super.key, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final mediaQuery = MediaQuery.of(context); - final rawControl = DropdownButton( - items: options, - value: value, - onChanged: onChanged, - menuMaxHeight: mediaQuery.size.height * 0.6, - ); - final controlPlaceholder = useMemoized( - () => options - .firstWhere( - (element) => element.value == value, - orElse: () => DropdownMenuItem( - value: null, - child: Container(), - ), - ) - .child, - [value, options]); - - final control = breakLayout ?? mediaQuery.mdAndUp - ? rawControl - : showValueWhenUnfolded - ? Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - border: Border.all( - color: theme.colorScheme.primary, - width: 2, - ), - borderRadius: BorderRadius.circular(10), - ), - child: DefaultTextStyle( - style: TextStyle( - color: theme.colorScheme.primary, - ), - child: controlPlaceholder, - ), - ) - : const SizedBox.shrink(); - - return ListTile( - title: title, - subtitle: subtitle, - leading: controlAffinity != ListTileControlAffinity.leading - ? secondary - : control, - trailing: controlAffinity == ListTileControlAffinity.leading - ? secondary - : control, - onTap: breakLayout ?? mediaQuery.mdAndUp - ? null - : () { - showDialog( - context: context, - builder: (context) { - return SimpleDialog( - title: title, - children: [ - for (final option in options) - RadioListTile( - title: option.child, - value: option.value as T, - groupValue: value, - onChanged: (v) { - Navigator.pop(context); - onChanged?.call(v); - }, - ), - ], - ); - }, - ); - }, - ); - } -} diff --git a/lib/components/shared/animated_gradient.dart b/lib/components/shared/animated_gradient.dart deleted file mode 100644 index b6485f6b..00000000 --- a/lib/components/shared/animated_gradient.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -class AnimateGradient extends HookWidget { - const AnimateGradient({ - Key? key, - required this.primaryColors, - required this.secondaryColors, - this.child, - this.primaryBegin, - this.primaryEnd, - this.secondaryBegin, - this.secondaryEnd, - AnimationController? controller, - this.duration = const Duration(seconds: 4), - this.animateAlignments = true, - this.reverse = true, - }) : assert(primaryColors.length >= 2), - assert(primaryColors.length == secondaryColors.length), - _controller = controller, - super(key: key); - - /// [controller]: pass this to have a fine control over the [Animation] - final AnimationController? _controller; - - /// [duration]: Time to switch between [Gradient]. - /// By default its value is [Duration(seconds:4)] - final Duration duration; - - /// [primaryColors]: These will be the starting colors of the [Animation]. - final List primaryColors; - - /// [secondaryColors]: These Colors are those in which the [primaryColors] will transition into. - final List secondaryColors; - - /// [primaryBegin]: This is begin [Alignment] for [primaryColors]. - /// By default its value is [Alignment.topLeft] - final Alignment? primaryBegin; - - /// [primaryBegin]: This is end [Alignment] for [primaryColors]. - /// By default its value is [Alignment.topRight] - final Alignment? primaryEnd; - - /// [secondaryBegin]: This is begin [Alignment] for [secondaryColors]. - /// By default its value is [Alignment.bottomLeft] - final Alignment? secondaryBegin; - - /// [secondaryEnd]: This is end [Alignment] for [secondaryColors]. - /// By default its value is [Alignment.bottomRight] - final Alignment? secondaryEnd; - - /// [animateAlignments]: set to false if you don't want to animate the alignments. - /// This can provide you way cooler animations - final bool animateAlignments; - - /// [reverse]: set it to false if you don't want to reverse the animation. - /// using that it will go into one direction only - final bool reverse; - - final Widget? child; - - @override - Widget build(BuildContext context) { - // ignore: no_leading_underscores_for_local_identifiers - final __controller = useAnimationController( - duration: duration, - )..repeat(reverse: reverse); - - final controller = _controller ?? __controller; - - final animation = useMemoized( - () => CurvedAnimation( - parent: controller, - curve: Curves.easeInOut, - ), - [controller]); - - final colorTween = useMemoized( - () => primaryColors.map((color) { - return ColorTween( - begin: color, - end: color, - ); - }).toList(), - [primaryColors]); - final colors = useMemoized( - () => colorTween.map((color) { - return color.evaluate(animation)!; - }).toList(), - [colorTween, animation]); - - final begin = useMemoized( - () => AlignmentTween( - begin: primaryBegin ?? Alignment.topLeft, - end: primaryEnd ?? Alignment.topRight, - ), - [primaryBegin, primaryEnd]); - - final end = useMemoized( - () => AlignmentTween( - begin: secondaryBegin ?? Alignment.bottomLeft, - end: secondaryEnd ?? Alignment.bottomRight, - ), - [secondaryBegin, secondaryEnd]); - - return AnimatedBuilder( - animation: animation, - child: useMemoized(() => child, [child]), - builder: (BuildContext context, Widget? child) { - return Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: animateAlignments - ? begin.evaluate(animation) - : (primaryBegin as Alignment), - end: animateAlignments - ? end.evaluate(animation) - : primaryEnd as Alignment, - colors: colors, - ), - ), - child: child, - ); - }, - ); - } -} diff --git a/lib/components/shared/bordered_text.dart b/lib/components/shared/bordered_text.dart deleted file mode 100644 index 627b2a3c..00000000 --- a/lib/components/shared/bordered_text.dart +++ /dev/null @@ -1,88 +0,0 @@ -library bordered_text; - -import 'package:flutter/widgets.dart'; - -/// Adds stroke to text widget -/// We can apply a very thin and subtle stroke to a [Text] -/// ```dart -/// BorderedText( -/// strokeWidth: 1.0, -/// text: Text( -/// 'Bordered Text', -/// style: TextStyle( -/// decoration: TextDecoration.none, -/// decorationStyle: TextDecorationStyle.wavy, -/// decorationColor: Colors.red, -/// ), -/// ), -/// ) -/// ``` -class BorderedText extends StatelessWidget { - const BorderedText({ - super.key, - required this.child, - this.strokeCap = StrokeCap.round, - this.strokeJoin = StrokeJoin.round, - this.strokeWidth = 6.0, - this.strokeColor = const Color.fromRGBO(53, 0, 71, 1), - }); - - /// the stroke cap style - final StrokeCap strokeCap; - - /// the stroke joint style - final StrokeJoin strokeJoin; - - /// the stroke width - final double strokeWidth; - - /// the stroke color - final Color strokeColor; - - /// the [Text] widget to apply stroke on - final Text child; - - @override - Widget build(BuildContext context) { - TextStyle style; - if (child.style != null) { - style = child.style!.copyWith( - foreground: Paint() - ..style = PaintingStyle.stroke - ..strokeCap = strokeCap - ..strokeJoin = strokeJoin - ..strokeWidth = strokeWidth - ..color = strokeColor, - color: null, - ); - } else { - style = TextStyle( - foreground: Paint() - ..style = PaintingStyle.stroke - ..strokeCap = strokeCap - ..strokeJoin = strokeJoin - ..strokeWidth = strokeWidth - ..color = strokeColor, - ); - } - return Stack( - alignment: Alignment.center, - textDirection: child.textDirection, - children: [ - Text( - child.data!, - style: style, - maxLines: child.maxLines, - overflow: child.overflow, - semanticsLabel: child.semanticsLabel, - softWrap: child.softWrap, - strutStyle: child.strutStyle, - textAlign: child.textAlign, - textDirection: child.textDirection, - textScaleFactor: child.textScaleFactor, - ), - child, - ], - ); - } -} diff --git a/lib/components/shared/compact_search.dart b/lib/components/shared/compact_search.dart deleted file mode 100644 index 70815291..00000000 --- a/lib/components/shared/compact_search.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -import 'package:popover/popover.dart'; -import 'package:spotube/collections/spotube_icons.dart'; - -class CompactSearch extends HookWidget { - final ValueChanged? onChanged; - final String placeholder; - final IconData icon; - final Color? iconColor; - - const CompactSearch({ - Key? key, - this.onChanged, - this.placeholder = "Search...", - this.icon = SpotubeIcons.search, - this.iconColor, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return IconButton( - onPressed: () { - showPopover( - context: context, - backgroundColor: Theme.of(context).cardColor, - transitionDuration: const Duration(milliseconds: 100), - barrierColor: Colors.transparent, - arrowDxOffset: -6, - bodyBuilder: (context) { - return Container( - padding: const EdgeInsets.all(8.0), - width: 300, - child: TextField( - autofocus: true, - onChanged: onChanged, - decoration: InputDecoration( - hintText: placeholder, - prefixIcon: Icon(icon), - ), - ), - ); - }, - height: 60, - ); - }, - tooltip: placeholder, - icon: Icon(icon, color: iconColor), - ); - } -} diff --git a/lib/components/shared/dialogs/confirm_download_dialog.dart b/lib/components/shared/dialogs/confirm_download_dialog.dart deleted file mode 100644 index c371e803..00000000 --- a/lib/components/shared/dialogs/confirm_download_dialog.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; - -class ConfirmDownloadDialog extends StatelessWidget { - const ConfirmDownloadDialog({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Padding( - padding: const EdgeInsets.all(15), - child: Row( - children: [ - Text(context.l10n.are_you_sure), - const SizedBox(width: 10), - const UniversalImage( - path: - "https://c.tenor.com/kHcmsxlKHEAAAAAM/rock-one-eyebrow-raised-rock-staring.gif", - height: 40, - width: 40, - ) - ], - ), - ), - content: Container( - padding: const EdgeInsets.all(15), - constraints: BoxConstraints(maxWidth: Breakpoints.sm), - 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: [ - OutlinedButton( - child: Text(context.l10n.decline), - onPressed: () { - Navigator.pop(context, false); - }, - ), - FilledButton( - style: FilledButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: Colors.red, - ), - onPressed: () => Navigator.of(context).pop(true), - child: Text(context.l10n.accept), - ), - ], - ); - } -} - -class BulletPoint extends StatelessWidget { - final String text; - const BulletPoint(this.text, {Key? key}) : super(key: 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/shared/dialogs/piped_down_dialog.dart b/lib/components/shared/dialogs/piped_down_dialog.dart deleted file mode 100644 index 6220adeb..00000000 --- a/lib/components/shared/dialogs/piped_down_dialog.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class PipedDownDialog extends HookConsumerWidget { - const PipedDownDialog({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final pipedInstance = - ref.watch(userPreferencesProvider.select((s) => s.pipedInstance)); - final ThemeData(:colorScheme) = Theme.of(context); - - return AlertDialog( - insetPadding: const EdgeInsets.all(6), - contentPadding: const EdgeInsets.all(6), - icon: Icon( - SpotubeIcons.error, - color: colorScheme.error, - ), - title: Text( - context.l10n.piped_api_down, - style: TextStyle(color: colorScheme.error), - ), - content: Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: - Text(context.l10n.piped_down_error_instructions(pipedInstance)), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.ok), - ), - FilledButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.settings), - ), - ], - ); - } -} diff --git a/lib/components/shared/dialogs/playlist_add_track_dialog.dart b/lib/components/shared/dialogs/playlist_add_track_dialog.dart deleted file mode 100644 index 51b77c76..00000000 --- a/lib/components/shared/dialogs/playlist_add_track_dialog.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/playlist/playlist_create_dialog.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.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, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final ThemeData(:textTheme) = Theme.of(context); - final spotify = ref.watch(spotifyProvider); - final userPlaylists = useQueries.playlist.ofMineAll(ref); - - final me = useQueries.user.me(ref); - - final filteredPlaylists = useMemoized( - () => - userPlaylists.data - ?.where( - (playlist) => - playlist.owner?.id != null && - playlist.owner!.id == me.data?.id && - playlist.id != openFromPlaylist, - ) - .toList() ?? - [], - [userPlaylists.data, me.data?.id, openFromPlaylist], - ); - - final playlistsCheck = useState({}); - final queryClient = useQueryClient(); - - Future onAdd() async { - final selectedPlaylists = playlistsCheck.value.entries - .where((entry) => entry.value) - .map((entry) => entry.key); - - await Future.wait( - selectedPlaylists.map( - (playlistId) => spotify.playlists.addTracks( - tracks - .map( - (track) => track.uri!, - ) - .toList(), - playlistId), - ), - ).then((_) => Navigator.pop(context, true)); - - await queryClient.refreshQueries( - selectedPlaylists - .map((playlistId) => "playlist-tracks/$playlistId") - .toList(), - ); - } - - return AlertDialog( - insetPadding: EdgeInsets.zero, - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - context.l10n.add_to_playlist, - style: textTheme.titleMedium, - ), - const Gap(20), - const PlaylistCreateDialogButton(), - ], - ), - actions: [ - OutlinedButton( - child: Text(context.l10n.cancel), - onPressed: () { - Navigator.pop(context, false); - }, - ), - FilledButton( - onPressed: onAdd, - child: Text(context.l10n.add), - ), - ], - content: SizedBox( - height: 300, - width: 300, - child: userPlaylists.isLoading - ? const Center(child: CircularProgressIndicator()) - : ListView.builder( - shrinkWrap: true, - itemCount: filteredPlaylists.length, - itemBuilder: (context, index) { - final playlist = filteredPlaylists.elementAt(index); - return CheckboxListTile( - secondary: CircleAvatar( - backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - playlist.images, - placeholder: ImagePlaceholder.collection, - ), - ), - ), - contentPadding: EdgeInsets.zero, - title: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text(playlist.name!), - ), - value: playlistsCheck.value[playlist.id] ?? false, - onChanged: (val) { - playlistsCheck.value = { - ...playlistsCheck.value, - playlist.id!: val == true - }; - }, - ); - }, - ), - ), - ); - } -} diff --git a/lib/components/shared/dialogs/replace_downloaded_dialog.dart b/lib/components/shared/dialogs/replace_downloaded_dialog.dart deleted file mode 100644 index 77721041..00000000 --- a/lib/components/shared/dialogs/replace_downloaded_dialog.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/context.dart'; - -final replaceDownloadedFileState = StateProvider((ref) => null); - -class ReplaceDownloadedDialog extends ConsumerWidget { - final Track track; - const ReplaceDownloadedDialog({required this.track, Key? key}) - : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final groupValue = ref.watch(replaceDownloadedFileState); - final theme = Theme.of(context); - final replaceAll = ref.watch(replaceDownloadedFileState); - - return AlertDialog( - title: Text(context.l10n.track_exists(track.name ?? "")), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(context.l10n.do_you_want_to_replace), - RadioListTile( - dense: true, - contentPadding: EdgeInsets.zero, - activeColor: theme.colorScheme.primary, - value: true, - groupValue: groupValue, - onChanged: (value) { - if (value != null) { - ref.read(replaceDownloadedFileState.notifier).state = true; - } - }, - title: Text(context.l10n.replace_downloaded_tracks), - ), - RadioListTile( - dense: true, - contentPadding: EdgeInsets.zero, - activeColor: theme.colorScheme.primary, - value: false, - groupValue: groupValue, - onChanged: (value) { - if (value != null) { - ref.read(replaceDownloadedFileState.notifier).state = false; - } - }, - title: Text(context.l10n.skip_download_tracks), - ), - ], - ), - actions: [ - OutlinedButton( - onPressed: replaceAll == true - ? null - : () { - Navigator.pop(context, false); - }, - child: Text(context.l10n.skip), - ), - FilledButton( - onPressed: replaceAll == false - ? null - : () { - Navigator.pop(context, true); - }, - child: Text(context.l10n.replace), - ), - ], - ); - } -} diff --git a/lib/components/shared/fallbacks/anonymous_fallback.dart b/lib/components/shared/fallbacks/anonymous_fallback.dart deleted file mode 100644 index aea7bf38..00000000 --- a/lib/components/shared/fallbacks/anonymous_fallback.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/extensions/context.dart'; - -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/utils/service_utils.dart'; - -class AnonymousFallback extends ConsumerWidget { - final Widget? child; - const AnonymousFallback({ - Key? key, - this.child, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final isLoggedIn = ref.watch(AuthenticationNotifier.provider) != null; - - if (isLoggedIn && child != null) return child!; - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(context.l10n.not_logged_in), - const SizedBox(height: 10), - FilledButton( - child: Text(context.l10n.login_with_spotify), - onPressed: () => ServiceUtils.push(context, "/settings"), - ) - ], - ), - ); - } -} diff --git a/lib/components/shared/fallbacks/not_found.dart b/lib/components/shared/fallbacks/not_found.dart deleted file mode 100644 index f45573ad..00000000 --- a/lib/components/shared/fallbacks/not_found.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:spotube/collections/assets.gen.dart'; - -class NotFound extends StatelessWidget { - final bool vertical; - const NotFound({Key? key, this.vertical = false}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final widgets = [ - SizedBox( - height: 150, - width: 150, - child: Assets.emptyBox.image(), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text("Nothing found", style: theme.textTheme.titleLarge), - Text( - "The box is empty", - style: theme.textTheme.titleMedium, - ), - ], - ), - ]; - return vertical ? Column(children: widgets) : Row(children: widgets); - } -} diff --git a/lib/components/shared/heart_button.dart b/lib/components/shared/heart_button.dart deleted file mode 100644 index 81ccffdb..00000000 --- a/lib/components/shared/heart_button.dart +++ /dev/null @@ -1,257 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/scrobbler_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/services/queries/queries.dart'; - -class HeartButton extends HookConsumerWidget { - final bool isLiked; - final void Function()? onPressed; - final IconData? icon; - final Color? color; - final String? tooltip; - const HeartButton({ - required this.isLiked, - required this.onPressed, - this.color, - this.tooltip, - this.icon, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final auth = ref.watch(AuthenticationNotifier.provider); - - if (auth == null) return const SizedBox.shrink(); - - return IconButton( - tooltip: tooltip, - 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, - ); - } -} - -typedef UseTrackToggleLike = ({ - bool isLiked, - Mutation toggleTrackLike, - Query me, -}); - -UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) { - final me = useQueries.user.me(ref); - - final savedTracks = useQueries.playlist.likedTracksQuery(ref); - - final isLiked = useMemoized( - () => savedTracks.data?.any((element) => element.id == track.id) ?? false, - [savedTracks.data, track.id], - ); - - final mounted = useIsMounted(); - - final scrobblerNotifier = ref.read(scrobblerProvider.notifier); - - final toggleTrackLike = useMutations.track.toggleFavorite( - ref, - track.id!, - onMutate: (isLiked) { - if (isLiked) { - savedTracks.setData( - savedTracks.data - ?.where((element) => element.id != track.id) - .toList() ?? - [], - ); - } else { - savedTracks.setData( - [ - ...?savedTracks.data, - track, - ], - ); - } - return isLiked; - }, - onData: (isLiked, recoveryData) async { - await savedTracks.refresh(); - if (isLiked) { - await scrobblerNotifier.love(track); - } else { - await scrobblerNotifier.unlove(track); - } - }, - onError: (payload, isLiked) { - if (!mounted()) return; - - if (isLiked != true) { - savedTracks.setData( - savedTracks.data - ?.where((element) => element.id != track.id) - .toList() ?? - [], - ); - } else { - savedTracks.setData( - [ - ...?savedTracks.data, - track, - ], - ); - } - }, - ); - - return (isLiked: isLiked, toggleTrackLike: toggleTrackLike, me: me); -} - -class TrackHeartButton extends HookConsumerWidget { - final Track track; - const TrackHeartButton({ - Key? key, - required this.track, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final savedTracks = useQueries.playlist.likedTracksQuery(ref); - final (:me, :isLiked, :toggleTrackLike) = useTrackToggleLike(track, ref); - - if (me.isLoading || !me.hasData) { - return const CircularProgressIndicator(); - } - - return HeartButton( - tooltip: isLiked - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - isLiked: isLiked, - onPressed: savedTracks.hasData - ? () { - toggleTrackLike.mutate(isLiked); - } - : null, - ); - } -} - -class PlaylistHeartButton extends HookConsumerWidget { - final PlaylistSimple playlist; - final IconData? icon; - final ValueChanged? onData; - - const PlaylistHeartButton({ - required this.playlist, - Key? key, - this.icon, - this.onData, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final me = useQueries.user.me(ref); - - final isLikedQuery = useQueries.playlist.doesUserFollow( - ref, - playlist.id!, - me.data?.id ?? '', - ); - - final togglePlaylistLike = useMutations.playlist.toggleFavorite( - ref, - playlist.id!, - refreshQueries: [ - isLikedQuery.key, - ], - onData: onData, - ); - - if (me.isLoading || !me.hasData) { - return const CircularProgressIndicator(); - } - - return HeartButton( - isLiked: isLikedQuery.data ?? false, - tooltip: isLikedQuery.data ?? false - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - color: Colors.white, - icon: icon, - onPressed: isLikedQuery.hasData - ? () { - togglePlaylistLike.mutate(isLikedQuery.data!); - } - : null, - ); - } -} - -class AlbumHeartButton extends HookConsumerWidget { - final AlbumSimple album; - - const AlbumHeartButton({ - required this.album, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final client = useQueryClient(); - final me = useQueries.user.me(ref); - - final albumIsSaved = useQueries.album.isSavedForMe(ref, album.id!); - final isLiked = albumIsSaved.data ?? false; - - final toggleAlbumLike = useMutations.album.toggleFavorite( - ref, - album.id!, - refreshQueries: [albumIsSaved.key], - onData: (_, __) async { - await client.refreshInfiniteQueryAllPages("current-user-albums"); - }, - ); - - if (me.isLoading || !me.hasData) { - return const CircularProgressIndicator(); - } - - return HeartButton( - isLiked: isLiked, - tooltip: isLiked - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - color: Colors.white, - onPressed: albumIsSaved.hasData - ? () { - toggleAlbumLike.mutate(isLiked); - } - : null, - ); - } -} diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart deleted file mode 100644 index 9aa2d4a8..00000000 --- a/lib/components/shared/page_window_title_bar.dart +++ /dev/null @@ -1,600 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.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 'dart:math'; -import 'package:flutter/foundation.dart' show kIsWeb; -import 'dart:io' show Platform; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; - -class PageWindowTitleBar extends StatefulHookConsumerWidget - implements PreferredSizeWidget { - final Widget? leading; - final bool automaticallyImplyLeading; - final List? actions; - final Color? backgroundColor; - final Color? foregroundColor; - final IconThemeData? actionsIconTheme; - final bool? centerTitle; - final double? titleSpacing; - final double toolbarOpacity; - final double? leadingWidth; - final TextStyle? toolbarTextStyle; - final TextStyle? titleTextStyle; - final double? titleWidth; - final Widget? title; - - const PageWindowTitleBar({ - Key? key, - this.actions, - this.title, - this.toolbarOpacity = 1, - this.backgroundColor, - this.actionsIconTheme, - this.automaticallyImplyLeading = false, - this.centerTitle, - this.foregroundColor, - this.leading, - this.leadingWidth, - this.titleSpacing, - this.titleTextStyle, - this.titleWidth, - this.toolbarTextStyle, - }) : super(key: key); - - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); - - @override - ConsumerState createState() => _PageWindowTitleBarState(); -} - -class _PageWindowTitleBarState extends ConsumerState { - void onDrag(details) { - final systemTitleBar = - ref.read(userPreferencesProvider.select((s) => s.systemTitleBar)); - if (kIsDesktop && !systemTitleBar) { - DesktopTools.window.startDragging(); - } - } - - @override - Widget build(BuildContext context) { - final mediaQuery = MediaQuery.of(context); - - return LayoutBuilder(builder: (context, constrains) { - final hasFullscreen = mediaQuery.size.width == constrains.maxWidth; - final hasLeadingOrCanPop = - widget.leading != null || Navigator.canPop(context); - - return GestureDetector( - onHorizontalDragStart: onDrag, - onVerticalDragStart: onDrag, - child: Padding( - padding: EdgeInsets.only( - left: DesktopTools.platform.isMacOS && - hasFullscreen && - hasLeadingOrCanPop - ? 65 - : 0, - ), - child: AppBar( - leading: widget.leading, - automaticallyImplyLeading: widget.automaticallyImplyLeading, - actions: [ - ...?widget.actions, - WindowTitleBarButtons(foregroundColor: widget.foregroundColor), - ], - backgroundColor: widget.backgroundColor, - foregroundColor: widget.foregroundColor, - actionsIconTheme: widget.actionsIconTheme, - centerTitle: widget.centerTitle, - titleSpacing: widget.titleSpacing, - toolbarOpacity: widget.toolbarOpacity, - leadingWidth: widget.leadingWidth, - toolbarTextStyle: widget.toolbarTextStyle, - titleTextStyle: widget.titleTextStyle, - title: widget.title, - ), - ), - ); - }); - } -} - -class WindowTitleBarButtons extends HookConsumerWidget { - final Color? foregroundColor; - const WindowTitleBarButtons({ - Key? key, - this.foregroundColor, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final preferences = ref.watch(userPreferencesProvider); - final isMaximized = useState(null); - const type = ThemeType.auto; - - Future onClose() async { - await DesktopTools.window.close(); - } - - useEffect(() { - if (kIsDesktop) { - DesktopTools.window.isMaximized().then((value) { - isMaximized.value = value; - }); - } - return null; - }, []); - - if (!kIsDesktop || kIsMacOS || preferences.systemTitleBar) { - return const SizedBox.shrink(); - } - - if (kIsWindows) { - final theme = Theme.of(context); - final colors = WindowButtonColors( - normal: Colors.transparent, - iconNormal: foregroundColor ?? theme.colorScheme.onBackground, - mouseOver: theme.colorScheme.onBackground.withOpacity(0.1), - mouseDown: theme.colorScheme.onBackground.withOpacity(0.2), - iconMouseOver: theme.colorScheme.onBackground, - iconMouseDown: theme.colorScheme.onBackground, - ); - - final closeColors = WindowButtonColors( - normal: Colors.transparent, - iconNormal: foregroundColor ?? theme.colorScheme.onBackground, - mouseOver: Colors.red, - mouseDown: Colors.red[800]!, - iconMouseOver: Colors.white, - iconMouseDown: Colors.black, - ); - - return Padding( - padding: const EdgeInsets.only(bottom: 25), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MinimizeWindowButton( - onPressed: DesktopTools.window.minimize, - colors: colors, - ), - if (isMaximized.value != true) - MaximizeWindowButton( - colors: colors, - onPressed: () { - DesktopTools.window.maximize(); - isMaximized.value = true; - }, - ) - else - RestoreWindowButton( - colors: colors, - onPressed: () { - DesktopTools.window.unmaximize(); - isMaximized.value = false; - }, - ), - CloseWindowButton( - colors: closeColors, - onPressed: onClose, - ), - ], - ), - ); - } - - return Padding( - padding: const EdgeInsets.only(bottom: 20, left: 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DecoratedMinimizeButton( - type: type, - onPressed: DesktopTools.window.minimize, - ), - DecoratedMaximizeButton( - type: type, - onPressed: () async { - if (await DesktopTools.window.isMaximized()) { - await DesktopTools.window.unmaximize(); - isMaximized.value = false; - } else { - await DesktopTools.window.maximize(); - isMaximized.value = true; - } - }, - ), - DecoratedCloseButton( - type: type, - onPressed: onClose, - ), - ], - ), - ); - } -} - -typedef WindowButtonIconBuilder = Widget Function( - WindowButtonContext buttonContext); -typedef WindowButtonBuilder = Widget Function( - WindowButtonContext buttonContext, Widget icon); - -class WindowButtonContext { - BuildContext context; - MouseState mouseState; - Color? backgroundColor; - Color iconColor; - WindowButtonContext( - {required this.context, - required this.mouseState, - this.backgroundColor, - required this.iconColor}); -} - -class WindowButtonColors { - late Color normal; - late Color mouseOver; - late Color mouseDown; - late Color iconNormal; - late Color iconMouseOver; - late Color iconMouseDown; - WindowButtonColors( - {Color? normal, - Color? mouseOver, - Color? mouseDown, - Color? iconNormal, - Color? iconMouseOver, - Color? iconMouseDown}) { - this.normal = normal ?? _defaultButtonColors.normal; - this.mouseOver = mouseOver ?? _defaultButtonColors.mouseOver; - this.mouseDown = mouseDown ?? _defaultButtonColors.mouseDown; - this.iconNormal = iconNormal ?? _defaultButtonColors.iconNormal; - this.iconMouseOver = iconMouseOver ?? _defaultButtonColors.iconMouseOver; - this.iconMouseDown = iconMouseDown ?? _defaultButtonColors.iconMouseDown; - } -} - -final _defaultButtonColors = WindowButtonColors( - normal: Colors.transparent, - iconNormal: const Color(0xFF805306), - mouseOver: const Color(0xFF404040), - mouseDown: const Color(0xFF202020), - iconMouseOver: const Color(0xFFFFFFFF), - iconMouseDown: const Color(0xFFF0F0F0), -); - -class WindowButton extends StatelessWidget { - final WindowButtonBuilder? builder; - final WindowButtonIconBuilder? iconBuilder; - late final WindowButtonColors colors; - final bool animate; - final EdgeInsets? padding; - final VoidCallback? onPressed; - - WindowButton( - {Key? key, - WindowButtonColors? colors, - this.builder, - @required this.iconBuilder, - this.padding, - this.onPressed, - this.animate = false}) - : super(key: key) { - this.colors = colors ?? _defaultButtonColors; - } - - Color getBackgroundColor(MouseState mouseState) { - if (mouseState.isMouseDown) return colors.mouseDown; - if (mouseState.isMouseOver) return colors.mouseOver; - return colors.normal; - } - - Color getIconColor(MouseState mouseState) { - if (mouseState.isMouseDown) return colors.iconMouseDown; - if (mouseState.isMouseOver) return colors.iconMouseOver; - return colors.iconNormal; - } - - @override - Widget build(BuildContext context) { - if (kIsWeb) { - return Container(); - } else { - // Don't show button on macOS - if (Platform.isMacOS) { - return Container(); - } - } - - return MouseStateBuilder( - builder: (context, mouseState) { - WindowButtonContext buttonContext = WindowButtonContext( - mouseState: mouseState, - context: context, - backgroundColor: getBackgroundColor(mouseState), - iconColor: getIconColor(mouseState)); - - var icon = - (iconBuilder != null) ? iconBuilder!(buttonContext) : Container(); - - var fadeOutColor = - getBackgroundColor(MouseState()..isMouseOver = true).withOpacity(0); - var padding = this.padding ?? const EdgeInsets.all(10); - var animationMs = - mouseState.isMouseOver ? (animate ? 100 : 0) : (animate ? 200 : 0); - Widget iconWithPadding = Padding(padding: padding, child: icon); - iconWithPadding = AnimatedContainer( - curve: Curves.easeOut, - duration: Duration(milliseconds: animationMs), - color: buttonContext.backgroundColor ?? fadeOutColor, - child: iconWithPadding); - var button = - (builder != null) ? builder!(buttonContext, icon) : iconWithPadding; - return SizedBox( - width: 45, - height: 32, - child: button, - ); - }, - onPressed: () { - if (onPressed != null) onPressed!(); - }, - ); - } -} - -class MinimizeWindowButton extends WindowButton { - MinimizeWindowButton( - {Key? key, - WindowButtonColors? colors, - VoidCallback? onPressed, - bool? animate}) - : super( - key: key, - colors: colors, - animate: animate ?? false, - iconBuilder: (buttonContext) => - MinimizeIcon(color: buttonContext.iconColor), - onPressed: onPressed, - ); -} - -class MaximizeWindowButton extends WindowButton { - MaximizeWindowButton( - {Key? key, - WindowButtonColors? colors, - VoidCallback? onPressed, - bool? animate}) - : super( - key: key, - colors: colors, - animate: animate ?? false, - iconBuilder: (buttonContext) => - MaximizeIcon(color: buttonContext.iconColor), - onPressed: onPressed, - ); -} - -class RestoreWindowButton extends WindowButton { - RestoreWindowButton( - {Key? key, - WindowButtonColors? colors, - VoidCallback? onPressed, - bool? animate}) - : super( - key: key, - colors: colors, - animate: animate ?? false, - iconBuilder: (buttonContext) => - RestoreIcon(color: buttonContext.iconColor), - onPressed: onPressed, - ); -} - -final _defaultCloseButtonColors = WindowButtonColors( - mouseOver: const Color(0xFFD32F2F), - mouseDown: const Color(0xFFB71C1C), - iconNormal: const Color(0xFF805306), - iconMouseOver: const Color(0xFFFFFFFF)); - -class CloseWindowButton extends WindowButton { - CloseWindowButton( - {Key? key, - WindowButtonColors? colors, - VoidCallback? onPressed, - bool? animate}) - : super( - key: key, - colors: colors ?? _defaultCloseButtonColors, - animate: animate ?? false, - iconBuilder: (buttonContext) => - CloseIcon(color: buttonContext.iconColor), - onPressed: onPressed, - ); -} - -// Switched to CustomPaint icons by https://github.com/esDotDev - -/// Close -class CloseIcon extends StatelessWidget { - final Color color; - const CloseIcon({Key? key, required this.color}) : super(key: key); - @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({Key? key, required this.color}) : super(key: key); - @override - Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color)); -} - -class _MaximizePainter extends _IconPainter { - _MaximizePainter(Color color) : 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({ - Key? key, - required this.color, - }) : super(key: key); - @override - Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color)); -} - -class _RestorePainter extends _IconPainter { - _RestorePainter(Color color) : 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({Key? key, required this.color}) : super(key: key); - @override - Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color)); -} - -class _MinimizePainter extends _IconPainter { - _MinimizePainter(Color color) : 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, {Key? key}) : super(key: key); - 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; - -typedef MouseStateBuilderCB = Widget Function( - BuildContext context, MouseState mouseState); - -class MouseState { - bool isMouseOver = false; - bool isMouseDown = false; - MouseState(); - @override - String toString() { - return "isMouseDown: $isMouseDown - isMouseOver: $isMouseOver"; - } -} - -T? _ambiguate(T? value) => value; - -class MouseStateBuilder extends StatefulWidget { - final MouseStateBuilderCB builder; - final VoidCallback? onPressed; - const MouseStateBuilder({Key? key, required this.builder, this.onPressed}) - : super(key: key); - @override - _MouseStateBuilderState createState() => _MouseStateBuilderState(); -} - -class _MouseStateBuilderState extends State { - late MouseState _mouseState; - _MouseStateBuilderState() { - _mouseState = MouseState(); - } - - @override - Widget build(BuildContext context) { - return MouseRegion( - onEnter: (event) { - setState(() { - _mouseState.isMouseOver = true; - }); - }, - onExit: (event) { - setState(() { - _mouseState.isMouseOver = false; - }); - }, - child: GestureDetector( - onTapDown: (_) { - setState(() { - _mouseState.isMouseDown = true; - }); - }, - onTapCancel: () { - setState(() { - _mouseState.isMouseDown = false; - }); - }, - onTap: () { - setState(() { - _mouseState.isMouseDown = false; - _mouseState.isMouseOver = false; - }); - _ambiguate(WidgetsBinding.instance)!.addPostFrameCallback((_) { - if (widget.onPressed != null) { - widget.onPressed!(); - } - }); - }, - onTapUp: (_) {}, - child: widget.builder(context, _mouseState))); - } -} diff --git a/lib/components/shared/panels/controller.dart b/lib/components/shared/panels/controller.dart deleted file mode 100644 index a573c06c..00000000 --- a/lib/components/shared/panels/controller.dart +++ /dev/null @@ -1,142 +0,0 @@ -part of panels; - -class PanelController extends ChangeNotifier { - SlidingUpPanelState? _panelState; - - void _addState(SlidingUpPanelState panelState) { - _panelState = panelState; - notifyListeners(); - } - - bool _forceScrollChange = false; - - /// use this function when scroll change in func - /// Example: - /// panelController.forseScrollChange(scrollController.animateTo(100, duration: Duration(milliseconds: 400), curve: Curves.ease)) - Future forceScrollChange(Future func) async { - _forceScrollChange = true; - _panelState!._scrollingEnabled = true; - await func; - // if (_panelState!._sc.offset == 0) { - // _panelState!._scrollingEnabled = true; - // } - if (panelPosition < 1) { - _panelState!._scMinOffset = _panelState!._scrollController.offset; - } - _forceScrollChange = false; - } - - bool __nowTargetForceDraggable = false; - - bool get _nowTargetForceDraggable => __nowTargetForceDraggable; - - set _nowTargetForceDraggable(bool value) { - __nowTargetForceDraggable = value; - notifyListeners(); - } - - /// Determine if the panelController is attached to an instance - /// of the SlidingUpPanel (this property must return true before any other - /// functions can be used) - bool get isAttached => _panelState != null; - - /// Closes the sliding panel to its collapsed state (i.e. to the minHeight) - Future close() { - assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - return _panelState!._close(); - } - - /// Opens the sliding panel fully - /// (i.e. to the maxHeight) - Future open() { - assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - return _panelState!._open(); - } - - /// Hides the sliding panel (i.e. is invisible) - Future hide() { - assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - return _panelState!._hide(); - } - - /// Shows the sliding panel in its collapsed state - /// (i.e. "un-hide" the sliding panel) - Future show() { - assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - return _panelState!._show(); - } - - /// Animates the panel position to the value. - /// The value must between 0.0 and 1.0 - /// where 0.0 is fully collapsed and 1.0 is completely open. - /// (optional) duration specifies the time for the animation to complete - /// (optional) curve specifies the easing behavior of the animation. - Future animatePanelToPosition(double value, - {Duration? duration, Curve curve = Curves.linear}) { - assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - assert(0.0 <= value && value <= 1.0); - return _panelState! - ._animatePanelToPosition(value, duration: duration, curve: curve); - } - - /// Animates the panel position to the snap point - /// Requires that the SlidingUpPanel snapPoint property is not null - /// (optional) duration specifies the time for the animation to complete - /// (optional) curve specifies the easing behavior of the animation. - Future animatePanelToSnapPoint( - {Duration? duration, Curve curve = Curves.linear}) { - assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - assert(_panelState!.widget.snapPoint != null, - "SlidingUpPanel snapPoint property must not be null"); - return _panelState! - ._animatePanelToSnapPoint(duration: duration, curve: curve); - } - - /// Sets the panel position (without animation). - /// The value must between 0.0 and 1.0 - /// where 0.0 is fully collapsed and 1.0 is completely open. - set panelPosition(double value) { - assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - assert(0.0 <= value && value <= 1.0); - _panelState!._panelPosition = value; - } - - /// Gets the current panel position. - /// Returns the % offset from collapsed state - /// to the open state - /// as a decimal between 0.0 and 1.0 - /// where 0.0 is fully collapsed and - /// 1.0 is full open. - double get panelPosition { - assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - return _panelState!._panelPosition; - } - - /// Returns whether or not the panel is - /// currently animating. - bool get isPanelAnimating { - assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - return _panelState!._isPanelAnimating; - } - - /// Returns whether or not the - /// panel is open. - bool get isPanelOpen { - assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - return _panelState!._isPanelOpen; - } - - /// Returns whether or not the - /// panel is closed. - bool get isPanelClosed { - assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - return _panelState!._isPanelClosed; - } - - /// Returns whether or not the - /// panel is shown/hidden. - bool get isPanelShown { - assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - return _panelState!._isPanelShown; - } -} diff --git a/lib/components/shared/panels/helpers.dart b/lib/components/shared/panels/helpers.dart deleted file mode 100644 index 2e754bdf..00000000 --- a/lib/components/shared/panels/helpers.dart +++ /dev/null @@ -1,96 +0,0 @@ -part of panels; - -/// if you want to prevent the panel from being dragged using the widget, -/// wrap the widget with this -class IgnoreDraggableWidget extends SingleChildRenderObjectWidget { - const IgnoreDraggableWidget({ - super.key, - required super.child, - }); - - @override - IgnoreDraggableWidgetWidgetRenderBox createRenderObject( - BuildContext context, - ) { - return IgnoreDraggableWidgetWidgetRenderBox(); - } -} - -class IgnoreDraggableWidgetWidgetRenderBox extends RenderPointerListener { - @override - HitTestBehavior get behavior => HitTestBehavior.opaque; -} - -/// if you want to force the panel to be dragged using the widget, -/// wrap the widget with this -/// For example, use [Scrollable] inside to allow the panel to be dragged -/// even if the scroll is not at position 0. -class ForceDraggableWidget extends SingleChildRenderObjectWidget { - const ForceDraggableWidget({ - super.key, - required super.child, - }); - - @override - ForceDraggableWidgetRenderBox createRenderObject( - BuildContext context, - ) { - return ForceDraggableWidgetRenderBox(); - } -} - -class ForceDraggableWidgetRenderBox extends RenderPointerListener { - @override - HitTestBehavior get behavior => HitTestBehavior.opaque; -} - -/// To make [ForceDraggableWidget] work in [Scrollable] widgets -class PanelScrollPhysics extends ScrollPhysics { - final PanelController controller; - const PanelScrollPhysics({required this.controller, ScrollPhysics? parent}) - : super(parent: parent); - @override - PanelScrollPhysics applyTo(ScrollPhysics? ancestor) { - return PanelScrollPhysics( - controller: controller, parent: buildParent(ancestor)); - } - - @override - double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { - if (controller._nowTargetForceDraggable) return 0.0; - return super.applyPhysicsToUserOffset(position, offset); - } - - @override - Simulation? createBallisticSimulation( - ScrollMetrics position, double velocity) { - if (controller._nowTargetForceDraggable) { - return super.createBallisticSimulation(position, 0); - } - return super.createBallisticSimulation(position, velocity); - } - - @override - bool get allowImplicitScrolling => false; -} - -/// if you want to prevent unwanted panel dragging when scrolling widgets [Scrollable] with horizontal axis -/// wrap the widget with this -class HorizontalScrollableWidget extends SingleChildRenderObjectWidget { - const HorizontalScrollableWidget({ - super.key, - required super.child, - }); - - @override - HorizontalScrollableWidgetRenderBox createRenderObject( - BuildContext context, - ) { - return HorizontalScrollableWidgetRenderBox(); - } -} - -class HorizontalScrollableWidgetRenderBox extends RenderPointerListener { - @override - HitTestBehavior get behavior => HitTestBehavior.opaque; -} diff --git a/lib/components/shared/panels/sliding_up_panel.dart b/lib/components/shared/panels/sliding_up_panel.dart deleted file mode 100644 index 137d5eb7..00000000 --- a/lib/components/shared/panels/sliding_up_panel.dart +++ /dev/null @@ -1,686 +0,0 @@ -/* -Name: Zotov Vladimir -Date: 18/06/22 -Purpose: Defines the package: sliding_up_panel2 -Copyright: © 2022, Zotov Vladimir. All rights reserved. -Licensing: More information can be found here: https://github.com/Zotov-VD/sliding_up_panel/blob/master/LICENSE - -This product includes software developed by Akshath Jain (https://akshathjain.com) -*/ - -library panels; - -import 'dart:math'; - -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/physics.dart'; -import 'package:flutter/rendering.dart'; - -part 'controller.dart'; -part 'helpers.dart'; - -enum SlideDirection { up, down } - -enum PanelState { open, closed } - -class SlidingUpPanel extends StatefulWidget { - /// Returns the Widget that slides into view. When the - /// panel is collapsed and if [collapsed] is null, - /// then top portion of this Widget will be displayed; - /// otherwise, [collapsed] will be displayed overtop - /// of this Widget. - final Widget? Function(double position)? panelBuilder; - - /// The Widget displayed overtop the [panel] when collapsed. - /// This fades out as the panel is opened. - final Widget? collapsed; - - /// The Widget that lies underneath the sliding panel. - /// This Widget automatically sizes itself - /// to fill the screen. - final Widget? body; - - /// Optional persistent widget that floats above the [panel] and attaches - /// to the top of the [panel]. Content at the top of the panel will be covered - /// by this widget. Add padding to the bottom of the `panel` to - /// avoid coverage. - final Widget? header; - - /// Optional persistent widget that floats above the [panel] and - /// attaches to the bottom of the [panel]. Content at the bottom of the panel - /// will be covered by this widget. Add padding to the bottom of the `panel` - /// to avoid coverage. - final Widget? footer; - - /// The height of the sliding panel when fully collapsed. - final double minHeight; - - /// The height of the sliding panel when fully open. - final double maxHeight; - - /// A point between [minHeight] and [maxHeight] that the panel snaps to - /// while animating. A fast swipe on the panel will disregard this point - /// and go directly to the open/close position. This value is represented as a - /// percentage of the total animation distance ([maxHeight] - [minHeight]), - /// so it must be between 0.0 and 1.0, exclusive. - final double? snapPoint; - - /// The amount to inset the children of the sliding panel sheet. - final EdgeInsetsGeometry? padding; - - /// Empty space surrounding the sliding panel sheet. - final EdgeInsetsGeometry? margin; - - /// Set to false to disable the panel from snapping open or closed. - final bool panelSnapping; - - /// Disable panel draggable on scrolling. Defaults to false. - final bool disableDraggableOnScrolling; - - /// If non-null, this can be used to control the state of the panel. - final PanelController? controller; - - /// If non-null, shows a darkening shadow over the [body] as the panel slides open. - final bool backdropEnabled; - - /// Shows a darkening shadow of this [Color] over the [body] as the panel slides open. - final Color backdropColor; - - /// The opacity of the backdrop when the panel is fully open. - /// This value can range from 0.0 to 1.0 where 0.0 is completely transparent - /// and 1.0 is completely opaque. - final double backdropOpacity; - - /// Flag that indicates whether or not tapping the - /// backdrop closes the panel. Defaults to true. - final bool backdropTapClosesPanel; - - /// If non-null, this callback - /// is called as the panel slides around with the - /// current position of the panel. The position is a double - /// between 0.0 and 1.0 where 0.0 is fully collapsed and 1.0 is fully open. - final void Function(double position)? onPanelSlide; - - /// If non-null, this callback is called when the - /// panel is fully opened - final VoidCallback? onPanelOpened; - - /// If non-null, this callback is called when the panel - /// is fully collapsed. - final VoidCallback? onPanelClosed; - - /// If non-null and true, the SlidingUpPanel exhibits a - /// parallax effect as the panel slides up. Essentially, - /// the body slides up as the panel slides up. - final bool parallaxEnabled; - - /// Allows for specifying the extent of the parallax effect in terms - /// of the percentage the panel has slid up/down. Recommended values are - /// within 0.0 and 1.0 where 0.0 is no parallax and 1.0 mimics a - /// one-to-one scrolling effect. Defaults to a 10% parallax. - final double parallaxOffset; - - /// Allows toggling of the draggability of the SlidingUpPanel. - /// Set this to false to prevent the user from being able to drag - /// the panel up and down. Defaults to true. - final bool isDraggable; - - /// Either SlideDirection.UP or SlideDirection.DOWN. Indicates which way - /// the panel should slide. Defaults to UP. If set to DOWN, the panel attaches - /// itself to the top of the screen and is fully opened when the user swipes - /// down on the panel. - final SlideDirection slideDirection; - - /// The default state of the panel; either PanelState.OPEN or PanelState.CLOSED. - /// This value defaults to PanelState.CLOSED which indicates that the panel is - /// in the closed position and must be opened. PanelState.OPEN indicates that - /// by default the Panel is open and must be swiped closed by the user. - final PanelState defaultPanelState; - - /// To attach to a [Scrollable] on a panel that - /// links the panel's position to the scroll position. Useful for implementing - /// infinite scroll behavior - final ScrollController? scrollController; - - final BoxDecoration? panelDecoration; - - const SlidingUpPanel( - {Key? key, - this.body, - this.collapsed, - this.minHeight = 100.0, - this.maxHeight = 500.0, - this.snapPoint, - this.padding, - this.margin, - this.panelDecoration, - this.panelSnapping = true, - this.disableDraggableOnScrolling = false, - this.controller, - this.backdropEnabled = false, - this.backdropColor = Colors.black, - this.backdropOpacity = 0.5, - this.backdropTapClosesPanel = true, - this.onPanelSlide, - this.onPanelOpened, - this.onPanelClosed, - this.parallaxEnabled = false, - this.parallaxOffset = 0.1, - this.isDraggable = true, - this.slideDirection = SlideDirection.up, - this.defaultPanelState = PanelState.closed, - this.header, - this.footer, - this.scrollController, - this.panelBuilder}) - : assert(panelBuilder != null), - assert(0 <= backdropOpacity && backdropOpacity <= 1.0), - assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0), - super(key: key); - - @override - SlidingUpPanelState createState() => SlidingUpPanelState(); -} - -class SlidingUpPanelState extends State - with SingleTickerProviderStateMixin { - late AnimationController _animationController; - late final ScrollController _scrollController; - - bool _scrollingEnabled = false; - final VelocityTracker _velocityTracker = - VelocityTracker.withKind(PointerDeviceKind.touch); - - bool _isPanelVisible = true; - - @override - void initState() { - super.initState(); - - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 300), - value: widget.defaultPanelState == PanelState.closed - ? 0.0 - : 1.0 //set the default panel state (i.e. set initial value of _ac) - ) - ..addListener(() { - if (widget.onPanelSlide != null) { - widget.onPanelSlide!(_animationController.value); - } - - if (widget.onPanelOpened != null && - (_animationController.value == 1.0 || - _animationController.value == 0.0)) { - widget.onPanelOpened!(); - } - }); - - // prevent the panel content from being scrolled only if the widget is - // draggable and panel scrolling is enabled - _scrollController = widget.scrollController ?? ScrollController(); - _scrollController.addListener(() { - if (widget.isDraggable && - !widget.disableDraggableOnScrolling && - (!_scrollingEnabled || _panelPosition < 1) && - widget.controller?._forceScrollChange != true) { - _scrollController.jumpTo(_scMinOffset); - } - }); - - widget.controller?._addState(this); - } - - @override - Widget build(BuildContext context) { - final mediaQuery = MediaQuery.of(context); - - return Stack( - alignment: widget.slideDirection == SlideDirection.up - ? Alignment.bottomCenter - : Alignment.topCenter, - children: [ - //make the back widget take up the entire back side - if (widget.body != null) - AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Positioned( - top: widget.parallaxEnabled ? _getParallax() : 0.0, - child: child ?? const SizedBox(), - ); - }, - child: SizedBox( - height: mediaQuery.size.height, - width: mediaQuery.size.width, - child: widget.body, - ), - ), - - //the backdrop to overlay on the body - if (widget.backdropEnabled) - GestureDetector( - onVerticalDragEnd: widget.backdropTapClosesPanel - ? (DragEndDetails details) { - // only trigger a close if the drag is towards panel close position - if ((widget.slideDirection == SlideDirection.up ? 1 : -1) * - details.velocity.pixelsPerSecond.dy > - 0) _close(); - } - : null, - onTap: widget.backdropTapClosesPanel ? () => _close() : null, - child: AnimatedBuilder( - animation: _animationController, - builder: (context, _) { - return Container( - height: mediaQuery.size.height, - width: mediaQuery.size.width, - - //set color to null so that touch events pass through - //to the body when the panel is closed, otherwise, - //if a color exists, then touch events won't go through - color: _animationController.value == 0.0 - ? null - : widget.backdropColor.withOpacity( - widget.backdropOpacity * _animationController.value, - ), - ); - }), - ), - - //the actual sliding part - if (_isPanelVisible) - _gestureHandler( - child: AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Container( - height: _animationController.value * - (widget.maxHeight - widget.minHeight) + - widget.minHeight, - margin: widget.margin, - padding: widget.padding, - decoration: widget.panelDecoration, - child: child, - ); - }, - child: Stack( - children: [ - //open panel - Positioned( - top: - widget.slideDirection == SlideDirection.up ? 0.0 : null, - bottom: widget.slideDirection == SlideDirection.down - ? 0.0 - : null, - width: mediaQuery.size.width - - (widget.margin != null - ? widget.margin!.horizontal - : 0) - - (widget.padding != null - ? widget.padding!.horizontal - : 0), - child: SizedBox( - height: widget.maxHeight, - child: widget.panelBuilder!( - _animationController.value, - ), - ), - ), - - // footer - if (widget.footer != null) - Positioned( - top: widget.slideDirection == SlideDirection.up - ? null - : 0.0, - bottom: widget.slideDirection == SlideDirection.down - ? null - : 0.0, - child: widget.footer ?? const SizedBox()), - - // header - if (widget.header != null) - Positioned( - top: widget.slideDirection == SlideDirection.up - ? 0.0 - : null, - bottom: widget.slideDirection == SlideDirection.down - ? 0.0 - : null, - child: widget.header ?? const SizedBox(), - ), - - // collapsed panel - Positioned( - top: - widget.slideDirection == SlideDirection.up ? 0.0 : null, - bottom: widget.slideDirection == SlideDirection.down - ? 0.0 - : null, - width: mediaQuery.size.width - - (widget.margin != null - ? widget.margin!.horizontal - : 0) - - (widget.padding != null - ? widget.padding!.horizontal - : 0), - child: AnimatedContainer( - duration: const Duration(milliseconds: 250), - height: widget.minHeight, - child: widget.collapsed == null - ? null - : FadeTransition( - opacity: Tween(begin: 1.0, end: 0.0) - .animate(_animationController), - - // if the panel is open ignore pointers (touch events) on the collapsed - // child so that way touch events go through to whatever is underneath - child: IgnorePointer( - ignoring: _animationController.value == 1.0, - child: widget.collapsed, - ), - ), - ), - ), - ], - ), - ), - ), - ], - ); - } - - @override - void dispose() { - _animationController.dispose(); - super.dispose(); - } - - double _getParallax() { - if (widget.slideDirection == SlideDirection.up) { - return -_animationController.value * - (widget.maxHeight - widget.minHeight) * - widget.parallaxOffset; - } else { - return _animationController.value * - (widget.maxHeight - widget.minHeight) * - widget.parallaxOffset; - } - } - - bool _ignoreScrollable = false; - bool _isHorizontalScrollableWidget = false; - Axis? _scrollableAxis; - - // returns a gesture detector if panel is used - // and a listener if panelBuilder is used. - // this is because the listener is designed only for use with linking the scrolling of - // panels and using it for panels that don't want to linked scrolling yields odd results - Widget _gestureHandler({required Widget child}) { - if (!widget.isDraggable) return child; - - return Listener( - onPointerDown: (PointerDownEvent e) { - var rb = context.findRenderObject() as RenderBox; - var result = BoxHitTestResult(); - rb.hitTest(result, position: e.position); - - if (_panelPosition == 1) { - _scMinOffset = 0.0; - } - // if there any widget in the path that must force graggable, - // stop it right here - if (result.path.any((entry) => - entry.target.runtimeType == ForceDraggableWidgetRenderBox)) { - widget.controller?._nowTargetForceDraggable = true; - _scMinOffset = _scrollController.offset; - _isHorizontalScrollableWidget = false; - } else if (result.path.any((entry) => - entry.target.runtimeType == HorizontalScrollableWidgetRenderBox)) { - _isHorizontalScrollableWidget = true; - widget.controller?._nowTargetForceDraggable = false; - } else if (result.path.any((entry) => - entry.target.runtimeType == IgnoreDraggableWidgetWidgetRenderBox)) { - _ignoreScrollable = true; - widget.controller?._nowTargetForceDraggable = false; - _isHorizontalScrollableWidget = false; - return; - } else { - widget.controller?._nowTargetForceDraggable = false; - _isHorizontalScrollableWidget = false; - } - _ignoreScrollable = false; - _velocityTracker.addPosition(e.timeStamp, e.position); - }, - onPointerMove: (PointerMoveEvent e) { - if (_scrollableAxis == null) { - if (e.delta.dx.abs() > e.delta.dy.abs()) { - _scrollableAxis = Axis.horizontal; - } else { - _scrollableAxis = Axis.vertical; - } - } - - if (_isHorizontalScrollableWidget && - _scrollableAxis == Axis.horizontal) { - return; - } - - if (_ignoreScrollable) return; - _velocityTracker.addPosition( - e.timeStamp, - e.position, - ); // add current position for velocity tracking - _onGestureSlide(e.delta.dy); - }, - onPointerUp: (PointerUpEvent e) { - if (_ignoreScrollable) return; - _scrollableAxis = null; - _onGestureEnd(_velocityTracker.getVelocity()); - }, - child: child, - ); - } - - double _scMinOffset = 0.0; - - // handles the sliding gesture - void _onGestureSlide(double dy) { - // only slide the panel if scrolling is not enabled - if (widget.controller?._nowTargetForceDraggable == false && - widget.disableDraggableOnScrolling) { - return; - } - if ((!_scrollingEnabled) || - _panelPosition < 1 || - widget.controller?._nowTargetForceDraggable == true) { - if (widget.slideDirection == SlideDirection.up) { - _animationController.value -= - dy / (widget.maxHeight - widget.minHeight); - } else { - _animationController.value += - dy / (widget.maxHeight - widget.minHeight); - } - } - - // if the panel is open and the user hasn't scrolled, we need to determine - // whether to enable scrolling if the user swipes up, or disable closing and - // begin to close the panel if the user swipes down - if (_isPanelOpen && - _scrollController.hasClients && - _scrollController.offset <= _scMinOffset) { - setState(() { - if (dy < 0) { - _scrollingEnabled = true; - } else { - _scrollingEnabled = false; - } - }); - } - } - - // handles when user stops sliding - void _onGestureEnd(Velocity v) { - if (widget.controller?._nowTargetForceDraggable == false && - widget.disableDraggableOnScrolling) { - return; - } - double minFlingVelocity = 365.0; - double kSnap = 8; - - //let the current animation finish before starting a new one - if (_animationController.isAnimating) return; - - // if scrolling is allowed and the panel is open, we don't want to close - // the panel if they swipe up on the scrollable - if (_isPanelOpen && _scrollingEnabled) return; - - //check if the velocity is sufficient to constitute fling to end - double visualVelocity = - -v.pixelsPerSecond.dy / (widget.maxHeight - widget.minHeight); - - // reverse visual velocity to account for slide direction - if (widget.slideDirection == SlideDirection.down) { - visualVelocity = -visualVelocity; - } - - // get minimum distances to figure out where the panel is at - double d2Close = _animationController.value; - double d2Open = 1 - _animationController.value; - double d2Snap = ((widget.snapPoint ?? 3) - _animationController.value) - .abs(); // large value if null results in not every being the min - double minDistance = min(d2Close, min(d2Snap, d2Open)); - - // check if velocity is sufficient for a fling - if (v.pixelsPerSecond.dy.abs() >= minFlingVelocity) { - // snapPoint exists - if (widget.panelSnapping && widget.snapPoint != null) { - if (v.pixelsPerSecond.dy.abs() >= kSnap * minFlingVelocity || - minDistance == d2Snap) { - _animationController.fling(velocity: visualVelocity); - } else { - _flingPanelToPosition(widget.snapPoint!, visualVelocity); - } - - // no snap point exists - } else if (widget.panelSnapping) { - _animationController.fling(velocity: visualVelocity); - - // panel snapping disabled - } else { - _animationController.animateTo( - _animationController.value + visualVelocity * 0.16, - duration: const Duration(milliseconds: 410), - curve: Curves.decelerate, - ); - } - - return; - } - - // check if the controller is already halfway there - if (widget.panelSnapping) { - if (minDistance == d2Close) { - _close(); - } else if (minDistance == d2Snap) { - _flingPanelToPosition(widget.snapPoint!, visualVelocity); - } else { - _open(); - } - } - } - - void _flingPanelToPosition(double targetPos, double velocity) { - final Simulation simulation = SpringSimulation( - SpringDescription.withDampingRatio( - mass: 1.0, - stiffness: 500.0, - ratio: 1.0, - ), - _animationController.value, - targetPos, - velocity); - - _animationController.animateWith(simulation); - } - - //--------------------------------- - //PanelController related functions - //--------------------------------- - - //close the panel - Future _close() { - return _animationController.fling(velocity: -1.0); - } - - //open the panel - Future _open() { - return _animationController.fling(velocity: 1.0); - } - - //hide the panel (completely offscreen) - Future _hide() { - return _animationController.fling(velocity: -1.0).then((x) { - setState(() { - _isPanelVisible = false; - }); - }); - } - - //show the panel (in collapsed mode) - Future _show() { - return _animationController.fling(velocity: -1.0).then((x) { - setState(() { - _isPanelVisible = true; - }); - }); - } - - //animate the panel position to value - must - //be between 0.0 and 1.0 - Future _animatePanelToPosition(double value, - {Duration? duration, Curve curve = Curves.linear}) { - assert(0.0 <= value && value <= 1.0); - return _animationController.animateTo(value, - duration: duration, curve: curve); - } - - //animate the panel position to the snap point - //REQUIRES that widget.snapPoint != null - Future _animatePanelToSnapPoint( - {Duration? duration, Curve curve = Curves.linear}) { - assert(widget.snapPoint != null); - return _animationController.animateTo(widget.snapPoint!, - duration: duration, curve: curve); - } - - //set the panel position to value - must - //be between 0.0 and 1.0 - set _panelPosition(double value) { - assert(0.0 <= value && value <= 1.0); - _animationController.value = value; - } - - //get the current panel position - //returns the % offset from collapsed state - //as a decimal between 0.0 and 1.0 - double get _panelPosition => _animationController.value; - - //returns whether or not - //the panel is still animating - bool get _isPanelAnimating => _animationController.isAnimating; - - //returns whether or not the - //panel is open - bool get _isPanelOpen => _animationController.value == 1.0; - - //returns whether or not the - //panel is closed - bool get _isPanelClosed => _animationController.value == 0.0; - - //returns whether or not the - //panel is shown/hidden - bool get _isPanelShown => _isPanelVisible; -} diff --git a/lib/components/shared/playbutton_card.dart b/lib/components/shared/playbutton_card.dart deleted file mode 100644 index a8a75d30..00000000 --- a/lib/components/shared/playbutton_card.dart +++ /dev/null @@ -1,229 +0,0 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; -import 'package:skeletonizer/skeletonizer.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/hover_builder.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; -import 'package:spotube/hooks/utils/use_brightness_value.dart'; - -final htmlTagRegexp = RegExp(r"<[^>]*>", caseSensitive: true); - -String? useDescription(String? description) { - return useMemoized(() { - if (description == null) return null; - return description.replaceAll(htmlTagRegexp, ''); - }, [description]); -} - -class PlaybuttonCard extends HookWidget { - final void Function()? onTap; - final void Function()? onPlaybuttonPressed; - final void Function()? onAddToQueuePressed; - final String? description; - final EdgeInsetsGeometry? margin; - final String imageUrl; - final bool isPlaying; - final bool isLoading; - final String title; - final bool isOwner; - - const PlaybuttonCard({ - required this.imageUrl, - required this.isPlaying, - required this.isLoading, - required this.title, - this.margin, - this.description, - this.onPlaybuttonPressed, - this.onAddToQueuePressed, - this.onTap, - this.isOwner = false, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final textsKey = useMemoized(() => GlobalKey(), []); - final theme = Theme.of(context); - final mediaQuery = MediaQuery.of(context); - final radius = BorderRadius.circular(15); - - final double size = useBreakpointValue( - xs: 130, - sm: 130, - md: 150, - others: 170, - ); - - final end = useBreakpointValue( - xs: 7, - sm: 7, - others: 15, - ); - - final cleanDescription = useDescription(description); - - return Container( - constraints: BoxConstraints(maxWidth: size), - margin: margin, - child: Material( - color: Color.lerp( - theme.colorScheme.surfaceVariant, - theme.colorScheme.surface, - useBrightnessValue(.9, .7), - ), - borderRadius: radius, - shadowColor: theme.colorScheme.background, - elevation: 3, - child: InkWell( - mouseCursor: SystemMouseCursors.click, - onTap: onTap, - borderRadius: radius, - splashFactory: theme.splashFactory, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Stack( - clipBehavior: Clip.none, - children: [ - Container( - margin: const EdgeInsets.fromLTRB(8, 8, 8, 0), - padding: const EdgeInsets.only( - left: 8, - right: 8, - top: 8, - ), - height: mediaQuery.smAndDown - ? 120 - : mediaQuery.mdAndDown - ? 130 - : 150, - decoration: BoxDecoration( - borderRadius: radius, - image: DecorationImage( - image: UniversalImage.imageProvider(imageUrl), - fit: BoxFit.cover, - ), - ), - ), - if (isOwner) - Positioned( - top: 15, - left: 15, - child: AnimatedSize( - duration: const Duration(milliseconds: 150), - alignment: Alignment.centerLeft, - curve: Curves.easeInExpo, - child: HoverBuilder(builder: (context, isHovered) { - return Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.blueAccent, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - SpotubeIcons.user, - color: Colors.white, - size: 16, - ), - if (isHovered) - Text( - "Owned by you", - style: theme.textTheme.bodySmall?.copyWith( - color: Colors.white, - ), - ), - ], - ), - ); - }), - ), - ), - Positioned( - right: end, - bottom: -15, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!isPlaying) - Skeleton.keep( - child: IconButton( - style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.background, - foregroundColor: theme.colorScheme.primary, - minimumSize: const Size.square(10), - ), - icon: const Icon(SpotubeIcons.queueAdd), - onPressed: isLoading ? null : onAddToQueuePressed, - ), - ), - const Gap(5), - IconButton( - style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.primaryContainer, - foregroundColor: theme.colorScheme.primary, - minimumSize: const Size.square(10), - ), - icon: Skeleton.keep( - child: isLoading - ? SizedBox.fromSize( - size: const Size.square(15), - child: const CircularProgressIndicator( - strokeWidth: 2), - ) - : isPlaying - ? const Icon(SpotubeIcons.pause) - : const Icon(SpotubeIcons.play), - ), - onPressed: isLoading ? null : onPlaybuttonPressed, - ), - ], - ), - ), - ], - ), - Column( - key: textsKey, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 15), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: AutoSizeText( - title, - maxLines: 1, - minFontSize: theme.textTheme.bodyMedium!.fontSize!, - overflow: TextOverflow.ellipsis, - ), - ), - if (cleanDescription != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: AutoSizeText( - cleanDescription, - maxLines: 2, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withOpacity(.5), - ), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(height: 10), - ], - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/components/shared/sort_tracks_dropdown.dart b/lib/components/shared/sort_tracks_dropdown.dart deleted file mode 100644 index ab35b2e3..00000000 --- a/lib/components/shared/sort_tracks_dropdown.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/library/user_local_tracks.dart'; -import 'package:spotube/components/shared/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, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - var theme = Theme.of(context); - return ListTileTheme( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - child: AdaptivePopSheetList( - children: [ - PopSheetEntry( - value: SortBy.none, - enabled: value != SortBy.none, - title: Text(context.l10n.none), - ), - PopSheetEntry( - value: SortBy.ascending, - enabled: value != SortBy.ascending, - title: Text(context.l10n.sort_a_z), - ), - PopSheetEntry( - value: SortBy.descending, - enabled: value != SortBy.descending, - title: Text(context.l10n.sort_z_a), - ), - PopSheetEntry( - value: SortBy.newest, - enabled: value != SortBy.newest, - title: Text(context.l10n.sort_newest), - ), - PopSheetEntry( - value: SortBy.oldest, - enabled: value != SortBy.oldest, - title: Text(context.l10n.sort_oldest), - ), - PopSheetEntry( - value: SortBy.duration, - enabled: value != SortBy.duration, - title: Text(context.l10n.sort_duration), - ), - PopSheetEntry( - value: SortBy.artist, - enabled: value != SortBy.artist, - title: Text(context.l10n.sort_artist), - ), - PopSheetEntry( - value: SortBy.album, - enabled: value != SortBy.album, - title: Text(context.l10n.sort_album), - ), - ], - headings: [ - Text(context.l10n.sort_tracks), - ], - onSelected: onChanged, - tooltip: context.l10n.sort_tracks, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - child: DefaultTextStyle( - style: theme.textTheme.titleSmall!, - child: Row( - children: [ - const Icon(SpotubeIcons.sort), - const SizedBox(width: 8), - Text(context.l10n.sort_tracks), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/components/shared/spotube_page_route.dart b/lib/components/shared/spotube_page_route.dart deleted file mode 100644 index 22e4d2f1..00000000 --- a/lib/components/shared/spotube_page_route.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -class SpotubePage extends MaterialPage { - const SpotubePage({required super.child}); -} - -class SpotubeSlidePage extends CustomTransitionPage { - SpotubeSlidePage({ - required super.child, - super.key, - }) : super( - reverseTransitionDuration: const Duration(milliseconds: 150), - transitionDuration: const Duration(milliseconds: 150), - transitionsBuilder: (context, animation, secondaryAnimation, child) { - return SlideTransition( - position: Tween( - begin: const Offset(1, 0), - end: Offset.zero, - ).animate(animation), - child: child, - ); - }, - ); -} diff --git a/lib/components/shared/themed_button_tab_bar.dart b/lib/components/shared/themed_button_tab_bar.dart deleted file mode 100644 index d5798189..00000000 --- a/lib/components/shared/themed_button_tab_bar.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:buttons_tabbar/buttons_tabbar.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:spotube/hooks/utils/use_brightness_value.dart'; - -class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { - final List tabs; - const ThemedButtonsTabBar({Key? key, required this.tabs}) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final bgColor = useBrightnessValue( - theme.colorScheme.primaryContainer, - Color.lerp(theme.colorScheme.primary, Colors.black, 0.7)!, - ); - - return Padding( - padding: const EdgeInsets.only( - top: 8, - bottom: 8, - ), - child: ButtonsTabBar( - radius: 100, - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(15), - ), - labelStyle: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - ), - borderWidth: 0, - unselectedDecoration: BoxDecoration( - color: theme.colorScheme.background, - borderRadius: BorderRadius.circular(15), - ), - unselectedLabelStyle: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.primary, - ), - tabs: tabs, - ), - ); - } - - @override - Size get preferredSize => const Size.fromHeight(50); -} diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart deleted file mode 100644 index a094259d..00000000 --- a/lib/components/shared/track_tile/track_options.dart +++ /dev/null @@ -1,459 +0,0 @@ -import 'dart:io'; - -import 'package:fl_query/fl_query.dart'; -import 'package:flutter/material.dart' hide Page; -import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/library/user_local_tracks.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; -import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; -import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; -import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; -import 'package:spotube/components/shared/heart_button.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/local_track.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/services/queries/search.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; -import 'package:url_launcher/url_launcher_string.dart'; - -enum TrackOptionValue { - album, - share, - songlink, - addToPlaylist, - addToQueue, - removeFromPlaylist, - removeFromQueue, - blacklist, - delete, - playNext, - favorite, - details, - download, - startRadio, -} - -class TrackOptions extends HookConsumerWidget { - final Track track; - final bool userPlaylist; - final String? playlistId; - final ObjectRef?>? showMenuCbRef; - final Widget? icon; - const TrackOptions({ - Key? key, - required this.track, - this.showMenuCbRef, - this.userPlaylist = false, - this.playlistId, - this.icon, - }) : super(key: key); - - void actionShare(BuildContext context, Track track) { - final data = "https://open.spotify.com/track/${track.id}"; - Clipboard.setData(ClipboardData(text: data)).then((_) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - context.l10n.copied_to_clipboard(data), - textAlign: TextAlign.center, - ), - ), - ); - }); - } - - void actionAddToPlaylist( - BuildContext context, - Track track, - ) { - showDialog( - context: context, - builder: (context) => PlaylistAddTrackDialog( - tracks: [track], - openFromPlaylist: playlistId, - ), - ); - } - - void actionStartRadio( - BuildContext context, - WidgetRef ref, - Track track, - ) async { - final playback = ref.read(ProxyPlaylistNotifier.notifier); - final playlist = ref.read(ProxyPlaylistNotifier.provider); - final spotify = ref.read(spotifyProvider); - final query = "${track.name} Radio"; - final pages = await QueryClient.of(context) - .fetchInfiniteQueryJob, dynamic, int, SearchParams>( - job: SearchQueries.queryJob(query), - args: ( - spotify: spotify, - searchType: SearchType.playlist, - query: query, - ), - ) ?? - []; - - final radios = pages - .expand((e) => e.items?.toList() ?? []) - .toList() - .cast(); - - final artists = track.artists!.map((e) => e.name); - - final radio = radios.firstWhere( - (e) { - final validPlaylists = - artists.where((a) => e.description!.contains(a!)); - return e.name == "${track.name} Radio" && - (validPlaylists.length >= 2 || - validPlaylists.length == artists.length) && - e.owner?.displayName == "Spotify"; - }, - orElse: () => radios.first, - ); - - 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); - } - - final tracks = - await spotify.playlists.getTracksByPlaylistId(radio.id!).all(); - - await playback.addTracks( - tracks.toList() - ..removeWhere((e) { - final isDuplicate = playlist.tracks.any((t) => t.id == e.id); - return e.id == track.id || isDuplicate; - }), - ); - } - - @override - Widget build(BuildContext context, ref) { - final scaffoldMessenger = ScaffoldMessenger.of(context); - final mediaQuery = MediaQuery.of(context); - final router = GoRouter.of(context); - final ThemeData(:colorScheme) = Theme.of(context); - - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playback = ref.watch(ProxyPlaylistNotifier.notifier); - final auth = ref.watch(AuthenticationNotifier.provider); - ref.watch(downloadManagerProvider); - final downloadManager = ref.watch(downloadManagerProvider.notifier); - final blacklist = ref.watch(BlackListNotifier.provider); - - final favorites = useTrackToggleLike(track, ref); - - final isBlackListed = useMemoized( - () => blacklist.contains( - BlacklistedElement.track( - track.id!, - track.name!, - ), - ), - [blacklist, track], - ); - - final removingTrack = useState(null); - final removeTrack = useMutations.playlist.removeTrackOf( - ref, - playlistId ?? "", - ); - - final isInQueue = useMemoized(() { - if (playlist.activeTrack == null) return false; - return downloadManager.isActive(playlist.activeTrack!); - }, [ - playlist.activeTrack, - downloadManager, - ]); - - final progressNotifier = useMemoized(() { - final spotubeTrack = downloadManager.mapToSourcedTrack(track); - if (spotubeTrack == null) return null; - return downloadManager.getProgressNotifier(spotubeTrack); - }); - - final adaptivePopSheetList = AdaptivePopSheetList( - onSelected: (value) async { - switch (value) { - case TrackOptionValue.album: - await router.push( - '/album/${track.album!.id}', - extra: track.album!, - ); - break; - case TrackOptionValue.delete: - await File((track as LocalTrack).path).delete(); - ref.refresh(localTracksProvider); - break; - case TrackOptionValue.addToQueue: - await playback.addTrack(track); - if (context.mounted) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.added_track_to_queue(track.name!), - ), - ), - ); - } - break; - case TrackOptionValue.playNext: - playback.addTracksAtFirst([track]); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.track_will_play_next(track.name!), - ), - ), - ); - break; - case TrackOptionValue.removeFromQueue: - playback.removeTrack(track.id!); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.removed_track_from_queue( - track.name!, - ), - ), - ), - ); - break; - case TrackOptionValue.favorite: - favorites.toggleTrackLike.mutate(favorites.isLiked); - break; - case TrackOptionValue.addToPlaylist: - actionAddToPlaylist(context, track); - break; - case TrackOptionValue.removeFromPlaylist: - removingTrack.value = track.uri; - removeTrack.mutate(track.uri!); - break; - case TrackOptionValue.blacklist: - if (isBlackListed) { - ref.read(BlackListNotifier.provider.notifier).remove( - BlacklistedElement.track(track.id!, track.name!), - ); - } else { - ref.read(BlackListNotifier.provider.notifier).add( - BlacklistedElement.track(track.id!, track.name!), - ); - } - break; - case TrackOptionValue.share: - actionShare(context, track); - break; - case TrackOptionValue.songlink: - final url = "https://song.link/s/${track.id}"; - await launchUrlString(url); - break; - case TrackOptionValue.details: - showDialog( - context: context, - builder: (context) => TrackDetailsDialog(track: track), - ); - break; - case TrackOptionValue.download: - await downloadManager.addToQueue(track); - break; - case TrackOptionValue.startRadio: - actionStartRadio(context, ref, track); - break; - } - }, - icon: icon ?? const Icon(SpotubeIcons.moreHorizontal), - headings: [ - ListTile( - dense: true, - leading: AspectRatio( - aspectRatio: 1, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString(track.album!.images, - placeholder: ImagePlaceholder.albumArt), - fit: BoxFit.cover, - ), - ), - ), - title: Text( - track.name!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), - subtitle: Align( - alignment: Alignment.centerLeft, - child: TypeConversionUtils.artists_X_ClickableArtists( - track.artists!, - ), - ), - ), - ], - children: switch (track.runtimeType) { - LocalTrack => [ - PopSheetEntry( - value: TrackOptionValue.delete, - leading: const Icon(SpotubeIcons.trash), - title: Text(context.l10n.delete), - ) - ], - _ => [ - if (mediaQuery.smAndDown) - PopSheetEntry( - value: TrackOptionValue.album, - leading: const Icon(SpotubeIcons.album), - title: Text(context.l10n.go_to_album), - subtitle: Text(track.album!.name!), - ), - if (!playlist.containsTrack(track)) ...[ - PopSheetEntry( - value: TrackOptionValue.addToQueue, - leading: const Icon(SpotubeIcons.queueAdd), - title: Text(context.l10n.add_to_queue), - ), - PopSheetEntry( - value: TrackOptionValue.playNext, - leading: const Icon(SpotubeIcons.lightning), - title: Text(context.l10n.play_next), - ), - ] else - PopSheetEntry( - value: TrackOptionValue.removeFromQueue, - enabled: playlist.activeTrack?.id != track.id, - leading: const Icon(SpotubeIcons.queueRemove), - title: Text(context.l10n.remove_from_queue), - ), - if (favorites.me.hasData) - PopSheetEntry( - value: TrackOptionValue.favorite, - leading: favorites.isLiked - ? const Icon( - SpotubeIcons.heartFilled, - color: Colors.pink, - ) - : const Icon(SpotubeIcons.heart), - title: Text( - favorites.isLiked - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - ), - ), - if (auth != null) ...[ - PopSheetEntry( - value: TrackOptionValue.startRadio, - leading: const Icon(SpotubeIcons.radio), - title: Text(context.l10n.start_a_radio), - ), - PopSheetEntry( - value: TrackOptionValue.addToPlaylist, - leading: const Icon(SpotubeIcons.playlistAdd), - title: Text(context.l10n.add_to_playlist), - ), - ], - if (userPlaylist && auth != null) - PopSheetEntry( - value: TrackOptionValue.removeFromPlaylist, - leading: (removeTrack.isMutating || !removeTrack.hasData) && - removingTrack.value == track.uri - ? const CircularProgressIndicator() - : const Icon(SpotubeIcons.removeFilled), - title: Text(context.l10n.remove_from_playlist), - ), - PopSheetEntry( - value: TrackOptionValue.download, - enabled: !isInQueue, - leading: isInQueue - ? HookBuilder(builder: (context) { - final progress = useListenable(progressNotifier!); - return CircularProgressIndicator( - value: progress.value, - ); - }) - : const Icon(SpotubeIcons.download), - title: Text(context.l10n.download_track), - ), - PopSheetEntry( - value: TrackOptionValue.blacklist, - leading: const Icon(SpotubeIcons.playlistRemove), - iconColor: !isBlackListed ? Colors.red[400] : null, - textColor: !isBlackListed ? Colors.red[400] : null, - title: Text( - isBlackListed - ? context.l10n.remove_from_blacklist - : context.l10n.add_to_blacklist, - ), - ), - PopSheetEntry( - value: TrackOptionValue.share, - leading: const Icon(SpotubeIcons.share), - title: Text(context.l10n.share), - ), - PopSheetEntry( - value: TrackOptionValue.songlink, - leading: Assets.logos.songlinkTransparent.image( - width: 22, - height: 22, - color: colorScheme.onSurface.withOpacity(0.5), - ), - title: Text(context.l10n.song_link), - ), - PopSheetEntry( - value: TrackOptionValue.details, - leading: const Icon(SpotubeIcons.info), - title: Text(context.l10n.details), - ), - ] - }, - ); - - //! This is the most ANTI pattern I've ever done, but it works - showMenuCbRef?.value = (relativeRect) { - adaptivePopSheetList.showPopupMenu(context, relativeRect); - }; - - return ListTileTheme( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - child: adaptivePopSheetList, - ); - } -} diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart deleted file mode 100644 index d268c783..00000000 --- a/lib/components/shared/track_tile/track_tile.dart +++ /dev/null @@ -1,270 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/hover_builder.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/links/link_text.dart'; -import 'package:spotube/components/shared/track_tile/track_options.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/duration.dart'; -import 'package:spotube/models/local_track.dart'; -import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class TrackTile extends HookConsumerWidget { - /// [index] will not be shown if null - final int? index; - final Track track; - final bool selected; - final ValueChanged? onChanged; - final Future Function()? onTap; - final VoidCallback? onLongPress; - final bool userPlaylist; - final String? playlistId; - - final List? leadingActions; - - const TrackTile({ - Key? key, - this.index, - required this.track, - this.selected = false, - this.onTap, - this.onLongPress, - this.onChanged, - this.userPlaylist = false, - this.playlistId, - this.leadingActions, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final theme = Theme.of(context); - - final blacklist = ref.watch(BlackListNotifier.provider); - - final isBlackListed = useMemoized( - () => blacklist.contains( - BlacklistedElement.track( - track.id!, - track.name!, - ), - ), - [blacklist, track], - ); - - final showOptionCbRef = useRef?>(null); - - final isPlaying = track.id == playlist.activeTrack?.id; - - final isLoading = useState(false); - - final isSelected = isPlaying || isLoading.value; - - return LayoutBuilder(builder: (context, constrains) { - return Listener( - onPointerDown: (event) { - if (event.buttons != kSecondaryMouseButton) return; - showOptionCbRef.value?.call( - RelativeRect.fromLTRB( - event.position.dx, - event.position.dy, - constrains.maxWidth - event.position.dx, - constrains.maxHeight - event.position.dy, - ), - ); - }, - child: HoverBuilder( - permanentState: isSelected || constrains.smAndDown ? true : null, - builder: (context, isHovering) { - return ListTile( - selected: isSelected, - onTap: () async { - try { - isLoading.value = true; - await onTap?.call(); - } finally { - if (context.mounted) { - isLoading.value = false; - } - } - }, - onLongPress: onLongPress, - enabled: !isBlackListed, - contentPadding: EdgeInsets.zero, - tileColor: - isBlackListed ? theme.colorScheme.errorContainer : null, - horizontalTitleGap: 12, - leadingAndTrailingTextStyle: theme.textTheme.bodyMedium, - leading: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ...?leadingActions, - if (index != null && onChanged == null && constrains.mdAndUp) - SizedBox( - width: 50, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Text( - '${(index ?? 0) + 1}', - maxLines: 1, - style: theme.textTheme.bodySmall, - textAlign: TextAlign.center, - ), - ), - ) - else if (constrains.smAndDown) - const SizedBox(width: 16), - if (onChanged != null) - Checkbox( - value: selected, - onChanged: onChanged, - ), - Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: AspectRatio( - aspectRatio: 1, - child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString( - track.album?.images, - placeholder: ImagePlaceholder.albumArt, - ), - fit: BoxFit.cover, - ), - ), - ), - Positioned.fill( - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - color: isHovering - ? Colors.black.withOpacity(0.4) - : Colors.transparent, - ), - ), - ), - Positioned.fill( - child: Center( - child: IconTheme( - data: theme.iconTheme - .copyWith(size: 26, color: Colors.white), - child: Skeleton.ignore( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: (isPlaying && playlist.isFetching) || - isLoading.value - ? const SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: Colors.white, - ), - ) - : isPlaying - ? Icon( - SpotubeIcons.pause, - color: theme.colorScheme.primary, - ) - : !isHovering - ? const SizedBox.shrink() - : const Icon(SpotubeIcons.play), - ), - ), - ), - ), - ), - ], - ), - ], - ), - title: Row( - children: [ - Expanded( - flex: 6, - child: LinkText( - track.name!, - "/track/${track.id}", - push: true, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (constrains.mdAndUp) ...[ - const SizedBox(width: 8), - Expanded( - flex: 4, - child: switch (track.runtimeType) { - LocalTrack => Text( - track.album!.name!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - _ => Align( - alignment: Alignment.centerLeft, - child: LinkText( - track.album!.name!, - "/album/${track.album?.id}", - extra: track.album, - push: true, - overflow: TextOverflow.ellipsis, - ), - ) - }, - ), - ], - ], - ), - subtitle: Align( - alignment: Alignment.centerLeft, - child: track is LocalTrack - ? Text( - TypeConversionUtils.artists_X_String( - track.artists ?? [], - ), - ) - : ClipRect( - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 40), - child: TypeConversionUtils.artists_X_ClickableArtists( - track.artists ?? [], - ), - ), - ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 8), - Text( - Duration(milliseconds: track.durationMs ?? 0) - .toHumanReadableString(padZero: false), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - TrackOptions( - track: track, - playlistId: playlistId, - userPlaylist: userPlaylist, - showMenuCbRef: showOptionCbRef, - ), - ], - ), - ); - }, - ), - ); - }); - } -} diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body.dart b/lib/components/shared/tracks_view/sections/body/track_view_body.dart deleted file mode 100644 index 33c8fa82..00000000 --- a/lib/components/shared/tracks_view/sections/body/track_view_body.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:fuzzywuzzy/fuzzywuzzy.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; -import 'package:spotube/components/shared/track_tile/track_tile.dart'; -import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart'; -import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:very_good_infinite_list/very_good_infinite_list.dart'; - -class TrackViewBodySection extends HookConsumerWidget { - const TrackViewBodySection({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final props = InheritedTrackView.of(context); - final trackViewState = ref.watch(trackViewProvider(props.tracks)); - - final searchController = useTextEditingController(); - final searchFocus = useFocusNode(); - - useValueListenable(searchController); - final searchQuery = searchController.text; - - final isFiltering = useState(false); - - final uniqTracks = useMemoized(() { - final trackIds = props.tracks.map((e) => e.id).toSet(); - return props.tracks.where((e) => trackIds.remove(e.id)).toList(); - }, [props.tracks]); - - final tracks = useMemoized(() { - List filteredTracks; - if (searchQuery.isEmpty) { - filteredTracks = uniqTracks; - } else { - filteredTracks = uniqTracks - .map((e) => (weightedRatio(e.name!, searchQuery), e)) - .sorted((a, b) => b.$1.compareTo(a.$1)) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList(); - } - return ServiceUtils.sortTracks(filteredTracks, trackViewState.sortBy); - }, [trackViewState.sortBy, searchQuery, uniqTracks]); - - final isUserPlaylist = useIsUserPlaylist(ref, props.collectionId); - - final isActive = playlist.collections.contains(props.collectionId); - - return SliverMainAxisGroup( - slivers: [ - SliverToBoxAdapter( - child: TrackViewBodyHeaders( - isFiltering: isFiltering, - searchFocus: searchFocus, - ), - ), - const SliverGap(8), - SliverToBoxAdapter( - child: ExpandableSearchField( - isFiltering: isFiltering.value, - onChangeFiltering: (value) { - isFiltering.value = value; - }, - searchController: searchController, - searchFocus: searchFocus, - ), - ), - SliverSafeArea( - top: false, - sliver: SliverInfiniteList( - itemCount: tracks.length, - onFetchData: props.pagination.onFetchMore, - isLoading: props.pagination.isLoading, - hasReachedMax: !props.pagination.hasNextPage, - loadingBuilder: (context) => Skeletonizer( - enabled: true, - child: TrackTile( - track: FakeData.track, - index: 0, - ), - ), - emptyBuilder: (context) => Skeletonizer( - enabled: true, - child: Column( - children: List.generate( - 10, - (index) => TrackTile(track: FakeData.track, index: index), - ), - ), - ), - itemBuilder: (context, index) { - final track = tracks[index]; - return TrackTile( - track: track, - index: index, - selected: trackViewState.selectedTrackIds.contains(track.id!), - playlistId: props.collectionId, - userPlaylist: isUserPlaylist, - onChanged: !trackViewState.isSelecting - ? null - : (value) { - trackViewState.toggleTrackSelection(track.id!); - }, - onLongPress: () { - trackViewState.selectTrack(track.id!); - HapticFeedback.selectionClick(); - }, - onTap: () async { - if (trackViewState.isSelecting) { - trackViewState.toggleTrackSelection(track.id!); - return; - } - - if (isActive || playlist.tracks.contains(track)) { - await playlistNotifier.jumpToTrack(track); - } else { - final tracks = await props.pagination.onFetchAll(); - await playlistNotifier.load( - tracks, - initialIndex: index, - autoPlay: true, - ); - playlistNotifier.addCollection(props.collectionId); - } - }, - ); - }, - ), - ), - ], - ); - } -} diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart b/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart deleted file mode 100644 index 7e4522a0..00000000 --- a/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; -import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; -import 'package:spotube/components/shared/tracks_view/sections/body/track_view_options.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; - -class TrackViewBodyHeaders extends HookConsumerWidget { - final ValueNotifier isFiltering; - final FocusNode searchFocus; - - const TrackViewBodyHeaders({ - Key? key, - required this.isFiltering, - required this.searchFocus, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final ThemeData(:textTheme) = Theme.of(context); - final props = InheritedTrackView.of(context); - final trackViewState = ref.watch(trackViewProvider(props.tracks)); - return LayoutBuilder( - builder: (context, constrains) { - return Row( - children: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: ScaleTransition( - scale: animation, - child: child, - ), - ); - }, - child: Checkbox( - value: trackViewState.hasSelectedAll, - onChanged: (checked) { - if (checked == true) { - trackViewState.selectAll(); - } else { - trackViewState.deselectAll(); - } - }, - ), - ), - Expanded( - flex: 7, - child: Row( - children: [ - Text( - context.l10n.title, - style: textTheme.bodyLarge, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - // used alignment of this table-head - if (constrains.mdAndUp) - Expanded( - flex: 3, - child: Row( - children: [ - Text( - context.l10n.album, - overflow: TextOverflow.ellipsis, - style: textTheme.bodyLarge, - ), - ], - ), - ), - SortTracksDropdown( - value: trackViewState.sortBy, - onChanged: (value) { - trackViewState.sort(value); - }, - ), - ExpandableSearchButton( - isFiltering: isFiltering.value, - searchFocus: searchFocus, - onPressed: (value) { - isFiltering.value = value; - if (value) { - searchFocus.requestFocus(); - } else { - searchFocus.unfocus(); - } - }, - ), - const TrackViewBodyOptions(), - ], - ); - }, - ); - } -} diff --git a/lib/components/shared/tracks_view/sections/body/track_view_options.dart b/lib/components/shared/tracks_view/sections/body/track_view_options.dart deleted file mode 100644 index 583c9107..00000000 --- a/lib/components/shared/tracks_view/sections/body/track_view_options.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; -import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart'; -import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; - -class TrackViewBodyOptions extends HookConsumerWidget { - const TrackViewBodyOptions({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final props = InheritedTrackView.of(context); - final ThemeData(:textTheme) = Theme.of(context); - - ref.watch(downloadManagerProvider); - final downloader = ref.watch(downloadManagerProvider.notifier); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final audioSource = - ref.watch(userPreferencesProvider.select((s) => s.audioSource)); - - final trackViewState = ref.watch(trackViewProvider(props.tracks)); - final selectedTracks = trackViewState.selectedTracks; - - return AdaptivePopSheetList( - tooltip: context.l10n.more_actions, - headings: [ - Text( - context.l10n.more_actions, - style: textTheme.bodyLarge, - ), - ], - onSelected: (action) async { - switch (action) { - case "download": - { - final confirmed = audioSource == AudioSource.piped || - await showDialog( - context: context, - builder: (context) { - return const ConfirmDownloadDialog(); - }, - ); - if (confirmed != true) return; - await downloader.batchAddToQueue(selectedTracks); - trackViewState.deselectAll(); - break; - } - case "add-to-playlist": - { - if (context.mounted) { - await showDialog( - context: context, - builder: (context) { - return PlaylistAddTrackDialog( - openFromPlaylist: props.collectionId, - tracks: selectedTracks.toList(), - ); - }, - ); - } - break; - } - case "play-next": - { - playlistNotifier.addTracksAtFirst(selectedTracks); - playlistNotifier.addCollection(props.collectionId); - trackViewState.deselectAll(); - break; - } - case "add-to-queue": - { - playlistNotifier.addTracks(selectedTracks); - playlistNotifier.addCollection(props.collectionId); - trackViewState.deselectAll(); - break; - } - default: - } - }, - icon: const Icon(SpotubeIcons.moreVertical), - children: [ - PopSheetEntry( - value: "download", - leading: const Icon(SpotubeIcons.download), - enabled: selectedTracks.isNotEmpty, - title: Text( - context.l10n.download_count(selectedTracks.length), - ), - ), - PopSheetEntry( - value: "add-to-playlist", - leading: const Icon(SpotubeIcons.playlistAdd), - enabled: selectedTracks.isNotEmpty, - title: Text( - context.l10n.add_count_to_playlist(selectedTracks.length), - ), - ), - PopSheetEntry( - enabled: selectedTracks.isNotEmpty, - value: "add-to-queue", - leading: const Icon(SpotubeIcons.queueAdd), - title: Text( - context.l10n.add_count_to_queue(selectedTracks.length), - ), - ), - PopSheetEntry( - enabled: selectedTracks.isNotEmpty, - value: "play-next", - leading: const Icon(SpotubeIcons.lightning), - title: Text( - context.l10n.play_count_next(selectedTracks.length), - ), - ), - ], - ); - } -} diff --git a/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart b/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart deleted file mode 100644 index ca3c6706..00000000 --- a/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/services/queries/queries.dart'; - -bool useIsUserPlaylist(WidgetRef ref, String playlistId) { - final userPlaylistsQuery = useQueries.playlist.ofMineAll(ref); - final me = useQueries.user.me(ref); - - return useMemoized( - () => - userPlaylistsQuery.data?.any((e) => - e.id == playlistId && - me.data != null && - e.owner?.id == me.data?.id) ?? - false, - [userPlaylistsQuery.data, playlistId, me.data], - ); -} diff --git a/lib/components/shared/tracks_view/sections/header/flexible_header.dart b/lib/components/shared/tracks_view/sections/header/flexible_header.dart deleted file mode 100644 index 19241dc6..00000000 --- a/lib/components/shared/tracks_view/sections/header/flexible_header.dart +++ /dev/null @@ -1,166 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/playbutton_card.dart'; -import 'package:spotube/components/shared/tracks_view/sections/header/header_actions.dart'; -import 'package:spotube/components/shared/tracks_view/sections/header/header_buttons.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; -import 'package:gap/gap.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/hooks/utils/use_palette_color.dart'; - -class TrackViewFlexHeader extends HookConsumerWidget { - const TrackViewFlexHeader({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final props = InheritedTrackView.of(context); - final ThemeData(:colorScheme, :textTheme, :iconTheme) = Theme.of(context); - final defaultTextStyle = DefaultTextStyle.of(context); - final mediaQuery = MediaQuery.of(context); - - final description = useDescription(props.description); - - final palette = usePaletteColor(props.image, ref); - - return IconTheme( - data: iconTheme.copyWith(color: palette.bodyTextColor), - child: SliverLayoutBuilder( - builder: (context, constrains) { - final isExpanded = constrains.scrollOffset < 350; - - final headingStyle = (mediaQuery.mdAndDown - ? textTheme.headlineSmall - : textTheme.headlineMedium) - ?.copyWith( - color: palette.bodyTextColor, - ); - return SliverAppBar( - iconTheme: iconTheme.copyWith( - color: palette.bodyTextColor, - size: 16, - ), - actions: isExpanded - ? [] - : [ - const TrackViewHeaderActions(), - TrackViewHeaderButtons(compact: true, color: palette), - ], - floating: false, - pinned: true, - expandedHeight: 450, - automaticallyImplyLeading: DesktopTools.platform.isMobile, - backgroundColor: palette.color, - title: isExpanded ? null : Text(props.title, style: headingStyle), - flexibleSpace: FlexibleSpaceBar( - background: Container( - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - image: DecorationImage( - image: UniversalImage.imageProvider(props.image), - fit: BoxFit.cover, - ), - ), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.black45, - colorScheme.surface, - ], - begin: const FractionalOffset(0, 0), - end: const FractionalOffset(0, 1), - tileMode: TileMode.clamp, - ), - ), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: mediaQuery.mdAndDown - ? mediaQuery.size.width - : 800, - ), - child: Flex( - direction: mediaQuery.mdAndDown - ? Axis.vertical - : Axis.horizontal, - mainAxisSize: MainAxisSize.min, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: UniversalImage( - path: props.image, - width: 200, - height: 200, - placeholder: Assets.albumPlaceholder.path, - ), - ), - const Gap(20), - Flexible( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: mediaQuery.mdAndDown - ? CrossAxisAlignment.center - : CrossAxisAlignment.start, - children: [ - Text( - props.title, - style: headingStyle, - textAlign: mediaQuery.mdAndDown - ? TextAlign.center - : TextAlign.start, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 10), - if (description != null && - description.isNotEmpty) - Text( - description, - style: - defaultTextStyle.style.copyWith( - color: palette.bodyTextColor, - ), - textAlign: mediaQuery.mdAndDown - ? TextAlign.center - : TextAlign.start, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const Gap(10), - const TrackViewHeaderActions(), - const Gap(10), - TrackViewHeaderButtons(color: palette), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), - ), - ), - ); - }, - ), - ); - } -} diff --git a/lib/components/shared/tracks_view/sections/header/header_actions.dart b/lib/components/shared/tracks_view/sections/header/header_actions.dart deleted file mode 100644 index 75aa3f61..00000000 --- a/lib/components/shared/tracks_view/sections/header/header_actions.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/playlist/playlist_create_dialog.dart'; -import 'package:spotube/components/shared/heart_button.dart'; -import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; - -class TrackViewHeaderActions extends HookConsumerWidget { - const TrackViewHeaderActions({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final props = InheritedTrackView.of(context); - - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - - final isActive = playlist.collections.contains(props.collectionId); - - final isUserPlaylist = useIsUserPlaylist(ref, props.collectionId); - - final scaffoldMessenger = ScaffoldMessenger.of(context); - - final auth = ref.watch(AuthenticationNotifier.provider); - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - tooltip: context.l10n.share, - icon: const Icon(SpotubeIcons.share), - onPressed: () async { - await Clipboard.setData( - ClipboardData(text: props.shareUrl), - ); - - scaffoldMessenger.showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - "Copied ${props.shareUrl} to clipboard", - textAlign: TextAlign.center, - ), - ), - ); - }, - ), - IconButton( - icon: const Icon(SpotubeIcons.queueAdd), - tooltip: context.l10n.add_to_queue, - onPressed: isActive || props.tracks.isEmpty - ? null - : () async { - final tracks = await props.pagination.onFetchAll(); - await playlistNotifier.addTracks(tracks); - playlistNotifier.addCollection(props.collectionId); - }, - ), - if (props.onHeart != null && auth != null) - HeartButton( - isLiked: props.isLiked, - icon: isUserPlaylist ? SpotubeIcons.trash : null, - tooltip: props.isLiked - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - onPressed: () async { - final shouldPop = await props.onHeart?.call(); - if (isUserPlaylist && shouldPop == true && context.mounted) { - context.pop(); - } - }, - ), - if (isUserPlaylist) - IconButton( - icon: const Icon(SpotubeIcons.edit), - onPressed: () { - showDialog( - context: context, - builder: (context) { - return PlaylistCreateDialog( - playlistId: props.collectionId, - trackIds: props.tracks.map((e) => e.id!).toList(), - ); - }, - ); - }, - ), - ], - ); - } -} diff --git a/lib/components/shared/tracks_view/sections/header/header_buttons.dart b/lib/components/shared/tracks_view/sections/header/header_buttons.dart deleted file mode 100644 index bae47f12..00000000 --- a/lib/components/shared/tracks_view/sections/header/header_buttons.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:palette_generator/palette_generator.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; - -class TrackViewHeaderButtons extends HookConsumerWidget { - final PaletteColor color; - final bool compact; - const TrackViewHeaderButtons({ - Key? key, - required this.color, - this.compact = false, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final props = InheritedTrackView.of(context); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - - final isActive = playlist.collections.contains(props.collectionId); - - final isLoading = useState(false); - - const progressIndicator = Center( - child: SizedBox.square( - dimension: 20, - child: CircularProgressIndicator(strokeWidth: .8), - ), - ); - - void onShuffle() async { - try { - isLoading.value = true; - - final allTracks = await props.pagination.onFetchAll(); - - await playlistNotifier.load( - allTracks, - autoPlay: true, - initialIndex: Random().nextInt(allTracks.length), - ); - await audioPlayer.setShuffle(true); - playlistNotifier.addCollection(props.collectionId); - } finally { - isLoading.value = false; - } - } - - void onPlay() async { - try { - isLoading.value = true; - - final allTracks = await props.pagination.onFetchAll(); - - await playlistNotifier.load(allTracks, autoPlay: true); - playlistNotifier.addCollection(props.collectionId); - } finally { - isLoading.value = false; - } - } - - if (compact) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (!isActive && !isLoading.value) - IconButton( - icon: const Icon(SpotubeIcons.shuffle), - onPressed: props.tracks.isEmpty ? null : onShuffle, - ), - const Gap(10), - IconButton.filledTonal( - icon: isActive - ? const Icon(SpotubeIcons.pause) - : isLoading.value - ? progressIndicator - : const Icon(SpotubeIcons.play), - onPressed: isActive || props.tracks.isEmpty || isLoading.value - ? null - : onPlay, - ), - const Gap(10), - ], - ); - } - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedOpacity( - duration: const Duration(milliseconds: 300), - opacity: isActive || isLoading.value ? 0 : 1, - child: AnimatedSize( - duration: const Duration(milliseconds: 300), - child: SizedBox.square( - dimension: isActive || isLoading.value ? 0 : null, - child: FilledButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - minimumSize: const Size(150, 40)), - label: Text(context.l10n.shuffle), - icon: const Icon(SpotubeIcons.shuffle), - onPressed: props.tracks.isEmpty ? null : onShuffle, - ), - ), - ), - ), - const Gap(10), - FilledButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: color.color, - foregroundColor: color.bodyTextColor, - minimumSize: const Size(150, 40)), - onPressed: isActive || props.tracks.isEmpty || isLoading.value - ? null - : onPlay, - icon: isActive - ? const Icon(SpotubeIcons.pause) - : isLoading.value - ? progressIndicator - : const Icon(SpotubeIcons.play), - label: Text(context.l10n.play), - ), - ], - ); - } -} diff --git a/lib/components/shared/tracks_view/track_view.dart b/lib/components/shared/tracks_view/track_view.dart deleted file mode 100644 index 4103573c..00000000 --- a/lib/components/shared/tracks_view/track_view.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:sliver_tools/sliver_tools.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/shared/tracks_view/sections/header/flexible_header.dart'; -import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; - -class TrackView extends HookConsumerWidget { - const TrackView({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final props = InheritedTrackView.of(context); - final controller = useScrollController(); - - return Scaffold( - appBar: DesktopTools.platform.isDesktop - ? const PageWindowTitleBar( - backgroundColor: Colors.transparent, - foregroundColor: Colors.white, - leadingWidth: 400, - leading: Align( - alignment: Alignment.centerLeft, - child: BackButton(color: Colors.white), - ), - ) - : null, - extendBodyBehindAppBar: true, - body: RefreshIndicator( - onRefresh: props.pagination.onRefresh, - child: InterScrollbar( - controller: controller, - child: CustomScrollView( - controller: controller, - slivers: const [ - TrackViewFlexHeader(), - SliverAnimatedSwitcher( - duration: Duration(milliseconds: 500), - child: TrackViewBodySection(), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/components/shared/tracks_view/track_view_props.dart b/lib/components/shared/tracks_view/track_view_props.dart deleted file mode 100644 index 21bbaec7..00000000 --- a/lib/components/shared/tracks_view/track_view_props.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'dart:async'; - -import 'package:fl_query/fl_query.dart'; -import 'package:flutter/material.dart' hide Page; -import 'package:spotify/spotify.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, - }); - - factory PaginationProps.fromQuery( - InfiniteQuery, dynamic, int> query, { - required Future> Function() onFetchAll, - }) { - return PaginationProps( - hasNextPage: query.hasNextPage, - isLoading: query.isLoadingNextPage, - onFetchMore: query.fetchNext, - onFetchAll: onFetchAll, - onRefresh: query.refreshAll, - ); - } - - @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 InheritedTrackView extends InheritedWidget { - final String collectionId; - final String title; - final String? description; - final String image; - final String routePath; - final List tracks; - final PaginationProps pagination; - final bool isLiked; - final String shareUrl; - - // events - final FutureOr Function()? onHeart; // if null heart button will hidden - - const InheritedTrackView({ - super.key, - required super.child, - required this.collectionId, - required this.title, - this.description, - required this.image, - required this.tracks, - required this.pagination, - required this.routePath, - required this.shareUrl, - this.isLiked = false, - this.onHeart, - }); - - @override - bool updateShouldNotify(InheritedTrackView oldWidget) { - return oldWidget.title != title || - oldWidget.description != description || - oldWidget.image != image || - oldWidget.tracks != tracks || - oldWidget.pagination != pagination || - oldWidget.isLiked != isLiked || - oldWidget.onHeart != onHeart || - oldWidget.shareUrl != shareUrl || - oldWidget.routePath != routePath || - oldWidget.collectionId != collectionId || - oldWidget.child != child; - } - - static InheritedTrackView of(BuildContext context) { - final widget = - context.dependOnInheritedWidgetOfExactType(); - if (widget == null) { - throw Exception( - 'InheritedTrackView not found. Make sure to wrap [TrackView] with [InheritedTrackView]', - ); - } - return widget; - } -} diff --git a/lib/components/shared/tracks_view/track_view_provider.dart b/lib/components/shared/tracks_view/track_view_provider.dart deleted file mode 100644 index 14dc1136..00000000 --- a/lib/components/shared/tracks_view/track_view_provider.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/library/user_local_tracks.dart'; - -class TrackViewNotifier extends ChangeNotifier { - List tracks; - List selectedTrackIds; - SortBy sortBy; - String? searchQuery; - - TrackViewNotifier( - this.tracks, { - this.selectedTrackIds = const [], - this.sortBy = SortBy.none, - this.searchQuery, - }); - - bool get isSelecting => selectedTrackIds.isNotEmpty; - - bool get hasSelectedAll => - selectedTrackIds.length == tracks.length && tracks.isNotEmpty; - - List get selectedTracks => - tracks.where((e) => selectedTrackIds.contains(e.id)).toList(); - - void selectTrack(String trackId) { - selectedTrackIds = [...selectedTrackIds, trackId]; - notifyListeners(); - } - - void unselectTrack(String trackId) { - selectedTrackIds = selectedTrackIds.where((e) => e != trackId).toList(); - notifyListeners(); - } - - void toggleTrackSelection(String trackId) { - if (selectedTrackIds.contains(trackId)) { - unselectTrack(trackId); - } else { - selectTrack(trackId); - } - } - - void selectAll() { - selectedTrackIds = tracks.map((e) => e.id!).toList(); - notifyListeners(); - } - - void deselectAll() { - selectedTrackIds = []; - notifyListeners(); - } - - void sort(SortBy sortBy) { - this.sortBy = sortBy; - notifyListeners(); - } -} - -final trackViewProvider = ChangeNotifierProvider.autoDispose - .family>((ref, tracks) { - return TrackViewNotifier(tracks); -}); diff --git a/lib/components/shared/shimmers/shimmer_lyrics.dart b/lib/components/shimmers/shimmer_lyrics.dart similarity index 87% rename from lib/components/shared/shimmers/shimmer_lyrics.dart rename to lib/components/shimmers/shimmer_lyrics.dart index b225c008..9312865e 100644 --- a/lib/components/shared/shimmers/shimmer_lyrics.dart +++ b/lib/components/shimmers/shimmer_lyrics.dart @@ -1,11 +1,10 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; import 'package:skeletonizer/skeletonizer.dart'; class ShimmerLyrics extends HookWidget { - const ShimmerLyrics({Key? key}) : super(key: key); + const ShimmerLyrics({super.key}); @override Widget build(BuildContext context) { diff --git a/lib/components/titlebar/titlebar.dart b/lib/components/titlebar/titlebar.dart new file mode 100644 index 00000000..778f0b09 --- /dev/null +++ b/lib/components/titlebar/titlebar.dart @@ -0,0 +1,135 @@ +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 new file mode 100644 index 00000000..30d88508 --- /dev/null +++ b/lib/components/titlebar/titlebar_buttons.dart @@ -0,0 +1,119 @@ +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 new file mode 100644 index 00000000..0a3f6178 --- /dev/null +++ b/lib/components/titlebar/titlebar_icon_buttons.dart @@ -0,0 +1,155 @@ +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 new file mode 100644 index 00000000..bbeb90a5 --- /dev/null +++ b/lib/components/track_presentation/presentation_actions.dart @@ -0,0 +1,231 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotify/spotify.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/database/database.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'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; + +class TrackPresentationActionsSection extends HookConsumerWidget { + const TrackPresentationActionsSection({super.key}); + + 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), + }; + + 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(); + }, + ), + ), + ); + }, + ); + } + + @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 audioSource = + ref.watch(userPreferencesProvider.select((s) => s.audioSource)); + + 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 confirmed = audioSource == AudioSource.piped || + (await showDialog( + context: context, + builder: (context) { + return const ConfirmDownloadDialog(); + }, + ) ?? + false); + if (confirmed != true) return; + downloader.batchAddToQueue(tracks); + notifier.deselectAllTracks(); + if (!context.mounted) return; + showToastForAction(context, action, tracks.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 AlbumSimple) { + historyNotifier.addAlbums([options.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([options.collection as PlaylistSimple]); + } + 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 AlbumSimple) { + historyNotifier.addAlbums([options.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([options.collection as PlaylistSimple]); + } + 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 new file mode 100644 index 00000000..dda7dffa --- /dev/null +++ b/lib/components/track_presentation/presentation_list.dart @@ -0,0 +1,111 @@ +import 'package:flutter/services.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/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) { + 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) { + final track = state.presentationTracks[index]; + final isSelected = state.selectedTracks.any((e) => e.id == track.id); + 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 new file mode 100644 index 00000000..42c3cb4f --- /dev/null +++ b/lib/components/track_presentation/presentation_modifiers.dart @@ -0,0 +1,131 @@ +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 new file mode 100644 index 00000000..144cf0e8 --- /dev/null +++ b/lib/components/track_presentation/presentation_props.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotify/spotify.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; + + // 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, + }) : assert(collection is AlbumSimple || collection is PlaylistSimple); + + String get collectionId => collection is AlbumSimple + ? (collection as AlbumSimple).id! + : (collection as PlaylistSimple).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; + } + + @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; +} diff --git a/lib/components/track_presentation/presentation_state.dart b/lib/components/track_presentation/presentation_state.dart new file mode 100644 index 00000000..d3428861 --- /dev/null +++ b/lib/components/track_presentation/presentation_state.dart @@ -0,0 +1,173 @@ +import 'package:collection/collection.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart'; +import 'package:spotube/provider/spotify/spotify.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 PlaylistSimple() || AlbumSimple()) { + if (isSavedTrackPlaylist) { + ref.listen( + likedTracksProvider, + (previous, next) { + next.whenData((value) { + state = state.copyWith( + presentationTracks: ServiceUtils.sortTracks( + value, + state.sortBy, + ), + ); + }); + }, + ); + } else { + ref.listen( + arg is PlaylistSimple + ? playlistTracksProvider((arg as PlaylistSimple).id!) + : albumTracksProvider((arg as AlbumSimple)), + (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 PlaylistSimple && + (arg as PlaylistSimple).id == "user-liked-tracks"; + + List get tracks { + assert( + arg is PlaylistSimple || arg is AlbumSimple, + "arg must be PlaylistSimple or AlbumSimple", + ); + + final isPlaylist = arg is PlaylistSimple; + + final tracks = switch ((isPlaylist, isSavedTrackPlaylist)) { + (true, true) => ref.read(likedTracksProvider).asData?.value, + (true, false) => ref + .read(playlistTracksProvider((arg as PlaylistSimple).id!)) + .asData + ?.value + .items, + _ => ref + .read(albumTracksProvider((arg as AlbumSimple))) + .asData + ?.value + .items, + } ?? + []; + + return tracks; + } + + void selectTrack(Track 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(Track 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 new file mode 100644 index 00000000..5935fa13 --- /dev/null +++ b/lib/components/track_presentation/presentation_top.dart @@ -0,0 +1,278 @@ +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:spotify/spotify.dart'; +import 'package:spotube/collections/env.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'; +import 'package:spotube/provider/spotify/spotify.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 playlistImage = (options.collection is PlaylistSimple && + (options.collection as PlaylistSimple).owner?.displayName == + "Spotify" && + Env.disableSpotifyImages) + ? ref.watch(playlistImageProvider(options.collectionId)) + : null; + final decorationImage = playlistImage != null + ? DecorationImage( + image: AssetImage(playlistImage.src), + fit: BoxFit.cover, + colorFilter: ColorFilter.mode( + playlistImage.color, + playlistImage.colorBlendMode, + ), + ) + : DecorationImage( + image: UniversalImage.imageProvider(options.image), + fit: BoxFit.cover, + ); + + final imageDimension = mediaQuery.mdAndUp ? 200 : 120; + + final (:isLoading, :isActive, :onPlay, :onShuffle) = + 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: () {}, + ), + ) + else + Button.secondary( + leading: const Icon(SpotubeIcons.add), + enabled: !isLoading && !isActive, + child: Text(context.l10n.queue), + onPressed: () {}, + ), + 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!, + ), + ) + : 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 new file mode 100644 index 00000000..0a07cbad --- /dev/null +++ b/lib/components/track_presentation/sort_tracks_dropdown.dart @@ -0,0 +1,70 @@ +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 new file mode 100644 index 00000000..47089bd6 --- /dev/null +++ b/lib/components/track_presentation/track_presentation.dart @@ -0,0 +1,98 @@ +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), + SliverLayoutBuilder( + builder: (context, constrains) { + return SliverList.list( + children: [ + TrackPresentationModifiersSection( + focusNode: focusNode, + ), + 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 new file mode 100644 index 00000000..0012594a --- /dev/null +++ b/lib/components/track_presentation/use_action_callbacks.dart @@ -0,0 +1,137 @@ +import 'dart:math'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/dialogs/select_device_dialog.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; + +import 'package:spotube/models/connect/connect.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'; + +typedef UseActionCallbacks = ({ + bool isActive, + bool isLoading, + Future Function() onShuffle, + Future Function() onPlay, +}); + +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 AlbumSimple + ? WebSocketLoadEventData.album( + tracks: allTracks, + collection: options.collection as AlbumSimple, + initialIndex: Random().nextInt(allTracks.length)) + : WebSocketLoadEventData.playlist( + tracks: allTracks, + collection: options.collection as PlaylistSimple, + 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 AlbumSimple) { + historyNotifier.addAlbums([options.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists([options.collection as PlaylistSimple]); + } + + final allTracks = await options.pagination.onFetchAll(); + + await playlistNotifier.addTracks( + allTracks.sublist(initialTracks.length), + ); + } + } 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 AlbumSimple + ? WebSocketLoadEventData.album( + tracks: allTracks, + collection: options.collection as AlbumSimple, + ) + : WebSocketLoadEventData.playlist( + tracks: allTracks, + collection: options.collection as PlaylistSimple, + ), + ); + } else { + await playlistNotifier.load(initialTracks, autoPlay: true); + playlistNotifier.addCollection(options.collectionId); + if (options.collection is AlbumSimple) { + historyNotifier.addAlbums([options.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists([options.collection as PlaylistSimple]); + } + + final allTracks = await options.pagination.onFetchAll(); + + await playlistNotifier.addTracks( + allTracks.sublist(initialTracks.length), + ); + } + } finally { + if (context.mounted) { + isLoading.value = false; + } + } + }, [options, playlistNotifier, historyNotifier]); + + return ( + isActive: isActive, + isLoading: isLoading.value, + onShuffle: onShuffle, + onPlay: onPlay, + ); +} diff --git a/lib/components/track_presentation/use_is_user_playlist.dart b/lib/components/track_presentation/use_is_user_playlist.dart new file mode 100644 index 00000000..2f87ccc8 --- /dev/null +++ b/lib/components/track_presentation/use_is_user_playlist.dart @@ -0,0 +1,18 @@ +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +bool useIsUserPlaylist(WidgetRef ref, String playlistId) { + final userPlaylistsQuery = ref.watch(favoritePlaylistsProvider); + final me = ref.watch(meProvider); + + 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 new file mode 100644 index 00000000..b519f781 --- /dev/null +++ b/lib/components/track_presentation/use_track_tile_play_callback.dart @@ -0,0 +1,89 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotify/spotify.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/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/history/history.dart'; + +Future Function(Track 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((Track 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 AlbumSimple + ? WebSocketLoadEventData.album( + tracks: tracks, + collection: options.collection as AlbumSimple, + initialIndex: index, + ) + : WebSocketLoadEventData.playlist( + tracks: tracks, + collection: options.collection as PlaylistSimple, + 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 AlbumSimple) { + historyNotifier.addAlbums([options.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists([options.collection as PlaylistSimple]); + } + } + } + }, [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 new file mode 100644 index 00000000..a0738165 --- /dev/null +++ b/lib/components/track_tile/track_options.dart @@ -0,0 +1,501 @@ +import 'dart:io'; + +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:spotify/spotify.dart' hide Offset; +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/adaptive/adaptive_pop_sheet_list.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/components/heart_button/use_track_toggle_like.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/links/artist_link.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/authentication/authentication.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/audio_player/audio_player.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +import 'package:url_launcher/url_launcher_string.dart'; + +enum TrackOptionValue { + album, + share, + songlink, + addToPlaylist, + addToQueue, + removeFromPlaylist, + removeFromQueue, + blacklist, + delete, + playNext, + favorite, + details, + download, + startRadio, +} + +class TrackOptions extends HookConsumerWidget { + final Track track; + final bool userPlaylist; + final String? playlistId; + final ObjectRef?>? showMenuCbRef; + final Widget? icon; + const TrackOptions({ + super.key, + required this.track, + this.showMenuCbRef, + this.userPlaylist = false, + this.playlistId, + this.icon, + }); + + void actionShare(BuildContext context, Track track) { + final data = "https://open.spotify.com/track/${track.id}"; + Clipboard.setData(ClipboardData(text: data)).then((_) { + if (context.mounted) { + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + child: Text( + context.l10n.copied_to_clipboard(data), + textAlign: TextAlign.center, + ), + ); + }, + ); + } + }); + } + + void actionAddToPlaylist( + BuildContext context, + Track track, + ) { + /// showDialog doesn't work for some reason. So we have to + /// manually push a Dialog Route in the Navigator to get it working + showDialog( + context: context, + builder: (context) { + return PlaylistAddTrackDialog( + tracks: [track], + openFromPlaylist: playlistId, + ); + }, + ); + } + + void actionStartRadio( + BuildContext context, + WidgetRef ref, + Track track, + ) async { + final playback = ref.read(audioPlayerProvider.notifier); + final playlist = ref.read(audioPlayerProvider); + final spotify = ref.read(spotifyProvider); + final query = "${track.name} Radio"; + final pages = await spotify.invoke( + (api) => api.search.get(query, types: [SearchType.playlist]).first(), + ); + + final radios = pages + .expand((e) => e.items?.cast().toList() ?? []) + .toList(); + + final artists = track.artists!.map((e) => e.name); + + final radio = radios.firstWhere( + (e) { + final validPlaylists = + artists.where((a) => e.description!.contains(a!)); + return e.name == "${track.name} Radio" && + (validPlaylists.length >= 2 || + validPlaylists.length == artists.length) && + e.owner?.displayName == "Spotify"; + }, + orElse: () => radios.first, + ); + + 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); + } + + final tracks = await spotify.invoke( + (api) => api.playlists.getTracksByPlaylistId(radio.id!).all(), + ); + + await playback.addTracks( + tracks.toList() + ..removeWhere((e) { + final isDuplicate = playlist.tracks.any((t) => t.id == e.id); + return e.id == track.id || isDuplicate; + }), + ); + } + + @override + Widget build(BuildContext context, ref) { + final mediaQuery = MediaQuery.of(context); + final ThemeData(:colorScheme) = Theme.of(context); + + final playlist = ref.watch(audioPlayerProvider); + final playback = ref.watch(audioPlayerProvider.notifier); + final auth = ref.watch(authenticationProvider); + ref.watch(downloadManagerProvider); + final downloadManager = ref.watch(downloadManagerProvider.notifier); + final blacklist = ref.watch(blacklistProvider); + final me = ref.watch(meProvider); + + final favorites = useTrackToggleLike(track, ref); + + final isBlackListed = useMemoized( + () => blacklist.asData?.value.any( + (element) => element.elementId == track.id, + ), + [blacklist, track], + ); + + final removingTrack = useState(null); + final favoritePlaylistsNotifier = + ref.watch(favoritePlaylistsProvider.notifier); + + final isInQueue = useMemoized(() { + if (playlist.activeTrack == null) return false; + return downloadManager.isActive(playlist.activeTrack!); + }, [ + playlist.activeTrack, + downloadManager, + ]); + + final progressNotifier = useMemoized(() { + final spotubeTrack = downloadManager.mapToSourcedTrack(track); + if (spotubeTrack == null) return null; + return downloadManager.getProgressNotifier(spotubeTrack); + }); + + final isLocalTrack = track is LocalTrack; + + final adaptivePopSheetList = AdaptivePopSheetList( + tooltip: context.l10n.more_actions, + onSelected: (value) 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 LocalTrack).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: + 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: + favorites.toggleTrackLike(track); + break; + case TrackOptionValue.addToPlaylist: + actionAddToPlaylist(context, track); + break; + case TrackOptionValue.removeFromPlaylist: + removingTrack.value = track.uri; + favoritePlaylistsNotifier + .removeTracks(playlistId ?? "", [track.id!]); + break; + case TrackOptionValue.blacklist: + if (isBlackListed == null) break; + 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, track); + break; + case TrackOptionValue.songlink: + final url = "https://song.link/s/${track.id}"; + await launchUrlString(url); + break; + case TrackOptionValue.details: + showDialog( + context: context, + builder: (context) => ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: TrackDetailsDialog(track: track), + ), + ); + break; + case TrackOptionValue.download: + await downloadManager.addToQueue(track); + break; + case TrackOptionValue.startRadio: + actionStartRadio(context, ref, track); + break; + } + }, + icon: icon ?? const Icon(SpotubeIcons.moreHorizontal), + variance: ButtonVariance.outline, + headings: [ + Basic( + leading: AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: UniversalImage( + path: track.album!.images + .asUrlString(placeholder: ImagePlaceholder.albumArt), + fit: BoxFit.cover, + ), + ), + ), + 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!), + ), + ), + ), + ), + ], + items: (context) => [ + if (isLocalTrack) + AdaptiveMenuButton( + value: TrackOptionValue.delete, + leading: const Icon(SpotubeIcons.trash), + child: Text(context.l10n.delete), + ), + if (mediaQuery.smAndDown && !isLocalTrack) + AdaptiveMenuButton( + value: TrackOptionValue.album, + leading: const Icon(SpotubeIcons.album), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.go_to_album), + Text( + track.album!.name!, + style: context.theme.typography.xSmall, + ), + ], + ), + ), + if (!playlist.containsTrack(track)) ...[ + AdaptiveMenuButton( + value: TrackOptionValue.addToQueue, + leading: const Icon(SpotubeIcons.queueAdd), + child: Text(context.l10n.add_to_queue), + ), + AdaptiveMenuButton( + value: TrackOptionValue.playNext, + leading: const Icon(SpotubeIcons.lightning), + child: Text(context.l10n.play_next), + ), + ] else + AdaptiveMenuButton( + value: TrackOptionValue.removeFromQueue, + enabled: playlist.activeTrack?.id != track.id, + leading: const Icon(SpotubeIcons.queueRemove), + child: Text(context.l10n.remove_from_queue), + ), + if (me.asData?.value != null && !isLocalTrack) + AdaptiveMenuButton( + value: TrackOptionValue.favorite, + leading: favorites.isLiked + ? const Icon( + SpotubeIcons.heartFilled, + color: Colors.pink, + ) + : const Icon(SpotubeIcons.heart), + child: Text( + favorites.isLiked + ? context.l10n.remove_from_favorites + : context.l10n.save_as_favorite, + ), + ), + if (auth.asData?.value != null && !isLocalTrack) ...[ + AdaptiveMenuButton( + value: TrackOptionValue.startRadio, + leading: const Icon(SpotubeIcons.radio), + child: Text(context.l10n.start_a_radio), + ), + AdaptiveMenuButton( + value: TrackOptionValue.addToPlaylist, + leading: const Icon(SpotubeIcons.playlistAdd), + child: Text(context.l10n.add_to_playlist), + ), + ], + if (userPlaylist && auth.asData?.value != null && !isLocalTrack) + AdaptiveMenuButton( + value: TrackOptionValue.removeFromPlaylist, + leading: const Icon(SpotubeIcons.removeFilled), + child: Text(context.l10n.remove_from_playlist), + ), + if (!isLocalTrack) + AdaptiveMenuButton( + value: TrackOptionValue.download, + enabled: !isInQueue, + leading: isInQueue + ? HookBuilder(builder: (context) { + final progress = useListenable(progressNotifier!); + return CircularProgressIndicator( + value: progress.value, + ); + }) + : const Icon(SpotubeIcons.download), + child: Text(context.l10n.download_track), + ), + if (!isLocalTrack) + AdaptiveMenuButton( + value: TrackOptionValue.blacklist, + leading: Icon( + SpotubeIcons.playlistRemove, + color: isBlackListed != true ? Colors.red[400] : null, + ), + child: Text( + isBlackListed == true + ? context.l10n.remove_from_blacklist + : context.l10n.add_to_blacklist, + style: TextStyle( + color: isBlackListed != true ? Colors.red[400] : null, + ), + ), + ), + if (!isLocalTrack) + AdaptiveMenuButton( + value: TrackOptionValue.share, + leading: const Icon(SpotubeIcons.share), + child: Text(context.l10n.share), + ), + if (!isLocalTrack) + AdaptiveMenuButton( + value: TrackOptionValue.songlink, + leading: Assets.logos.songlinkTransparent.image( + width: 22, + height: 22, + color: colorScheme.foreground.withValues(alpha: 0.5), + ), + child: Text(context.l10n.song_link), + ), + if (!isLocalTrack) + AdaptiveMenuButton( + value: TrackOptionValue.details, + leading: const Icon(SpotubeIcons.info), + child: Text(context.l10n.details), + ), + ], + ); + + //! This is the most ANTI pattern I've ever done, but it works + showMenuCbRef?.value = (relativeRect) { + final offsetFromRect = Offset( + relativeRect.left, + relativeRect.top, + ); + adaptivePopSheetList.showDropdownMenu(context, offsetFromRect); + }; + + return adaptivePopSheetList; + } +} diff --git a/lib/components/track_tile/track_tile.dart b/lib/components/track_tile/track_tile.dart new file mode 100644 index 00000000..f47980cd --- /dev/null +++ b/lib/components/track_tile/track_tile.dart @@ -0,0 +1,318 @@ +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' hide Consumer; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotify/spotify.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.dart'; +import 'package:spotube/components/ui/button_tile.dart'; +import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/duration.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/models/local_track.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'; + +class TrackTile extends HookConsumerWidget { + /// [index] will not be shown if null + final int? index; + final Track track; + final bool selected; + 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, + 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 blacklist = ref.watch(blacklistProvider); + final blacklistNotifier = ref.watch(blacklistProvider.notifier); + + final isBlackListed = useMemoized( + () => blacklistNotifier.contains(track), + [blacklist, track], + ); + + final showOptionCbRef = useRef?>(null); + + final isLoading = useState(false); + + final isPlaying = playlist.activeTrack?.id == track.id; + + final isSelected = isPlaying || isLoading.value; + + return LayoutBuilder(builder: (context, constrains) { + return Listener( + onPointerDown: (event) { + if (event.buttons != kSecondaryMouseButton) return; + showOptionCbRef.value?.call( + RelativeRect.fromLTRB( + event.position.dx, + event.position.dy, + constrains.maxWidth - event.position.dx, + constrains.maxHeight - event.position.dy, + ), + ); + }, + 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: UniversalImage.imageProvider( + (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + ), + ), + ), + ), + 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: switch (track) { + LocalTrack() => 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: () { + 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) { + LocalTrack() => 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 LocalTrack + ? Text( + track.artists?.asString() ?? '', + ) + : ClipRect( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 40), + child: ArtistLink( + artists: track.artists ?? [], + onOverflowArtistClick: () { + context.navigateTo( + TrackRoute(trackId: track.id!), + ); + }, + ), + ), + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 8), + Text( + Duration(milliseconds: track.durationMs ?? 0) + .toHumanReadableString(padZero: false), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + TrackOptions( + track: track, + playlistId: playlistId, + userPlaylist: userPlaylist, + showMenuCbRef: showOptionCbRef, + ), + if (kIsDesktop) const Gap(10), + ], + ), + ), + ), + ); + }); + } +} diff --git a/lib/components/ui/button_tile.dart b/lib/components/ui/button_tile.dart new file mode 100644 index 00000000..e31a09a5 --- /dev/null +++ b/lib/components/ui/button_tile.dart @@ -0,0 +1,109 @@ +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/shared/waypoint.dart b/lib/components/waypoint.dart similarity index 85% rename from lib/components/shared/waypoint.dart rename to lib/components/waypoint.dart index abd9f98d..cf00e29b 100644 --- a/lib/components/shared/waypoint.dart +++ b/lib/components/waypoint.dart @@ -11,17 +11,15 @@ class Waypoint extends HookWidget { final bool isGrid; const Waypoint({ - Key? key, + super.key, required this.controller, this.isGrid = false, this.onTouchEdge, this.child, - }) : super(key: key); + }); @override Widget build(BuildContext context) { - final isMounted = useIsMounted(); - useEffect(() { if (isGrid) { return null; @@ -32,19 +30,19 @@ class Waypoint extends HookWidget { // scrollController fetches the next paginated data when the current // position of the user on the screen has surpassed - if (controller.position.pixels >= nextPageTrigger && isMounted()) { + if (controller.position.pixels >= nextPageTrigger && context.mounted) { await onTouchEdge?.call(); } } WidgetsBinding.instance.addPostFrameCallback((_) { - if (controller.hasClients && isMounted()) { + if (controller.hasClients && context.mounted) { listener(); controller.addListener(listener); } }); return () => controller.removeListener(listener); - }, [controller, onTouchEdge, isMounted]); + }, [controller, onTouchEdge]); if (isGrid) { return VisibilityDetector( diff --git a/lib/extensions/album_simple.dart b/lib/extensions/album_simple.dart index 00db4dca..5678390c 100644 --- a/lib/extensions/album_simple.dart +++ b/lib/extensions/album_simple.dart @@ -1,18 +1,21 @@ import 'package:spotify/spotify.dart'; -extension AlbumJson on AlbumSimple { - Map toJson() { - return { - "albumType": albumType?.name, - "id": id, - "name": name, - "images": images - ?.map((image) => { - "height": image.height, - "url": image.url, - "width": image.width, - }) - .toList(), - }; +extension AlbumExtensions on AlbumSimple { + Album toAlbum() { + Album album = Album(); + album.albumType = albumType; + album.artists = artists; + album.availableMarkets = availableMarkets; + album.externalUrls = externalUrls; + album.href = href; + album.id = id; + album.images = images; + album.name = name; + album.releaseDate = releaseDate; + album.releaseDatePrecision = releaseDatePrecision; + album.tracks = tracks; + album.type = type; + album.uri = uri; + return album; } } diff --git a/lib/extensions/artist_simple.dart b/lib/extensions/artist_simple.dart index caf2e510..7997355d 100644 --- a/lib/extensions/artist_simple.dart +++ b/lib/extensions/artist_simple.dart @@ -1,13 +1,7 @@ import 'package:spotify/spotify.dart'; -extension ArtistJson on ArtistSimple { - Map toJson() { - return { - "href": href, - "id": id, - "name": name, - "type": type, - "uri": uri, - }; +extension ArtistExtension on List { + String asString() { + return map((e) => e.name?.replaceAll(",", " ")).join(", "); } } diff --git a/lib/extensions/button_variance.dart b/lib/extensions/button_variance.dart new file mode 100644 index 00000000..cf66d528 --- /dev/null +++ b/lib/extensions/button_variance.dart @@ -0,0 +1,21 @@ +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 index 68cd8ef7..bc7d65a2 100644 --- a/lib/extensions/color.dart +++ b/lib/extensions/color.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; extension ColorAlterer on Color { Color darken(double amount) { diff --git a/lib/extensions/constrains.dart b/lib/extensions/constrains.dart index 1177f5ac..b7353c4f 100644 --- a/lib/extensions/constrains.dart +++ b/lib/extensions/constrains.dart @@ -1,6 +1,20 @@ 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, @@ -22,6 +36,15 @@ extension SliverBreakpoints on SliverConstraints { 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; @@ -45,6 +68,15 @@ extension ContainerBreakpoints on BoxConstraints { 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; @@ -74,3 +106,22 @@ extension ScreenBreakpoints on MediaQueryData { 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 index 9ca1e237..29fbb7ca 100644 --- a/lib/extensions/context.dart +++ b/lib/extensions/context.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +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/image.dart b/lib/extensions/image.dart new file mode 100644 index 00000000..ee78653a --- /dev/null +++ b/lib/extensions/image.dart @@ -0,0 +1,34 @@ +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/utils/primitive_utils.dart'; +import 'package:collection/collection.dart'; + +enum ImagePlaceholder { + albumArt, + artist, + collection, + online, +} + +extension SpotifyImageExtensions on List? { + String asUrlString({ + int index = 1, + required ImagePlaceholder placeholder, + }) { + final String placeholderUrl = { + ImagePlaceholder.albumArt: Assets.albumPlaceholder.path, + ImagePlaceholder.artist: Assets.userPlaceholder.path, + ImagePlaceholder.collection: Assets.placeholder.path, + ImagePlaceholder.online: + "https://avatars.dicebear.com/api/bottts/${PrimitiveUtils.uuid.v4()}.png", + }[placeholder]!; + + final sortedImage = this?.sorted((a, b) => a.width!.compareTo(b.width!)); + + return sortedImage != null && sortedImage.isNotEmpty + ? sortedImage[ + index > sortedImage.length - 1 ? sortedImage.length - 1 : index] + .url! + : placeholderUrl; + } +} diff --git a/lib/extensions/infinite_query.dart b/lib/extensions/infinite_query.dart deleted file mode 100644 index 2181ab3c..00000000 --- a/lib/extensions/infinite_query.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:spotify/spotify.dart'; - -extension FetchAllTracks on InfiniteQuery, dynamic, int> { - Future> fetchAllTracks({ - required Future> Function() getAllTracks, - }) async { - if (pages.isNotEmpty && !hasNextPage) { - return pages.expand((page) => page).toList(); - } - final tracks = await getAllTracks(); - - final numOfPages = (tracks.length / 20).round(); - - final Map> pagedTracks = {}; - - for (var i = 0; i < numOfPages; i++) { - if (i == numOfPages - 1) { - final pageTracks = tracks.sublist(i * 20); - pagedTracks[i] = pageTracks; - break; - } - - final pageTracks = tracks.sublist(i * 20, (i + 1) * 20); - pagedTracks[i] = pageTracks; - } - - for (final group in pagedTracks.entries) { - setPageData(group.key, group.value); - } - - return tracks.toList(); - } -} diff --git a/lib/extensions/list.dart b/lib/extensions/list.dart index 6ecf6cf6..ddd36e4d 100644 --- a/lib/extensions/list.dart +++ b/lib/extensions/list.dart @@ -1,99 +1,19 @@ -import 'package:collection/collection.dart'; -import 'package:spotube/models/logger.dart'; +extension UniqueItemExtension on List { + List unique(bool Function(T a, T b) equals) { + final copy = []; -final logger = getLogger("List"); - -extension MultiSortListMap on List { - /// [preference] - List of properties in which you want to sort the list - /// i.e. - /// ``` - /// List preference = ['property1','property2']; - /// ``` - /// This will first sort the list by property1 then by property2 - /// - /// [criteria] - List of booleans that specifies the criteria of sort - /// i.e., For ascending order `true` and for descending order `false`. - /// ``` - /// List criteria = [true. false]; - /// ``` - List sortByProperties(List criteria, List preference) { - if (preference.isEmpty || criteria.isEmpty || isEmpty) { - return this; - } - if (preference.length != criteria.length) { - logger.d('Criteria length is not equal to preference'); - return this; + for (final item in this) { + if (copy.any((element) => equals(element, item))) continue; + copy.add(item); } - int compare(int i, Map a, Map b) { - if (a[preference[i]] == b[preference[i]]) { - return 0; - } else if (a[preference[i]] > b[preference[i]]) { - return criteria[i] ? 1 : -1; - } else { - return criteria[i] ? -1 : 1; - } - } + return copy; + } - int sortAll(Map a, Map b) { - int i = 0; - int result = 0; - while (i < preference.length) { - result = compare(i, a, b); - if (result != 0) break; - i++; - } - return result; + bool containsBy(T item, dynamic Function(T a) fn) { + for (final el in this) { + if (fn(el) == fn(item)) return true; } - - return sorted((a, b) => sortAll(a, b)); - } -} - -extension MultiSortListTupleMap on List<(Map, V)> { - /// [preference] - List of properties in which you want to sort the list - /// i.e. - /// ``` - /// List preference = ['property1','property2']; - /// ``` - /// This will first sort the list by property1 then by property2 - /// - /// [criteria] - List of booleans that specifies the criteria of sort - /// i.e., For ascending order `true` and for descending order `false`. - /// ``` - /// List criteria = [true. false]; - /// ``` - List<(Map, V)> sortByProperties( - List criteria, List preference) { - if (preference.isEmpty || criteria.isEmpty || isEmpty) { - return this; - } - if (preference.length != criteria.length) { - logger.d('Criteria length is not equal to preference'); - return this; - } - - int compare(int i, (Map, V) a, (Map, V) b) { - if (a.$1[preference[i]] == b.$1[preference[i]]) { - return 0; - } else if (a.$1[preference[i]] > b.$1[preference[i]]) { - return criteria[i] ? 1 : -1; - } else { - return criteria[i] ? -1 : 1; - } - } - - int sortAll((Map, V) a, (Map, V) b) { - int i = 0; - int result = 0; - while (i < preference.length) { - result = compare(i, a, b); - if (result != 0) break; - i++; - } - return result; - } - - return sorted((a, b) => sortAll(a, b)); + return false; } } diff --git a/lib/extensions/page.dart b/lib/extensions/page.dart deleted file mode 100644 index 34343fb5..00000000 --- a/lib/extensions/page.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:spotify/spotify.dart'; - -extension CursorPageJson on CursorPage { - static CursorPage fromJson( - Map json, - T Function(dynamic json) itemFromJson, - ) { - final metadata = Paging.fromJson(json["metadata"]); - final paging = CursorPaging(); - paging.cursors = Cursor.fromJson(json["metadata"])..after = json["after"]; - paging.href = metadata.href; - paging.itemsNative = paging.itemsNative; - paging.limit = metadata.limit; - paging.next = metadata.next; - return CursorPage( - paging, - itemFromJson, - ); - } - - Map toJson() { - return { - "after": after, - "metadata": metadata.toJson(), - }; - } -} - -extension PagingToJson on Paging { - Map toJson() { - return { - "items": itemsNative, - "total": total, - "next": next, - "previous": previous, - "limit": limit, - "offset": offset, - "href": href, - }; - } -} - -extension PageJson on Page { - static Page fromJson( - Map json, - T Function(dynamic json) itemFromJson, - ) { - return Page( - Paging.fromJson( - Map.castFrom(json["metadata"]), - ), - itemFromJson, - ); - } - - Map toJson() { - return { - "metadata": metadata.toJson(), - }; - } -} diff --git a/lib/extensions/string.dart b/lib/extensions/string.dart index 0aa41dc6..94123fe3 100644 --- a/lib/extensions/string.dart +++ b/lib/extensions/string.dart @@ -1,13 +1,16 @@ 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? unescapeHtml() => this == null ? null : htmlEscape.convert(this!); + String? cleanHtml() => this?.cleanHtml(); + String? unescapeHtml() => this?.unescapeHtml(); } extension StringExtension on String { diff --git a/lib/extensions/theme.dart b/lib/extensions/theme.dart deleted file mode 100644 index 22a1ce84..00000000 --- a/lib/extensions/theme.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/material.dart'; - -class ShimmerColorTheme extends ThemeExtension { - final Color? shimmerColor; - final Color? shimmerBackgroundColor; - - ShimmerColorTheme({ - this.shimmerBackgroundColor, - this.shimmerColor, - }); - - @override - ThemeExtension copyWith( - {Color? shimmerColor, Color? shimmerBackgroundColor}) { - return ShimmerColorTheme( - shimmerBackgroundColor: - shimmerBackgroundColor ?? this.shimmerBackgroundColor, - shimmerColor: shimmerColor ?? this.shimmerColor, - ); - } - - @override - ThemeExtension lerp( - ThemeExtension? other, double t) { - if (other is! ShimmerColorTheme) { - return this; - } - return ShimmerColorTheme( - shimmerBackgroundColor: - Color.lerp(shimmerBackgroundColor, other.shimmerBackgroundColor, t), - shimmerColor: Color.lerp(shimmerColor, other.shimmerColor, t), - ); - } -} diff --git a/lib/extensions/track.dart b/lib/extensions/track.dart index 51498b33..92d8b0da 100644 --- a/lib/extensions/track.dart +++ b/lib/extensions/track.dart @@ -1,32 +1,113 @@ -import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/album_simple.dart'; -import 'package:spotube/extensions/artist_simple.dart'; +import 'dart:io'; +import 'dart:typed_data'; -extension TrackJson on Track { - Map toJson() { - return TrackJson.trackToJson(this); +import 'package:metadata_god/metadata_god.dart'; +import 'package:path/path.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/logger/logger.dart'; + +extension TrackExtensions on Track { + Track fromFile( + File file, { + Metadata? metadata, + String? art, + }) { + album = Album() + ..name = metadata?.album ?? "Unknown" + ..images = [if (art != null) Image()..url = art] + ..genres = [if (metadata?.genre != null) metadata!.genre!] + ..artists = [ + Artist() + ..name = metadata?.albumArtist ?? "Unknown" + ..id = metadata?.albumArtist ?? "Unknown" + ..type = "artist", + ] + ..id = metadata?.album + ..releaseDate = metadata?.year?.toString(); + artists = [ + Artist() + ..name = metadata?.artist ?? "Unknown" + ..id = metadata?.artist ?? "Unknown" + ]; + + id = metadata?.title ?? basenameWithoutExtension(file.path); + name = metadata?.title ?? basenameWithoutExtension(file.path); + type = "track"; + uri = file.path; + durationMs = (metadata?.durationMs?.toInt() ?? 0); + + return this; } - static Map trackToJson(Track track) { - return { - "album": track.album?.toJson(), - "artists": track.artists?.map((artist) => artist.toJson()).toList(), - "available_markets": track.availableMarkets?.map((e) => e.name).toList(), - "disc_number": track.discNumber, - "duration_ms": track.durationMs, - "explicit": track.explicit, - // "external_ids"track.: externalIds, - // "external_urls"track.: externalUrls, - "href": track.href, - "id": track.id, - "is_playable": track.isPlayable, - // "linked_from"track.: linkedFrom, - "name": track.name, - "popularity": track.popularity, - "preview_rrl": track.previewUrl, - "track_number": track.trackNumber, - "type": track.type, - "uri": track.uri, - }; + Metadata toMetadata({ + required int fileLength, + Uint8List? imageBytes, + }) { + 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 + ? int.tryParse(album!.releaseDate!.split("-").first) ?? 1969 + : 1969, + trackNumber: trackNumber, + discNumber: discNumber, + durationMs: durationMs?.toDouble() ?? 0.0, + fileSize: BigInt.from(fileLength), + trackTotal: album?.tracks?.length ?? 0, + picture: imageBytes != null + ? Picture( + data: imageBytes, + // Spotify images are always JPEGs + mimeType: 'image/jpeg', + ) + : null, + ); + } +} + +extension IterableTrackSimpleExtensions on Iterable { + Future> asTracks(AlbumSimple album, ref) async { + try { + final spotify = ref.read(spotifyProvider); + final tracks = await spotify.invoke( + (api) => api.tracks.list(map((trackSimple) => trackSimple.id!).toList())); + return tracks.toList(); + } catch (e, stack) { + // Ignore errors and create the track locally + AppLogger.reportError(e, stack); + + List tracks = []; + for (final trackSimple in this) { + Track track = Track(); + track.album = album; + track.name = trackSimple.name; + track.artists = trackSimple.artists; + track.availableMarkets = trackSimple.availableMarkets; + track.discNumber = trackSimple.discNumber; + track.durationMs = trackSimple.durationMs; + track.explicit = trackSimple.explicit; + track.externalUrls = trackSimple.externalUrls; + track.href = trackSimple.href; + track.id = trackSimple.id; + track.isPlayable = trackSimple.isPlayable; + track.linkedFrom = trackSimple.linkedFrom; + track.previewUrl = trackSimple.previewUrl; + track.trackNumber = trackSimple.trackNumber; + track.type = trackSimple.type; + track.uri = trackSimple.uri; + tracks.add(track); + } + return tracks; + } + } +} + +extension TracksToMediaExtension on Iterable { + List asMediaList() { + return map((track) => SpotubeMedia(track)).toList(); } } diff --git a/lib/hooks/configurators/use_check_yt_dlp_installed.dart b/lib/hooks/configurators/use_check_yt_dlp_installed.dart new file mode 100644 index 00000000..1d948258 --- /dev/null +++ b/lib/hooks/configurators/use_check_yt_dlp_installed.dart @@ -0,0 +1,40 @@ +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 index 05c03fff..2bdc65ef 100644 --- a/lib/hooks/configurators/use_close_behavior.dart +++ b/lib/hooks/configurators/use_close_behavior.dart @@ -1,28 +1,32 @@ import 'dart:io'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; 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:spotube/provider/user_preferences/user_preferences_state.dart'; -import 'package:local_notifier/local_notifier.dart'; -final closeNotification = DesktopTools.createNotification( - title: 'Spotube', - message: 'Running in background. Minimized to System Tray', - actions: [ - LocalNotificationAction(text: 'Close The App'), - ], -)?..onClickAction = (value) { - exit(0); - }; +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 DesktopTools.window.hide(); + 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 index f11a1cff..a141a21d 100644 --- a/lib/hooks/configurators/use_deep_linking.dart +++ b/lib/hooks/configurators/use_deep_linking.dart @@ -1,25 +1,22 @@ import 'dart:async'; import 'package:app_links/app_links.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/routes.dart'; -import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:flutter_sharing_intent/flutter_sharing_intent.dart'; import 'package:flutter_sharing_intent/model/sharing_file.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:spotube/services/logger/logger.dart'; +import 'package:spotube/utils/platform.dart'; final appLinks = AppLinks(); -final linkStream = appLinks.allStringLinkStream.asBroadcastStream(); +final linkStream = appLinks.stringLinkStream.asBroadcastStream(); -void useDeepLinking(WidgetRef ref) { +void useDeepLinking(WidgetRef ref, AppRouter router) { // single instance no worries final spotify = ref.watch(spotifyProvider); - final queryClient = useQueryClient(); - - final router = ref.watch(routerProvider); useEffect(() { void uriListener(List files) async { @@ -30,30 +27,25 @@ void useDeepLinking(WidgetRef ref) { switch (url.pathSegments.first) { case "album": - router.push( - "/album/${url.pathSegments.last}", - extra: await queryClient.fetchQuery( - "album/${url.pathSegments.last}", - () => spotify.albums.get(url.pathSegments.last), - ), + 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.push("/artist/${url.pathSegments.last}"); + router.navigate(ArtistRoute(artistId: url.pathSegments.last)); break; case "playlist": - router.push( - "/playlist/${url.pathSegments.last}", - extra: await queryClient.fetchQuery( - "playlist/${url.pathSegments.last}", - () => spotify.playlists.get(url.pathSegments.last), - ), - ); + 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.push( - "/track/${url.pathSegments.last}", - ); + router.navigate(TrackRoute(trackId: url.pathSegments.last)); break; default: break; @@ -63,7 +55,7 @@ void useDeepLinking(WidgetRef ref) { StreamSubscription? mediaStream; - if (DesktopTools.platform.isMobile) { + if (kIsMobile) { FlutterSharingIntent.instance.getInitialSharing().then(uriListener); mediaStream = @@ -71,36 +63,38 @@ void useDeepLinking(WidgetRef ref) { } final subscription = linkStream.listen((uri) async { - final startSegment = uri.split(":").take(2).join(":"); - final endSegment = uri.split(":").last; + try { + final startSegment = uri.split(":").take(2).join(":"); + final endSegment = uri.split(":").last; - switch (startSegment) { - case "spotify:album": - await router.push( - "/album/$endSegment", - extra: await queryClient.fetchQuery( - "album/$endSegment", - () => spotify.albums.get(endSegment), - ), - ); - break; - case "spotify:artist": - await router.push("/artist/$endSegment"); - break; - case "spotify:track": - await router.push("/track/$endSegment"); - break; - case "spotify:playlist": - await router.push( - "/playlist/$endSegment", - extra: await queryClient.fetchQuery( - "playlist/$endSegment", - () => spotify.playlists.get(endSegment), - ), - ); - break; - default: - break; + 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); } }); @@ -108,5 +102,5 @@ void useDeepLinking(WidgetRef ref) { mediaStream?.cancel(); subscription.cancel(); }; - }, [spotify, queryClient]); + }, [spotify]); } diff --git a/lib/hooks/configurators/use_disable_battery_optimizations.dart b/lib/hooks/configurators/use_disable_battery_optimizations.dart index a9afef45..4aa51b74 100644 --- a/lib/hooks/configurators/use_disable_battery_optimizations.dart +++ b/lib/hooks/configurators/use_disable_battery_optimizations.dart @@ -1,12 +1,12 @@ import 'package:disable_battery_optimization/disable_battery_optimization.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.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 (!DesktopTools.platform.isAndroid || - KVStoreService.askedForBatteryOptimization) return; + if (!kIsAndroid || KVStoreService.askedForBatteryOptimization) return; await DisableBatteryOptimization.showDisableBatteryOptimizationSettings(); diff --git a/lib/hooks/configurators/use_endless_playback.dart b/lib/hooks/configurators/use_endless_playback.dart index f5d11829..b86a4865 100644 --- a/lib/hooks/configurators/use_endless_playback.dart +++ b/lib/hooks/configurators/use_endless_playback.dart @@ -1,47 +1,35 @@ -import 'package:catcher_2/catcher_2.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/search.dart'; void useEndlessPlayback(WidgetRef ref) { - final auth = ref.watch(AuthenticationNotifier.provider); - final playback = ref.watch(ProxyPlaylistNotifier.notifier); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final auth = ref.watch(authenticationProvider); + final playback = ref.watch(audioPlayerProvider.notifier); + final playlist = ref.watch(audioPlayerProvider.select((s) => s.playlist)); final spotify = ref.watch(spotifyProvider); final endlessPlayback = ref.watch(userPreferencesProvider.select((s) => s.endlessPlayback)); - final queryClient = useQueryClient(); - useEffect( () { - if (!endlessPlayback || auth == null) return null; + if (!endlessPlayback || auth.asData?.value == null) return null; void listener(int index) async { try { - final playlist = ref.read(ProxyPlaylistNotifier.provider); + final playlist = ref.read(audioPlayerProvider); if (index != playlist.tracks.length - 1) return; final track = playlist.tracks.last; final query = "${track.name} Radio"; - final pages = await queryClient.fetchInfiniteQueryJob, - dynamic, int, SearchParams>( - job: SearchQueries.queryJob(query), - args: ( - spotify: spotify, - searchType: SearchType.playlist, - query: query - ), - ) ?? - []; + final pages = await spotify.invoke((api) => + api.search.get(query, types: [SearchType.playlist]).first()); final radios = pages .expand((e) => e.items?.toList() ?? []) @@ -62,28 +50,28 @@ void useEndlessPlayback(WidgetRef ref) { orElse: () => radios.first, ); - final tracks = - await spotify.playlists.getTracksByPlaylistId(radio.id!).all(); + final tracks = await spotify.invoke( + (api) => api.playlists.getTracksByPlaylistId(radio.id!).all()); await playback.addTracks( tracks.toList() ..removeWhere((e) { - final playlist = ref.read(ProxyPlaylistNotifier.provider); + final playlist = ref.read(audioPlayerProvider); final isDuplicate = playlist.tracks.any((t) => t.id == e.id); return e.id == track.id || isDuplicate; }), ); } catch (e, stack) { - Catcher2.reportCheckedError(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 (playlist.active == playlist.tracks.length - 1 && + if (playlist.index == playlist.medias.length - 1 && audioPlayer.isPlaying) { - listener(playlist.active!); + listener(playlist.index); } final subscription = @@ -94,8 +82,7 @@ void useEndlessPlayback(WidgetRef ref) { [ spotify, playback, - queryClient, - playlist.tracks, + playlist.medias, endlessPlayback, auth, ], diff --git a/lib/hooks/configurators/use_fix_window_stretching.dart b/lib/hooks/configurators/use_fix_window_stretching.dart new file mode 100644 index 00000000..b94098ab --- /dev/null +++ b/lib/hooks/configurators/use_fix_window_stretching.dart @@ -0,0 +1,21 @@ +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 index 3fcb369b..f860aaa7 100644 --- a/lib/hooks/configurators/use_get_storage_perms.dart +++ b/lib/hooks/configurators/use_get_storage_perms.dart @@ -1,35 +1,46 @@ import 'package:device_info_plus/device_info_plus.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.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/components/library/user_local_tracks.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 isMounted = useIsMounted(); + final context = useContext(); useAsyncEffect( () async { - if (!DesktopTools.platform.isMobile) return; + if (kIsAndroid) { + final androidInfo = await DeviceInfoPlugin().androidInfo; - final androidInfo = await DeviceInfoPlugin().androidInfo; + final hasNoStoragePerm = androidInfo.version.sdkInt < 33 && + !await Permission.storage.isGranted && + !await Permission.storage.isLimited; - 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; - final hasNoAudioPerm = androidInfo.version.sdkInt >= 33 && - !await Permission.audio.isGranted && - !await Permission.audio.isLimited; - - if (hasNoStoragePerm) { - await Permission.storage.request(); - if (isMounted()) ref.refresh(localTracksProvider); + 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 (hasNoAudioPerm) { - await Permission.audio.request(); - if (isMounted()) ref.refresh(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 new file mode 100644 index 00000000..5ce309b8 --- /dev/null +++ b/lib/hooks/configurators/use_has_touch.dart @@ -0,0 +1,27 @@ +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_init_sys_tray.dart b/lib/hooks/configurators/use_init_sys_tray.dart deleted file mode 100644 index 8080bea6..00000000 --- a/lib/hooks/configurators/use_init_sys_tray.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/intents.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -void useInitSysTray(WidgetRef ref) { - final context = useContext(); - final systemTray = useRef(null); - - final initializeMenu = useCallback(() async { - systemTray.value?.destroy(); - final playlist = ref.read(ProxyPlaylistNotifier.provider); - final playlistQueue = ref.read(ProxyPlaylistNotifier.notifier); - final preferences = ref.read(userPreferencesProvider); - if (!preferences.showSystemTrayIcon) { - await systemTray.value?.destroy(); - systemTray.value = null; - return; - } - final enabled = !playlist.isFetching; - systemTray.value = await DesktopTools.createSystemTrayMenu( - title: DesktopTools.platform.isWindows ? "Spotube" : "", - iconPath: "assets/spotube-logo.png", - windowsIconPath: "assets/spotube-logo.ico", - items: [ - MenuItemLabel( - label: "Show/Hide", - name: "show-hide", - onClicked: (item) async { - if (await DesktopTools.window.isVisible()) { - await DesktopTools.window.hide(); - } else { - await DesktopTools.window.show(); - } - }, - ), - MenuSeparator(), - MenuItemLabel( - label: "Play/Pause", - name: "play-pause", - enabled: enabled, - onClicked: (_) async { - Actions.maybeInvoke( - context, PlayPauseIntent(ref)) ?? - PlayPauseAction().invoke(PlayPauseIntent(ref)); - }, - ), - MenuItemLabel( - label: "Next", - name: "next", - enabled: enabled && (playlist.tracks.length) > 1, - onClicked: (p0) async { - await playlistQueue.next(); - }, - ), - MenuItemLabel( - label: "Previous", - name: "previous", - enabled: enabled && (playlist.tracks.length) > 1, - onClicked: (p0) async { - await playlistQueue.previous(); - }, - ), - MenuSeparator(), - MenuItemLabel( - label: "Quit", - name: "quit", - onClicked: (item) async { - exit(0); - }, - ), - ], - onEvent: (event, tray) async { - if (DesktopTools.platform.isWindows) { - switch (event) { - case SystemTrayEvent.click: - await DesktopTools.window.show(); - break; - case SystemTrayEvent.rightClick: - await tray.popUpContextMenu(); - break; - default: - } - } else { - switch (event) { - case SystemTrayEvent.rightClick: - await DesktopTools.window.show(); - break; - case SystemTrayEvent.click: - await tray.popUpContextMenu(); - break; - default: - } - } - }, - ); - }, [ref]); - - useReassemble(initializeMenu); - - ref.listen( - ProxyPlaylistNotifier.provider, - (previous, next) { - initializeMenu(); - }, - ); - ref.listen( - userPreferencesProvider.select((s) => s.showSystemTrayIcon), - (previous, next) { - initializeMenu(); - }, - ); - - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - initializeMenu(); - }); - return () async { - await systemTray.value?.destroy(); - }; - }, [initializeMenu]); -} diff --git a/lib/hooks/configurators/use_update_checker.dart b/lib/hooks/configurators/use_update_checker.dart deleted file mode 100644 index 1a6a5be5..00000000 --- a/lib/hooks/configurators/use_update_checker.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:http/http.dart' as http; -import 'package:spotube/collections/env.dart'; - -import 'package:spotube/components/shared/links/anchor_button.dart'; -import 'package:spotube/hooks/controllers/use_package_info.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:url_launcher/url_launcher_string.dart'; -import 'package:version/version.dart'; - -void useUpdateChecker(WidgetRef ref) { - final isCheckUpdateEnabled = - ref.watch(userPreferencesProvider.select((s) => s.checkUpdate)); - final packageInfo = usePackageInfo( - appName: 'Spotube', - packageName: 'spotube', - ); - final Future> Function() checkUpdate = useCallback( - () async { - final value = await http.get( - Uri.parse( - "https://api.github.com/repos/KRTirtho/spotube/releases/latest"), - ); - final tagName = - (jsonDecode(value.body)["tag_name"] as String).replaceAll("v", ""); - final currentVersion = packageInfo.version == "Unknown" - ? null - : Version.parse(packageInfo.version); - final latestVersion = - tagName == "nightly" ? null : Version.parse(tagName); - return [currentVersion, latestVersion]; - }, - [packageInfo.version], - ); - - final context = useContext(); - - download(String url) => launchUrlString( - url, - mode: LaunchMode.externalApplication, - ); - - useEffect(() { - if (!Env.enableUpdateChecker) return; - if (!isCheckUpdateEnabled) return null; - checkUpdate().then((value) { - final currentVersion = value.first; - final latestVersion = value.last; - if (currentVersion == null || - latestVersion == null || - (latestVersion.isPreRelease && !currentVersion.isPreRelease) || - (!latestVersion.isPreRelease && currentVersion.isPreRelease)) return; - if (latestVersion <= currentVersion) return; - showDialog( - context: context, - barrierDismissible: true, - barrierColor: Colors.black26, - builder: (context) { - const url = - "https://spotube.krtirtho.dev/other-downloads/stable-downloads"; - return AlertDialog( - title: const Text("Spotube has an update"), - actions: [ - FilledButton( - child: const Text("Download Now"), - onPressed: () => download(url), - ), - ], - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text("Spotube v${value.last} has been released"), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text("Read the latest "), - AnchorButton( - "release notes", - style: const TextStyle(color: Colors.blue), - onTap: () => launchUrlString( - url, - mode: LaunchMode.externalApplication, - ), - ), - ], - ), - ], - ), - ); - }, - ); - }); - return null; - }, [packageInfo, isCheckUpdateEnabled]); -} diff --git a/lib/hooks/configurators/use_window_listener.dart b/lib/hooks/configurators/use_window_listener.dart index b91ad413..5977ea8e 100644 --- a/lib/hooks/configurators/use_window_listener.dart +++ b/lib/hooks/configurators/use_window_listener.dart @@ -1,6 +1,8 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.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; @@ -154,6 +156,8 @@ void useWindowListener({ VoidCallback? onWindowEvent, }) { useEffect(() { + if (!kIsDesktop) return null; + final listener = CallbackWindowListener( onWindowClose: onWindowClose, onWindowFocus: onWindowFocus, @@ -172,9 +176,9 @@ void useWindowListener({ onWindowUndocked: onWindowUndocked, onWindowEvent: onWindowEvent, ); - DesktopTools.window.addListener(listener); + windowManager.addListener(listener); return () { - DesktopTools.window.removeListener(listener); + windowManager.removeListener(listener); }; }, [ onWindowClose, diff --git a/lib/hooks/controllers/use_auto_scroll_controller.dart b/lib/hooks/controllers/use_auto_scroll_controller.dart index 8edfb041..befc4351 100644 --- a/lib/hooks/controllers/use_auto_scroll_controller.dart +++ b/lib/hooks/controllers/use_auto_scroll_controller.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; @@ -39,8 +39,8 @@ class _AutoScrollControllerHook extends Hook { this.copyTagsFrom, this.suggestedRowHeight, this.debugLabel, - List? keys, - }) : super(keys: keys); + super.keys, + }); final double initialScrollOffset; final bool keepScrollOffset; diff --git a/lib/hooks/controllers/use_package_info.dart b/lib/hooks/controllers/use_package_info.dart index 9b142ced..07b53af6 100644 --- a/lib/hooks/controllers/use_package_info.dart +++ b/lib/hooks/controllers/use_package_info.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -44,8 +44,8 @@ class _PackageInfoHook extends Hook { required this.version, required this.buildNumber, this.buildSignature = '', - List? keys, - }) : super(keys: keys); + super.keys, + }); @override HookState> createState() => diff --git a/lib/hooks/controllers/use_shadcn_text_editing_controller.dart b/lib/hooks/controllers/use_shadcn_text_editing_controller.dart new file mode 100644 index 00000000..ae33f4e4 --- /dev/null +++ b/lib/hooks/controllers/use_shadcn_text_editing_controller.dart @@ -0,0 +1,97 @@ +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/controllers/use_sidebarx_controller.dart b/lib/hooks/controllers/use_sidebarx_controller.dart deleted file mode 100644 index 5af921b7..00000000 --- a/lib/hooks/controllers/use_sidebarx_controller.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:sidebarx/sidebarx.dart'; - -/// Creates [SidebarXController] that will be disposed automatically. -/// -/// See also: -/// - [SidebarXController] -SidebarXController useSidebarXController({ - required int selectedIndex, - bool? extended, - List? keys, -}) { - return use( - _SidebarXControllerHook( - selectedIndex: selectedIndex, - extended: extended, - keys: keys, - ), - ); -} - -class _SidebarXControllerHook extends Hook { - const _SidebarXControllerHook({ - required this.selectedIndex, - this.extended, - List? keys, - }) : super(keys: keys); - - final int selectedIndex; - final bool? extended; - - @override - HookState> createState() => - _SidebarXControllerHookState(); -} - -class _SidebarXControllerHookState - extends HookState { - late final SidebarXController controller; - - @override - void initHook() { - super.initHook(); - controller = SidebarXController( - selectedIndex: hook.selectedIndex, - extended: hook.extended, - ); - } - - @override - SidebarXController build(BuildContext context) => controller; - - @override - void dispose() => controller.dispose(); - - @override - String get debugLabel => 'useSidebarXController'; -} diff --git a/lib/hooks/spotify/use_spotify_infinite_query.dart b/lib/hooks/spotify/use_spotify_infinite_query.dart deleted file mode 100644 index 2063b083..00000000 --- a/lib/hooks/spotify/use_spotify_infinite_query.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'dart:async'; - -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/spotify_provider.dart'; - -InfiniteQuery - useSpotifyInfiniteQuery( - String queryKey, - FutureOr Function(PageType page, SpotifyApi spotify) queryFn, { - required WidgetRef ref, - required InfiniteQueryNextPage nextPage, - required PageType initialPage, - RetryConfig? retryConfig, - RefreshConfig? refreshConfig, - JsonConfig? jsonConfig, - ValueChanged>? onData, - ValueChanged>? onError, - bool enabled = true, - List? keys, -}) { - final spotify = ref.watch(spotifyProvider); - final query = useInfiniteQuery( - queryKey, - (page) => queryFn(page, spotify), - nextPage: nextPage, - initialPage: initialPage, - retryConfig: retryConfig, - refreshConfig: refreshConfig, - jsonConfig: jsonConfig, - onData: onData, - onError: onError, - enabled: enabled, - keys: keys, - ); - - useEffect(() { - return ref.listenManual( - spotifyProvider, - (previous, next) { - if (previous != next) { - query.refreshAll(); - } - }, - ).close; - }, [query]); - - return query; -} diff --git a/lib/hooks/spotify/use_spotify_mutation.dart b/lib/hooks/spotify/use_spotify_mutation.dart deleted file mode 100644 index 637f778f..00000000 --- a/lib/hooks/spotify/use_spotify_mutation.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/spotify_provider.dart'; - -Mutation - useSpotifyMutation( - String mutationKey, - Future Function(VariablesType variables, SpotifyApi spotify) - mutationFn, { - required WidgetRef ref, - RetryConfig? retryConfig, - MutationOnDataFn? onData, - MutationOnErrorFn? onError, - MutationOnMutationFn? onMutate, - List? refreshQueries, - List? refreshInfiniteQueries, - List? keys, -}) { - final spotify = ref.watch(spotifyProvider); - final mutation = - useMutation( - mutationKey, - (variables) => mutationFn(variables, spotify), - retryConfig: retryConfig, - onData: onData, - onError: onError, - onMutate: onMutate, - refreshQueries: refreshQueries, - refreshInfiniteQueries: refreshInfiniteQueries, - keys: keys, - ); - - return mutation; -} diff --git a/lib/hooks/spotify/use_spotify_query.dart b/lib/hooks/spotify/use_spotify_query.dart deleted file mode 100644 index 0c79de91..00000000 --- a/lib/hooks/spotify/use_spotify_query.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'dart:async'; - -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/spotify_provider.dart'; - -typedef SpotifyQueryFn = FutureOr Function( - SpotifyApi spotify); - -Query useSpotifyQuery( - final String queryKey, - final SpotifyQueryFn queryFn, { - required WidgetRef ref, - final DataType? initial, - final RetryConfig? retryConfig, - final RefreshConfig? refreshConfig, - final JsonConfig? jsonConfig, - final ValueChanged? onData, - final ValueChanged? onError, - final bool enabled = true, -}) { - final spotify = ref.watch(spotifyProvider); - - final query = useQuery( - queryKey, - () => queryFn(spotify), - initial: initial, - retryConfig: retryConfig, - refreshConfig: refreshConfig, - jsonConfig: jsonConfig, - onData: onData, - onError: onError, - enabled: enabled, - ); - - useEffect(() { - return ref.listenManual( - spotifyProvider, - (previous, next) { - if (previous != next) { - query.refresh(); - } - }, - ).close; - }, [query]); - - return query; -} diff --git a/lib/hooks/utils/use_breakpoint_value.dart b/lib/hooks/utils/use_breakpoint_value.dart index b2592124..74b2f860 100644 --- a/lib/hooks/utils/use_breakpoint_value.dart +++ b/lib/hooks/utils/use_breakpoint_value.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:spotube/extensions/constrains.dart'; diff --git a/lib/hooks/utils/use_brightness_value.dart b/lib/hooks/utils/use_brightness_value.dart index d3823b2f..64e3f27c 100644 --- a/lib/hooks/utils/use_brightness_value.dart +++ b/lib/hooks/utils/use_brightness_value.dart @@ -1,4 +1,5 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; T useBrightnessValue( diff --git a/lib/hooks/utils/use_custom_status_bar_color.dart b/lib/hooks/utils/use_custom_status_bar_color.dart index d1266fe2..f34ae7a8 100644 --- a/lib/hooks/utils/use_custom_status_bar_color.dart +++ b/lib/hooks/utils/use_custom_status_bar_color.dart @@ -1,29 +1,36 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -void useCustomStatusBarColor( +VoidCallback useCustomStatusBarColor( Color color, bool isCurrentRoute, { bool noSetBGColor = false, bool? automaticSystemUiAdjustment, }) { final context = useContext(); - final backgroundColor = Theme.of(context).scaffoldBackgroundColor; - resetStatusbar() => SystemChrome.setSystemUIOverlayStyle( - SystemUiOverlayStyle( - statusBarColor: backgroundColor, // status bar color - statusBarIconBrightness: backgroundColor.computeLuminance() > 0.179 - ? Brightness.dark - : Brightness.light, - ), - ); + 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; } @@ -43,6 +50,7 @@ void useCustomStatusBarColor( }); return () { if (automaticSystemUiAdjustment != null) { + // ignore: deprecated_member_use WidgetsBinding.instance.renderView.automaticSystemUiAdjustment = false; } }; @@ -51,4 +59,6 @@ void useCustomStatusBarColor( useEffect(() { return resetStatusbar; }, []); + + return resetStatusbar; } diff --git a/lib/hooks/utils/use_force_update.dart b/lib/hooks/utils/use_force_update.dart index 74151a65..268f0f04 100644 --- a/lib/hooks/utils/use_force_update.dart +++ b/lib/hooks/utils/use_force_update.dart @@ -2,5 +2,6 @@ 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 index 9269edd7..c70bcf72 100644 --- a/lib/hooks/utils/use_palette_color.dart +++ b/lib/hooks/utils/use_palette_color.dart @@ -1,12 +1,13 @@ -import 'package:flutter/material.dart'; +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/shared/image/universal_image.dart'; +import 'package:spotube/components/image/universal_image.dart'; final _paletteColorState = StateProvider( (ref) { - return PaletteColor(Colors.grey[300]!, 0); + return PaletteColor(Colors.gray[300], 0); }, ); @@ -14,7 +15,6 @@ PaletteColor usePaletteColor(String imageUrl, WidgetRef ref) { final context = useContext(); final theme = Theme.of(context); final paletteColor = ref.watch(_paletteColorState); - final mounted = useIsMounted(); useEffect(() { WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { @@ -25,7 +25,7 @@ PaletteColor usePaletteColor(String imageUrl, WidgetRef ref) { width: 50, ), ); - if (!mounted()) return; + if (!context.mounted) return; final color = theme.brightness == Brightness.light ? palette.lightMutedColor ?? palette.lightVibrantColor : palette.darkMutedColor ?? palette.darkVibrantColor; @@ -41,7 +41,7 @@ PaletteColor usePaletteColor(String imageUrl, WidgetRef ref) { PaletteGenerator usePaletteGenerator(String imageUrl) { final palette = useState(PaletteGenerator.fromColors([])); - final mounted = useIsMounted(); + final context = useContext(); useEffect(() { WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { @@ -52,7 +52,7 @@ PaletteGenerator usePaletteGenerator(String imageUrl) { width: 50, ), ); - if (!mounted()) return; + if (!context.mounted) return; palette.value = newPalette; }); diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 41fab083..4a7f176d 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -313,5 +313,122 @@ "help_project_grow_description": "Spotube هو مشروع مفتوح المصدر. يمكنك مساعدة هذا المشروع في النمو عن طريق المساهمة في المشروع، أو الإبلاغ عن الأخطاء، أو اقتراح ميزات جديدة.", "contribute_on_github": "المساهمة على GitHub", "donate_on_open_collective": "التبرع على Open Collective", - "browse_anonymously": "تصفح بشكل مجهول" + "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": "تم رفض الاتصال. المستخدم رفض الوصول." } \ No newline at end of file diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 353ca617..7d5a3e5a 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -313,5 +313,122 @@ "help_project_grow_description": "স্পটুব একটি ওপেন সোর্স প্রকল্প। আপনি প্রকল্পে অবদান রাখেন, বাগ রিপোর্ট করেন, বা নতুন বৈশিষ্ট্যগুলি সুপারিশ করেন।", "contribute_on_github": "গিটহাবে অবদান রাখুন", "donate_on_open_collective": "ওপেন কলেক্টিভে অনুদান করুন", - "browse_anonymously": "অজানে ব্রাউজ করুন" + "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": "সংযোগ অস্বীকৃত। ব্যবহারকারী প্রবেশাধিকার অস্বীকার করেছে।" } \ No newline at end of file diff --git a/lib/l10n/app_ca.arb b/lib/l10n/app_ca.arb index 9848954a..8afb33f3 100644 --- a/lib/l10n/app_ca.arb +++ b/lib/l10n/app_ca.arb @@ -313,5 +313,122 @@ "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" + "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." } \ No newline at end of file diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb new file mode 100644 index 00000000..44631945 --- /dev/null +++ b/lib/l10n/app_cs.arb @@ -0,0 +1,434 @@ +{ + "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." +} \ No newline at end of file diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b058d41a..7ce9c333 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -313,5 +313,122 @@ "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" + "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." } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8257eac9..4da423a8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,317 +1,432 @@ { - "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", - "create_playlist": "Create 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", - "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", - "account": "Account", - "login_with_spotify": "Login with your Spotify account", - "connect_with_spotify": "Connect with Spotify", - "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": "Spotube, a lightweight, cross-platform, free-for-all spotify client", - "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", - "add_spotify_credentials": "Add your spotify credentials to get started", - "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", - "spotify_cookie": "Spotify {name} Cookie", - "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", - "login_if_not_logged_in": "and Login/Signup if you are not logged in", - "step_2": "Step 2", - "step_2_steps": "1. Once you're logged in, press F12 or Mouse Right Click > Inspect to Open the Browser devtools.\n2. Then go the \"Application\" Tab (Chrome, Edge, Brave etc..) or \"Storage\" Tab (Firefox, Palemoon etc..)\n3. Go to the \"Cookies\" section then the \"https://accounts.spotify.com\" subsection", - "step_3": "Step 3", - "step_3_steps": "Copy the value of \"sp_dc\" Cookie", - "success_emoji": "Success🥳", - "success_message": "Now you've successfully Logged in with your Spotify account. Good Job, mate!", - "step_4": "Step 4", - "step_4_steps": "Paste the copied \"sp_dc\" value", - "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", - "generate_playlist": "Generate Playlist", - "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_codec": "Download music codec", - "streaming_music_codec": "Streaming music codec", - "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", - "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.", - "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" -} \ No newline at end of file + "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", + "login_with_spotify": "Login with your Spotify account", + "connect_with_spotify": "Connect with Spotify", + "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": "Spotube, a lightweight, cross-platform, free-for-all spotify client", + "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", + "add_spotify_credentials": "Add your spotify credentials to get started", + "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", + "spotify_cookie": "Spotify {name} Cookie", + "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", + "login_if_not_logged_in": "and Login/Signup if you are not logged in", + "step_2": "Step 2", + "step_2_steps": "1. Once you're logged in, press F12 or Mouse Right Click > Inspect to Open the Browser devtools.\n2. Then go the \"Application\" Tab (Chrome, Edge, Brave etc..) or \"Storage\" Tab (Firefox, Palemoon etc..)\n3. Go to the \"Cookies\" section then the \"https://accounts.spotify.com\" subsection", + "step_3": "Step 3", + "step_3_steps": "Copy the value of \"sp_dc\" Cookie", + "success_emoji": "Success🥳", + "success_message": "Now you've successfully Logged in with your Spotify account. Good Job, mate!", + "step_4": "Step 4", + "step_4_steps": "Paste the copied \"sp_dc\" value", + "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_codec": "Download music codec", + "streaming_music_codec": "Streaming music codec", + "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", + "spotify_hipotetical_calculation": "*This is calculated based on Spotify'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 Spotify.", + "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." +} diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 0b4cbb2a..8a6e02ad 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -313,5 +313,122 @@ "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" + "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." } \ No newline at end of file diff --git a/lib/l10n/app_eu.arb b/lib/l10n/app_eu.arb new file mode 100644 index 00000000..f28d5f8e --- /dev/null +++ b/lib/l10n/app_eu.arb @@ -0,0 +1,434 @@ +{ + "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." +} \ No newline at end of file diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 629238cc..0791d0b6 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -313,5 +313,122 @@ "help_project_grow_description": "Spotube یک پروژه متن باز است. شما می‌توانید با به پروژه کمک کردن، گزارش دادن اشکالات یا پیشنهاد ویژگی‌های جدید، به این پروژه کمک کنید.", "contribute_on_github": "مشارکت در GitHub", "donate_on_open_collective": "کمک مالی در Open Collective", - "browse_anonymously": "مرور به صورت ناشناس" + "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": "اتصال رد شد. کاربر دسترسی را رد کرد." } \ No newline at end of file diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb new file mode 100644 index 00000000..57a5a0b8 --- /dev/null +++ b/lib/l10n/app_fi.arb @@ -0,0 +1,434 @@ +{ + "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." +} \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 69b2bb69..192b6c5c 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -313,5 +313,122 @@ "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" + "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." } \ No newline at end of file diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index b442da37..124d0634 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -313,5 +313,122 @@ "help_project_grow_description": "Spotube एक ओपन सोर्स परियोजना है। आप इस परियोजना को योगदान देकर, बग रिपोर्ट करके या नई विशेषताओं का सुझाव देकर इस परियोजना को बढ़ा सकते हैं।", "contribute_on_github": "GitHub पर योगदान करें", "donate_on_open_collective": "ओपन कलेक्टिव पर दान करें", - "browse_anonymously": "बिना नाम के ब्राउज़ करें" + "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": "कनेक्शन अस्वीकृत। उपयोगकर्ता ने पहुंच अस्वीकृत कर दी।" } \ No newline at end of file diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb new file mode 100644 index 00000000..ffe36295 --- /dev/null +++ b/lib/l10n/app_id.arb @@ -0,0 +1,434 @@ +{ + "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." +} \ No newline at end of file diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index f8440cd0..1c7dc114 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -314,5 +314,122 @@ "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" + "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." } \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index ecdc77a2..230b2b49 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -313,5 +313,122 @@ "help_project_grow_description": "Spotubeはオープンソースプロジェクトです。プロジェクトに貢献したり、バグを報告したり、新しい機能を提案することで、このプロジェクトの成長に貢献できます。", "contribute_on_github": "GitHubで貢献する", "donate_on_open_collective": "Open Collectiveで寄付する", - "browse_anonymously": "匿名で閲覧する" + "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系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": "接続が拒否されました。ユーザーがアクセスを拒否しました。" } \ No newline at end of file diff --git a/lib/l10n/app_ka.arb b/lib/l10n/app_ka.arb new file mode 100644 index 00000000..67f90361 --- /dev/null +++ b/lib/l10n/app_ka.arb @@ -0,0 +1,434 @@ +{ + "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": "კავშირი უარყოფილია. მომხმარებელმა უარყო წვდომა." +} \ No newline at end of file diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 5a3ee8bc..037e1fb1 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -314,5 +314,122 @@ "help_project_grow_description": "Spotube는 오픈 소스 프로젝트입니다. 프로젝트에 기여하거나 버그를 보고하거나 새로운 기능을 제안하여이 프로젝트의 성장에 도움을 줄 수 있습니다.", "contribute_on_github": "GitHub에서 기여하기", "donate_on_open_collective": "Open Collective에 기부하기", - "browse_anonymously": "익명으로 둘러보기" + "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": "연결이 거부되었습니다. 사용자가 액세스를 거부했습니다." } \ No newline at end of file diff --git a/lib/l10n/app_ne.arb b/lib/l10n/app_ne.arb index d921f3ba..823dadb1 100644 --- a/lib/l10n/app_ne.arb +++ b/lib/l10n/app_ne.arb @@ -313,5 +313,122 @@ "help_project_grow_description": "Spotube एक खुला स्रोतको परियोजना हो। तपाईं परियोजनामा योगदान गरेर, त्रुटिहरू सूचिकै, वा नयाँ सुविधाहरू सुझाव दिएर यस परियोजनामा वृद्धि गर्न सक्नुहुन्छ।", "contribute_on_github": "GitHubमा योगदान गर्नुहोस्", "donate_on_open_collective": "खुला संगठनमा दान गर्नुहोस्", - "browse_anonymously": "अनामित रूपमा ब्राउज़ गर्नुहोस्" + "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": "जडान अस्वीकृत। प्रयोगकर्ताले पहुँच अस्वीकृत गर्यो।" } \ No newline at end of file diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index ad689105..2dd7e7a1 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -313,5 +313,122 @@ "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" + "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." } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index a1bc5de6..a2388614 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -313,5 +313,122 @@ "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" + "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." } \ No newline at end of file diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 7f290a1d..bec4f7c9 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -313,5 +313,122 @@ "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" + "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 ." } \ No newline at end of file diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c9139a90..9e8cbb3f 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -8,16 +8,16 @@ "genre_categories_filter": "Фильтр по категориям или жанрам...", "genre": "Жанр", "personalized": "Персонализированный", - "featured": "Будующий", - "new_releases": "Новые", - "songs": "Песни", + "featured": "Популярное", + "new_releases": "Новое", + "songs": "Треки", "playing_track": "Играет {track}", "queue_clear_alert": "Это удалит текущую очередь. {track_length} треков будет удалено. Вы хотите продолжить?", "load_more": "Загрузить больше", "playlists": "Плейлисты", "artists": "Исполнители", "albums": "Альбомы", - "tracks": "Трек", + "tracks": "Треки", "downloads": "Загрузки", "filter_playlists": "Применить фильтры к вашим плейлистам...", "liked_tracks": "Понравившиеся треки", @@ -25,20 +25,22 @@ "create_playlist": "Создание плейлиста", "create_a_playlist": "Создать плейлист", "create": "Создать", - "cancel": "Отменить", + "cancel": "Отмена", + "update": "Обновить", "playlist_name": "Назвать плейлист", "name_of_playlist": "Название плейлиста", "description": "Описание", - "public": "Публичные", + "public": "Публичный", "collaborative": "Совместный", "search_local_tracks": "Поиск песен на вашем устройстве...", "play": "Играть", "delete": "Удалить", - "none": "Никто", + "none": "Пусто", "sort_a_z": "Сортировка по алфавиту", "sort_z_a": "Сортировка по алфавиту в обратную сторону", "sort_artist": "Сортировать по исполнителю", "sort_album": "Сортировать по альбомам", + "sort_duration": "Сортировать по длительности", "sort_tracks": "Сортировать треки", "currently_downloading": "Загружается ({tracks_length})", "cancel_all": "Отменить все", @@ -104,6 +106,9 @@ "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", @@ -141,7 +146,7 @@ "close": "Закрыть", "minimize_to_tray": "Свернуть", "show_tray_icon": "Показать значок на панели задач", - "about": "О", + "about": "О нас", "u_love_spotube": "Мы знаем что вам нравится Spotube", "check_for_updates": "Проверьте наличие обновлений", "about_spotube": "О Spotube", @@ -175,9 +180,11 @@ "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", - "success_emoji": "Успешно 🥳", + "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 для сопоставления треков", @@ -205,7 +212,7 @@ "popularity": "Популярность", "key": "Ключ", "duration": "Продолжительность (с)", - "tempo": "Время (BPM)", + "tempo": "Темп (BPM)", "mode": "Режим", "time_signature": "Тактовый размер", "short": "Короткий", @@ -257,8 +264,6 @@ "you_are_offline": "Нет доступа к сети", "connection_restored": "Ваше интернет-соединение восстановлено", "use_system_title_bar": "Использовать системную панель заголовка", - "update_playlist": "Обновить плейлист", - "update": "Обновить", "crunching_results": "Обработка результатов...", "search_to_get_results": "Поиск для получения результатов", "use_amoled_mode": "Режим AMOLED", @@ -283,11 +288,8 @@ "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": "Хотите заменить текущую очередь или добавить к ней?", @@ -295,6 +297,7 @@ "delete_playlist": "Удалить плейлист", "delete_playlist_confirmation": "Вы уверены, что хотите удалить этот плейлист?", "local_tracks": "Локальные треки", + "local_tab": "Локальное", "song_link": "Ссылка на песню", "skip_this_nonsense": "Пропустить этот бред", "freedom_of_music": "“Свобода музыки”", @@ -313,5 +316,119 @@ "help_project_grow_description": "Spotube - это проект с открытым исходным кодом. Вы можете помочь этому проекту развиваться, внося вклад в проект, сообщая ошибках или предлагая новые функции.", "contribute_on_github": "Внести вклад на GitHub", "donate_on_open_collective": "Пожертвовать на Open Collective", - "browse_anonymously": "Анонимно просматривать" + "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": "Подключение отклонено. Пользователь отказал в доступе." } \ No newline at end of file diff --git a/lib/l10n/app_ta.arb b/lib/l10n/app_ta.arb new file mode 100644 index 00000000..15c8d54f --- /dev/null +++ b/lib/l10n/app_ta.arb @@ -0,0 +1,432 @@ +{ + "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": "இணைப்பு மறுக்கப்பட்டது. பயனர் அணுகலை மறுத்தார்." +} \ No newline at end of file diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb new file mode 100644 index 00000000..38decf66 --- /dev/null +++ b/lib/l10n/app_th.arb @@ -0,0 +1,435 @@ +{ + "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": "การเชื่อมต่อล้มเหลว ผู้ใช้ปฏิเสธการเข้าถึง" +} \ No newline at end of file diff --git a/lib/l10n/app_tl.arb b/lib/l10n/app_tl.arb new file mode 100644 index 00000000..a1c4264c --- /dev/null +++ b/lib/l10n/app_tl.arb @@ -0,0 +1,432 @@ +{ + "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." +} \ No newline at end of file diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 94800023..7c3bc42f 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -1,63 +1,64 @@ { "guest": "Misafir", - "browse": "Gözat", + "browse": "Göz at", "search": "Ara", "library": "Kütüphane", - "lyrics": "Sözler", + "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", + "featured": "Öne çıkanlar", + "new_releases": "Yeni çıkanlar", "songs": "Şarkılar", - "playing_track": "Oynatılıyor {track}", - "queue_clear_alert": "Bu, mevcut kuyruğu temizleyecektir. {track_length} parçaları kaldırılacaktır\nDevam etmek istiyor musunuz?", + "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": "Çalma Listeleri", + "playlists": "Oynatma listeleri", "artists": "Sanatçılar", "albums": "Albümler", "tracks": "Parçalar", - "downloads": "İndirmeler", - "filter_playlists": "Çalma listelerinizi filtreleyin...", - "liked_tracks": "Beğenilen 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": "Çalma Listesi Oluştur", - "create_a_playlist": "Bir çalma listesi oluştur", - "update_playlist": "Çalma listesini güncelle", + "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": "Çalma Listesi Adı", - "name_of_playlist": "Çalma listesi adı", + "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ı arayın...", + "search_local_tracks": "Yerel parçaları ara...", "play": "Oynat", "delete": "Sil", - "none": "Hiçbiri", - "sort_a_z": "A'dan Z'ye sırala", - "sort_z_a": "Z'dan A'ye sırala", - "sort_artist": "Sanatçıya Göre Sırala", - "sort_album": "Albüme Göre Sırala", - "sort_tracks": "Parçaları Sırala", - "currently_downloading": "Şu Anda İndiriliyor ({tracks_length})", - "cancel_all": "Tümünü İptal Et", - "filter_artist": "Sanatçıları filtrele...", + "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 İyi Parçalar", - "fans_also_like": "Hayranlar ayrıca şunları beğendi", + "top_tracks": "En iyi parçalar", + "fans_also_like": "Hayranlar ayrıca şunları da beğendi", "loading": "Yükleniyor...", "artist": "Sanatçı", - "blacklisted": "Kara Listede", - "following": "Takip Ediliyor", - "follow": "Takip Et", + "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çaları eklendi", - "filter_albums": "Albümleri filtrele...", - "synced": "Eşitlendi", + "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...", @@ -65,151 +66,153 @@ "error": "Hata {error}", "title": "Başlık", "time": "Zaman", - "more_actions": "Daha fazla işlem", + "more_actions": "Daha fazla eylem", "download_count": "İndir ({count})", - "add_count_to_playlist": "Çalma Listesine ({count}) Ekle", - "add_count_to_queue": "Sıraya ({count}) ekle", - "play_count_next": "Oynat ({count}) sonraki", + "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": "Panoya {data} kopyalandı", - "add_to_following_playlists": "Aşağıdaki Çalma Listelerine {track} ekle", + "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": "Sıraya {track} eklendi", + "added_track_to_queue": "{track} kuyruğa eklendi", "add_to_queue": "Kuyruğa ekle", - "track_will_play_next": "{track} sonraki çalacak", - "play_next": "Sıradaki", - "removed_track_from_queue": "Sıradan {track} kaldırıldı", - "remove_from_queue": "Kuyruktan çıkar", + "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": "Çalma listesine ekle", - "remove_from_playlist": "Çalma listesinden kaldır", + "add_to_playlist": "Oynatma listesine ekle", + "remove_from_playlist": "Oynatma listesinden kaldır", "add_to_blacklist": "Kara listeye ekle", - "remove_from_blacklist": "Kara listeden çıkar", + "remove_from_blacklist": "Kara listeden kaldır", "share": "Paylaş", - "mini_player": "Mini Oynatıcı", + "mini_player": "Mini oynatıcı", "slide_to_seek": "İleri veya geri arama yapmak için kaydırın", - "shuffle_playlist": "Çalma listesini karıştır", - "unshuffle_playlist": "Karışık çalma listesi", + "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": "Çalmayı Duraklat", - "resume_playback": "Çalmaya Devam Et", + "pause_playback": "Oynatmayı duraklat", + "resume_playback": "Oynatmayı sürdür", "loop_track": "Döngü parçası", - "repeat_playlist": "Çalma listesini tekrarla", - "queue": "Sıra", + "repeat_playlist": "Oynatma listesini tekrarla", + "queue": "Kuyruk", "alternative_track_sources": "Alternatif parça kaynakları", "download_track": "Parçayı indir", - "tracks_in_queue": "{tracks} sıradaki parçalar", + "tracks_in_queue": "{tracks} parça kuyrukta", "clear_all": "Tümünü temizle", - "show_hide_ui_on_hover": "Üzerine gelindiğinde kullanıcı arayüzünü göster/gizle", - "always_on_top": "Her zaman en üstte", + "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ınız ile giriş yapın", - "connect_with_spotify": "Spotify ile bağlantı kurun", - "logout": "Çıkış Yap", - "logout_of_this_account": "Bu hesaptan çıkış yap", - "language_region": "Dil & Bölge", - "language": "Dil", - "system_default": "Sistem Varsayılanı", - "market_place_region": "Mevcut Bölge", - "recommendation_country": "Tavsiye Edilen Ülke", + "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", + "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": "Karanlık", - "light": "Aydınlık", + "dark": "Koyu", + "light": "Açık", "system": "Sistem", - "accent_color": "Vurgu Rengi", - "sync_album_color": "Albüm rengini eşitle", - "sync_album_color_description": "Albüm resminin baskın rengini vurgu rengi olarak kullanır", - "playback": "Çalma", - "audio_quality": "Ses Kalitesi", + "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ı indirin ve oynatın (Daha yüksek bant genişliği kullanıcıları için önerilir)", - "skip_non_music": "Müzik olmayan bölümleri atla (SponsorBlock)", + "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 bitmesini bekleyin", + "wait_for_download_to_finish": "Lütfen mevcut indirme işleminin tamamlanmasını bekleyin", "desktop": "Masaüstü", - "close_behavior": "Yakın Davranış", + "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": "Lütfen Sponsor Olun/Bağış Yapın", - "spotube_description": "Spotube, hafif, platformlar arası, herkesin kullanabileceği ücretsiz bir Spotify istemcisidir.", + "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": "Kurucu", + "build_number": "Derleme numarası", + "founder": "Geliştirici", "repository": "Depo", "bug_issues": "Hata + Sorunlar", - "made_with": "❤️ ile Bangladesh🇧🇩 adresinde yapılmıştır.", + "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 bilgilerinizi ekleyin", - "credentials_will_not_be_shared_disclaimer": "Endişelenmeyin, bilgileriniz toplanmayacak veya kimseyle paylaşılmayacak", - "know_how_to_login": "Nasıl yapılacağını bilmiyor musunuz?", - "follow_step_by_step_guide": "Adım Adım kılavuzu takip edin", - "spotify_cookie": "Spotify {name} Çerez", - "cookie_name_cookie": "{name} Çerez", + "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": "Gönder", + "submit": "Başvur", "exit": "Çık", "previous": "Önceki", "next": "Sonraki", "done": "Bitti", "step_1": "1. Adım", - "first_go_to": "İlk önce şu adrese gidin", - "login_if_not_logged_in": "ve oturum açmadıysanız Giriş Yapın/Kaydolun", + "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. Giriş yaptıktan sonra, Tarayıcı devtools.\n2'yi açmak için F12'ye basın veya Fare Sağ Tıklaması > İncele'ye basın. Ardından \"Uygulama\" Sekmesine (Chrome, Edge, Brave vb.) veya \"Depolama\" Sekmesine (Firefox, Palemoon vb.) gidin\n3. \"Çerezler\" bölümüne ve ardından \"https://accounts.spotify.com\" alt bölümüne gidin", + "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": "Şimdi Spotify hesabınızla başarılı bir şekilde oturum açtınız. İyi iş, dostum!", + "success_message": "Artık Spotify hesabınızla başarıyla giriş yaptınız. Tebrik ederim!", "step_4": "4. Adım", - "something_went_wrong": "Bir şeyler ters gitti", - "piped_instance": "Piped Sunucu Örneği", + "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. Bu yüzden riski size ait olmak üzere kullanın", - "generate_playlist": "Çalma Listesi Oluştur", - "track_exists": "Track {track} zaten mevcut", + "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 mi istiyorsunuz?", + "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ür Seç", - "add_genres": "Tür Ekle", + "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": "Dansedilebilirlik", + "danceability": "Dans Edilebilirlik", "energy": "Enerji", - "instrumentalness": "Enstrümansallık", + "instrumentalness": "Araçsallık", "liveness": "Canlılık", - "loudness": "Yükseklik", + "loudness": "Ses yüksekliği", "speechiness": "Konuşkanlık", - "valence": "Değerlilik", + "valence": "Değerlik", "popularity": "Popülerlik", "key": "Anahtar", "duration": "Süre (sn)", "tempo": "Tempo (BPM)", "mode": "Mod", - "time_signature": "Zaman İmzası", + "time_signature": "Zaman imzası", "short": "Kısa", "medium": "Orta", "long": "Uzun", @@ -217,101 +220,215 @@ "max": "Maks", "target": "Hedef", "moderate": "Orta", - "deselect_all": "Tüm Seçimleri Kaldır", - "select_all": "Tümünü Seç", + "deselect_all": "Tüm seçimleri kaldır", + "select_all": "Tümünü seç", "are_you_sure": "Emin misiniz?", - "generating_playlist": "Özel çalma listenizi oluşturun...", - "selected_count_tracks": "Seçilen {count} parçalar", - "download_warning": "Tüm Parçaları toplu olarak indirirseniz, açıkça Müzik korsanlığı yapmış ve yaratıcı Müzik toplumuna zarar vermiş olursunuz. Umarım bunun farkındasınızdır. Her zaman, Sanatçıların sıkı çalışmalarına saygı duymayı ve desteklemeyi deneyin", - "download_ip_ban_warning": "Bu arada, normalden fazla indirme isteği nedeniyle IP adresiniz YouTube'da engellenebilir. IP engeli, o IP cihazından en az 2-3 ay boyunca YouTube'u (giriş yapmış olsanız bile) kullanamayacağınız anlamına gelir. Ve Spotube böyle bir durumda herhangi bir sorumluluk kabul etmez", - "by_clicking_accept_terms": "'Kabul et' seçeneğine tıklayarak aşağıdaki şartları kabul etmiş olursunuz:", - "download_agreement_1": "Müzik korsanlığı yaptığımı biliyorum. Ben malım.", - "download_agreement_2": "Sanatçıları elimden geldiğince destekleyeceğim ve bunu sadece sanatlarını satın alacak param olmadığı için yapıyorum", - "download_agreement_3": "IP adresimin YouTube'da engellenebileceğinin tamamen farkındayım ve mevcut eylemimin neden olduğu herhangi bir kazadan Spotube'u veya sahiplerini/dağıtıcılarını sorumlu tutmuyorum", + "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ğeniler", - "dislikes": "Beğenmemeler", + "likes": "Beğenenler", + "dislikes": "Beğenmeyenler", "views": "İzlenmeler", - "streamUrl": "Yayın Bağlantısı", - "stop": "Dur", - "sort_newest": "En yeni eklenene göre sırala", + "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} Dakikalar", - "hours": "{hours} Saat", - "hour": "{hours} Saatler", + "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ğı", + "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 bir depolamaya geri dönecektir. Linux kullanıyorsanız, lütfen gnome-keyring, kde-wallet, keepassxc vb. gibi bir güvenlik hizmetinizin kurulu olduğundan emin olun.", + "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\nYa örneği değiştirin ya da '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", + "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 yeniden kuruldu", + "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 kırılıyor...", - "search_to_get_results": "Sonuç almak için arama yap", - "use_amoled_mode": "AMOLED modunu kullan", - "pitch_dark_theme": "Zifiri siyah dart teması", + "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 indirin", - "streaming_music_codec": "Müzik akışı codec bileşeni", + "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ı", + "disconnect": "Bağlantıyı kes", + "username": "Kullanıcı adı", "password": "Şifre", - "login": "Giriş Yap", - "login_with_your_lastfm": "Last.fm hesabınız ile giriş yapın", + "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ık", - "browse_all": "Tümünü Gözat", - "genres": "Müzik Türleri", - "explore_genres": "Türleri Keşfet", - "step_3_steps": "\"sp_dc\" Çerezinin değerini kopyala", - "step_4_steps": "Kopyalanan \"sp_dc\" değerini yapıştır", + "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ça için şarkı sözleri bulunamıyor", - "sort_duration": "Süreye Göre Sırala", - "start_a_radio": "Radyo Başlat", + "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 Çalma", - "delete_playlist": "Çalma Listesini Sil", - "delete_playlist_confirmation": "Bu çalma listesini silmek istediğinizden emin misiniz?", - "local_tracks": "Yerel Parçalar", - "song_link": "Şarkı Bağlantısı", + "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üziğin Özgürlüğü”", - "freedom_of_music_palm": "“Müziğin Özgürlüğü avucunuzun içinde”", - "get_started": "Başlayalım", - "youtube_source_description": "Tavsiye edilir ve en iyi çalışır.", - "piped_source_description": "Özgür hissediyor musunuz? YouTube ile aynı ama çok daha fazla ücretsiz.", + "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ç", - "endless_playback_description": "Yeni şarkıları otomatik olarak sıraya ekle\nsonuna", - "choose_your_region": "Bölgenizi Seçin", - "choose_your_region_description": "Bu, Spotube'un konumunuza uygun doğru içeriği göstermesine yardımcı olacaktır.", - "choose_your_language": "Dilinizi Seçin", + "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. Bu projenin büyümesine, projeye katkıda bulunarak, hataları raporlayarak veya yeni özellikler önererek yardımcı olabilirsiniz.", - "contribute_on_github": "GitHub'da Katkıda Bulun", - "donate_on_open_collective": "Açık Topluluğa Bağış Yapın", - "browse_anonymously": "Anonim Olarak Göz At" + "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." } \ No newline at end of file diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index fe57e617..9cefc4a8 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -313,5 +313,122 @@ "help_project_grow_description": "Spotube - це проект з відкритим кодом. Ви можете допомогти цьому проекту зростати, вносячи свій внесок у проект, повідомляючи про помилки або пропонуючи нові функції.", "contribute_on_github": "Долучайтесь на GitHub", "donate_on_open_collective": "Пожертвуйте на Open Collective", - "browse_anonymously": "Анонімно переглядати" + "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": "Підключення відхилено. Користувач відмовив у доступі." } \ No newline at end of file diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 0e9b0b7c..47e5cf27 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -311,5 +311,124 @@ "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" + "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." } \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 506661f0..f68b30f5 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -313,5 +313,122 @@ "help_project_grow_description": "Spotube是一个开源项目。您可以通过为项目做出贡献、报告错误或建议新功能来帮助该项目成长。", "contribute_on_github": "在GitHub上做出贡献", "donate_on_open_collective": "在Open Collective上捐款", - "browse_anonymously": "匿名浏览" + "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": "连接被拒绝。用户拒绝访问。" } \ No newline at end of file diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart new file mode 100644 index 00000000..35cafab8 --- /dev/null +++ b/lib/l10n/generated/app_localizations.dart @@ -0,0 +1,2790 @@ +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') + ]; + + /// 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 @login_with_spotify. + /// + /// In en, this message translates to: + /// **'Login with your Spotify account'** + String get login_with_spotify; + + /// No description provided for @connect_with_spotify. + /// + /// In en, this message translates to: + /// **'Connect with Spotify'** + String get connect_with_spotify; + + /// 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: + /// **'Spotube, a lightweight, cross-platform, free-for-all spotify client'** + 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 @add_spotify_credentials. + /// + /// In en, this message translates to: + /// **'Add your spotify credentials to get started'** + String get add_spotify_credentials; + + /// 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 @spotify_cookie. + /// + /// In en, this message translates to: + /// **'Spotify {name} Cookie'** + String spotify_cookie(Object name); + + /// 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 @login_if_not_logged_in. + /// + /// In en, this message translates to: + /// **'and Login/Signup if you are not logged in'** + String get login_if_not_logged_in; + + /// No description provided for @step_2. + /// + /// In en, this message translates to: + /// **'Step 2'** + String get step_2; + + /// No description provided for @step_2_steps. + /// + /// In en, this message translates to: + /// **'1. Once you\'re logged in, press F12 or Mouse Right Click > Inspect to Open the Browser devtools.\n2. Then go the \"Application\" Tab (Chrome, Edge, Brave etc..) or \"Storage\" Tab (Firefox, Palemoon etc..)\n3. Go to the \"Cookies\" section then the \"https://accounts.spotify.com\" subsection'** + String get step_2_steps; + + /// No description provided for @step_3. + /// + /// In en, this message translates to: + /// **'Step 3'** + String get step_3; + + /// No description provided for @step_3_steps. + /// + /// In en, this message translates to: + /// **'Copy the value of \"sp_dc\" Cookie'** + String get step_3_steps; + + /// No description provided for @success_emoji. + /// + /// In en, this message translates to: + /// **'Success🥳'** + String get success_emoji; + + /// No description provided for @success_message. + /// + /// In en, this message translates to: + /// **'Now you\'ve successfully Logged in with your Spotify account. Good Job, mate!'** + String get success_message; + + /// No description provided for @step_4. + /// + /// In en, this message translates to: + /// **'Step 4'** + String get step_4; + + /// No description provided for @step_4_steps. + /// + /// In en, this message translates to: + /// **'Paste the copied \"sp_dc\" value'** + String get step_4_steps; + + /// 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_codec. + /// + /// In en, this message translates to: + /// **'Download music codec'** + String get download_music_codec; + + /// No description provided for @streaming_music_codec. + /// + /// In en, this message translates to: + /// **'Streaming music codec'** + String get streaming_music_codec; + + /// 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 @spotify_hipotetical_calculation. + /// + /// In en, this message translates to: + /// **'*This is calculated based on Spotify\'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 Spotify.'** + String get spotify_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; +} + +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 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 new file mode 100644 index 00000000..e713fe4e --- /dev/null +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'تسجيل الدخول بواسطة حساب Spotify'; + + @override + String get connect_with_spotify => 'توصيل بـSpotify'; + + @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 add_spotify_credentials => 'أضف بيانات Spotify الخاصة بك للبدء'; + + @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 spotify_cookie(Object name) { + return 'Spotify $name كوكيز'; + } + + @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 login_if_not_logged_in => 'وتسجيل الدخول/الاشتراك إذا لم تقم بتسجيل الدخول'; + + @override + String get step_2 => 'الخطوة 2'; + + @override + String get step_2_steps => '1. بمجرد تسجيل الدخول، اضغط على F12 أو انقر بزر الماوس الأيمن > فحص لفتح أدوات تطوير المتصفح.\n2. ثم انتقل إلى علامة التبويب \"التطبيقات\" (Chrome وEdge وBrave وما إلى ذلك.) أو علامة التبويب \"التخزين\" (Firefox وPalemoon وما إلى ذلك..)\n3. انتقل إلى قسم \"ملفات تعريف الارتباط\" ثم القسم الفرعي \"https://accounts.spotify.com\"'; + + @override + String get step_3 => 'الخطوة 3'; + + @override + String get step_3_steps => 'انسخ قيمة الكوكي \"sp_dc\"'; + + @override + String get success_emoji => 'نجاح 🥳'; + + @override + String get success_message => 'لقد قمت الآن بتسجيل الدخول بنجاح باستخدام حساب Spotify الخاص بك. عمل جيد يا صديقي!'; + + @override + String get step_4 => 'الخطوة 4'; + + @override + String get step_4_steps => 'الصق قيمة \"sp_dc\" المنسوخة'; + + @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_codec => 'تنزيل ترميز الموسيقى'; + + @override + String get streaming_music_codec => 'ترميز الموسيقى بالتدفق'; + + @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 spotify_hipotetical_calculation => '*هذا محسوب بناءً على الدفع لكل بث من سبوتيفاي\nبقيمة 0.003 إلى 0.005 دولار. هذا حساب افتراضي\nلإعطاء المستخدم فكرة عن المبلغ الذي\nكان سيدفعه للفنانين إذا كانوا قد استمعوا\nإلى أغنيتهم على سبوتيفاي.'; + + @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 => 'تم رفض الاتصال. المستخدم رفض الوصول.'; +} diff --git a/lib/l10n/generated/app_localizations_bn.dart b/lib/l10n/generated/app_localizations_bn.dart new file mode 100644 index 00000000..ae9c842a --- /dev/null +++ b/lib/l10n/generated/app_localizations_bn.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'আপনার Spotify account দিয়ে লগইন করুন'; + + @override + String get connect_with_spotify => 'Spotify লগইন'; + + @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 add_spotify_credentials => 'আপনার Spotify লগইন তথ্য যোগ করুন'; + + @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 spotify_cookie(Object name) { + return 'Spotify $name কুকি'; + } + + @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 login_if_not_logged_in => 'এবং যদি আপনি লগইন/সাইন-আপ না থাকেন তবে লগইন/সাইন-আপ করুন'; + + @override + String get step_2 => 'ধাপ 2'; + + @override + String get 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\" উপবিভাগে যান'; + + @override + String get step_3 => 'ধাপ 3'; + + @override + String get step_3_steps => 'কুকি \"sp_dc\" এর মানটি কপি করুন'; + + @override + String get success_emoji => 'আমরা সফল🥳'; + + @override + String get success_message => 'এখন আপনি সফলভাবে আপনার Spotify অ্যাকাউন্ট দিয়ে লগ ইন করেছেন। সাধুভাত আপনাকে'; + + @override + String get step_4 => 'ধাপ 4'; + + @override + String get step_4_steps => 'কপি করা \"sp_dc\" মানটি পেস্ট করুন'; + + @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_codec => 'সঙ্গীত কোডেক ডাউনলোড করুন'; + + @override + String get streaming_music_codec => 'স্ট্রিমিং সঙ্গীত কোডেক'; + + @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 spotify_hipotetical_calculation => '*এটি স্পোটিফাইয়ের প্রতি স্ট্রিম\n\$0.003 থেকে \$0.005 পেআউটের ভিত্তিতে গণনা করা হয়েছে। এটি একটি ধারণাগত\nগণনা ব্যবহারকারীদেরকে জানাতে দেয় যে কত টাকা\nতারা শিল্পীদের দিতো যদি তারা স্পোটিফাইতে\nতাদের গান শুনতেন।'; + + @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 => 'সংযোগ অস্বীকৃত। ব্যবহারকারী প্রবেশাধিকার অস্বীকার করেছে।'; +} diff --git a/lib/l10n/generated/app_localizations_ca.dart b/lib/l10n/generated/app_localizations_ca.dart new file mode 100644 index 00000000..1e78b087 --- /dev/null +++ b/lib/l10n/generated/app_localizations_ca.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'Iniciar sesión amb el seu compte de Spotify'; + + @override + String get connect_with_spotify => 'Connectar amb Spotify'; + + @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 add_spotify_credentials => 'Afegir les seves credencials de Spotify per començar'; + + @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 spotify_cookie(Object name) { + return 'Cookie de Spotify $name'; + } + + @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 login_if_not_logged_in => 'i iniciï sessió/registri el seu compte si no ho ha fet encara'; + + @override + String get step_2 => 'Pas 2'; + + @override + String get 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\"'; + + @override + String get step_3 => 'Pas 3'; + + @override + String get step_3_steps => 'Copia el valor de la cookie \"sp_dc\"'; + + @override + String get success_emoji => 'Èxit! 🥳'; + + @override + String get success_message => 'Ara has iniciat sessió amb èxit al teu compte de Spotify. Bona feina!'; + + @override + String get step_4 => 'Pas 4'; + + @override + String get step_4_steps => 'Pega el valor copiado de \"sp_dc\"'; + + @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_codec => 'Descarrega el codec de música'; + + @override + String get streaming_music_codec => 'Codec de música en streaming'; + + @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 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.'; + + @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.'; +} diff --git a/lib/l10n/generated/app_localizations_cs.dart b/lib/l10n/generated/app_localizations_cs.dart new file mode 100644 index 00000000..dcf39b70 --- /dev/null +++ b/lib/l10n/generated/app_localizations_cs.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'Přihlásit se pomocí Spotify účtu'; + + @override + String get connect_with_spotify => 'Připojit k Spotify'; + + @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 add_spotify_credentials => 'Přidejte své přihlašovací údaje Spotify a začněte'; + + @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 spotify_cookie(Object name) { + return 'Cookie Spotify $name'; + } + + @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 login_if_not_logged_in => 'a přihlašte se nebo se zaregistrujte, pokud nejste přihlášeni'; + + @override + String get step_2 => 'Krok 2'; + + @override + String get 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\"'; + + @override + String get step_3 => 'Krok 3'; + + @override + String get step_3_steps => 'Zkopírujte hodnotu cookie \"sp_dc\"'; + + @override + String get success_emoji => 'Úspěch🥳'; + + @override + String get success_message => 'Nyní jste úspěšně přihlášeni pomocí svého Spotify účtu. Dobrá práce, kamaráde!'; + + @override + String get step_4 => 'Krok 4'; + + @override + String get step_4_steps => 'Vložte zkopírovanou hodnotu \"sp_dc\"'; + + @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_codec => 'Kodek pro stahování'; + + @override + String get streaming_music_codec => 'Kodek pro streamování'; + + @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 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.'; + + @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.'; +} diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart new file mode 100644 index 00000000..17b9eac4 --- /dev/null +++ b/lib/l10n/generated/app_localizations_de.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'Mit deinem Spotify-Konto anmelden'; + + @override + String get connect_with_spotify => 'Mit Spotify verbinden'; + + @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 add_spotify_credentials => 'Fügen Sie Ihre Spotify-Anmeldeinformationen hinzu, um zu starten'; + + @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 spotify_cookie(Object name) { + return 'Spotify $name Cookie'; + } + + @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 login_if_not_logged_in => 'und melde dich an/registriere dich, falls du nicht angemeldet bist'; + + @override + String get step_2 => 'Schritt 2'; + + @override + String get 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\"'; + + @override + String get step_3 => 'Schritt 3'; + + @override + String get step_3_steps => 'Kopiere den Wert des Cookies \"sp_dc\"'; + + @override + String get success_emoji => 'Erfolg🥳'; + + @override + String get success_message => 'Jetzt bist du erfolgreich mit deinem Spotify-Konto angemeldet. Gut gemacht, Kumpel!'; + + @override + String get step_4 => 'Schritt 4'; + + @override + String get step_4_steps => 'Füge den kopierten Wert von \"sp_dc\" ein'; + + @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_codec => 'Musik-Codec herunterladen'; + + @override + String get streaming_music_codec => 'Streaming-Musik-Codec'; + + @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 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.'; + + @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.'; +} diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart new file mode 100644 index 00000000..91ac3586 --- /dev/null +++ b/lib/l10n/generated/app_localizations_en.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'Login with your Spotify account'; + + @override + String get connect_with_spotify => 'Connect with Spotify'; + + @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 => 'Spotube, a lightweight, cross-platform, free-for-all spotify client'; + + @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 add_spotify_credentials => 'Add your spotify credentials to get started'; + + @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 spotify_cookie(Object name) { + return 'Spotify $name Cookie'; + } + + @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 login_if_not_logged_in => 'and Login/Signup if you are not logged in'; + + @override + String get step_2 => 'Step 2'; + + @override + String get step_2_steps => '1. Once you\'re logged in, press F12 or Mouse Right Click > Inspect to Open the Browser devtools.\n2. Then go the \"Application\" Tab (Chrome, Edge, Brave etc..) or \"Storage\" Tab (Firefox, Palemoon etc..)\n3. Go to the \"Cookies\" section then the \"https://accounts.spotify.com\" subsection'; + + @override + String get step_3 => 'Step 3'; + + @override + String get step_3_steps => 'Copy the value of \"sp_dc\" Cookie'; + + @override + String get success_emoji => 'Success🥳'; + + @override + String get success_message => 'Now you\'ve successfully Logged in with your Spotify account. Good Job, mate!'; + + @override + String get step_4 => 'Step 4'; + + @override + String get step_4_steps => 'Paste the copied \"sp_dc\" value'; + + @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_codec => 'Download music codec'; + + @override + String get streaming_music_codec => 'Streaming music codec'; + + @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 spotify_hipotetical_calculation => '*This is calculated based on Spotify\'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 Spotify.'; + + @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.'; +} diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart new file mode 100644 index 00000000..5a05dc78 --- /dev/null +++ b/lib/l10n/generated/app_localizations_es.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'Iniciar sesión con tu cuenta de Spotify'; + + @override + String get connect_with_spotify => 'Conectar con Spotify'; + + @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 add_spotify_credentials => 'Agrega tus credenciales de Spotify para comenzar'; + + @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 spotify_cookie(Object name) { + return 'Cookie de Spotify $name'; + } + + @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 login_if_not_logged_in => 'e inicia sesión/registra tu cuenta si no lo has hecho aún'; + + @override + String get step_2 => 'Paso 2'; + + @override + String get 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\"'; + + @override + String get step_3 => 'Paso 3'; + + @override + String get step_3_steps => 'Copia el valor de la cookie \"sp_dc\"'; + + @override + String get success_emoji => '¡Éxito! 🥳'; + + @override + String get success_message => 'Ahora has iniciado sesión con éxito en tu cuenta de Spotify. ¡Buen trabajo!'; + + @override + String get step_4 => 'Paso 4'; + + @override + String get step_4_steps => 'Pega el valor copiado de \"sp_dc\"'; + + @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_codec => 'Descargar códec de música'; + + @override + String get streaming_music_codec => 'Códec de música en streaming'; + + @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 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.'; + + @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.'; +} diff --git a/lib/l10n/generated/app_localizations_eu.dart b/lib/l10n/generated/app_localizations_eu.dart new file mode 100644 index 00000000..6a5d81d7 --- /dev/null +++ b/lib/l10n/generated/app_localizations_eu.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'Hasi saioa zure Spotify kontuarekin'; + + @override + String get connect_with_spotify => 'Spotify-rekin konektatu'; + + @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 add_spotify_credentials => 'Gehitu zure Spotify kredentzialak hasi ahal izateko'; + + @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 spotify_cookie(Object name) { + return 'Spotify-ren $name cookiea'; + } + + @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 login_if_not_logged_in => 'eta hasi saioa/sortu kontua lehendik ez baduzu eginda'; + + @override + String get step_2 => '2. pausua'; + + @override + String get 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'; + + @override + String get step_3 => '3. pausua'; + + @override + String get step_3_steps => 'Kopiatu \"sp_dc\" cookiearen balioa'; + + @override + String get success_emoji => 'Eginda! 🥳'; + + @override + String get success_message => 'Ongi hasi duzu zure Spotify kontua. Lan bikaina, lagun!'; + + @override + String get step_4 => '4. pausua'; + + @override + String get step_4_steps => 'Itsatsi \"sp_dc\"-tik kopiatutako balioa'; + + @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_codec => 'Deskargatutako musikaren codec-a'; + + @override + String get streaming_music_codec => 'Streaming musikaren codec-a'; + + @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 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.'; + + @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.'; +} diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart new file mode 100644 index 00000000..d3e13d97 --- /dev/null +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'با حساب اسپوتیفای خود وارد شوید'; + + @override + String get connect_with_spotify => 'متصل شدن به اسپوتیفای'; + + @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 add_spotify_credentials => 'برای شروع اعتبار اسپوتیفای خود را اضافه کنید'; + + @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 spotify_cookie(Object name) { + return 'Spotify $name کوکی'; + } + + @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 login_if_not_logged_in => 'و اگر وارد نشده اید، وارد/ثبت نام کنید'; + + @override + String get step_2 => 'گام 2'; + + @override + String get step_2_steps => '1. پس از ورود به سیستم، F12 یا کلیک راست ماوس > Inspect را فشار دهید تا ابزارهای توسعه مرورگر باز شود..\n2. سپس به تب \"Application\" (Chrome, Edge, Brave etc..) یا \"Storage\" Tab (Firefox, Palemoon etc..)\n3. به قسمت \"Cookies\" و به پخش \"https://accounts.spotify.com\" بروید'; + + @override + String get step_3 => 'گام 3'; + + @override + String get step_3_steps => 'مقدار کوکی \"sp_dc\" را کپی کنید'; + + @override + String get success_emoji => 'موفقیت🥳'; + + @override + String get success_message => 'اکنون با موفقیت با حساب اسپوتیفای خود وارد شده اید'; + + @override + String get step_4 => 'مرحله 4'; + + @override + String get step_4_steps => 'مقدار کپی شده \"sp_dc\" را الصاق کنید'; + + @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_codec => 'دانلود کدک موسیقی'; + + @override + String get streaming_music_codec => 'کدک موسیقی استریمینگ'; + + @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 spotify_hipotetical_calculation => '*این بر اساس پرداخت هر پخش اسپاتیفای\nبه مبلغ 0.003 تا 0.005 دلار محاسبه شده است.\nاین یک محاسبه فرضی است که به کاربران نشان دهد چقدر ممکن است\nبه هنرمندان پرداخت می‌کردند اگر ترانه آنها را در اسپاتیفای گوش می‌دادند.'; + + @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 => 'اتصال رد شد. کاربر دسترسی را رد کرد.'; +} diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart new file mode 100644 index 00000000..f31b6609 --- /dev/null +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'Kirjaudu Spotify-käyttäjällä'; + + @override + String get connect_with_spotify => 'Yhdistä Spotify:lla'; + + @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 add_spotify_credentials => 'Lisää Spotify-tunnuksesi aloittaaksesi'; + + @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 spotify_cookie(Object name) { + return 'Spotify $name Keksi'; + } + + @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 login_if_not_logged_in => 'ja Kirjaudu/Tee tili jos et ole kirjautunut sisään'; + + @override + String get step_2 => 'Vaihe 2'; + + @override + String get 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.'; + + @override + String get step_3 => 'Vaihe 3'; + + @override + String get step_3_steps => 'Kopioi Keksin \"sp_dc\" arvo'; + + @override + String get success_emoji => 'Onnistuit🥳'; + + @override + String get success_message => 'Olet nyt kirjautunut sisään Spotify-käyttäjällesi. Hyvää työtä toveri!'; + + @override + String get step_4 => 'Vaihe 4'; + + @override + String get step_4_steps => 'Liitä kopioitu \"sp_dc\" arvo'; + + @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_codec => 'Ladatun musiikin codefc'; + + @override + String get streaming_music_codec => 'Suoratoistetun musiikin codec'; + + @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 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.'; + + @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.'; +} diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart new file mode 100644 index 00000000..56caeae7 --- /dev/null +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'Se connecter avec votre compte Spotify'; + + @override + String get connect_with_spotify => 'Se connecter avec Spotify'; + + @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 add_spotify_credentials => 'Ajoutez vos identifiants Spotify pour commencer'; + + @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 spotify_cookie(Object name) { + return 'Cookie Spotify $name'; + } + + @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 login_if_not_logged_in => 'et connectez-vous/inscrivez-vous si vous n\'êtes pas connecté'; + + @override + String get step_2 => 'Étape 2'; + + @override + String get 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\"'; + + @override + String get step_3 => 'Étape 3'; + + @override + String get step_3_steps => 'Copiez la valeur du cookie \"sp_dc\"'; + + @override + String get success_emoji => 'Succès🥳'; + + @override + String get success_message => 'Vous êtes maintenant connecté avec succès à votre compte Spotify. Bon travail, mon ami!'; + + @override + String get step_4 => 'Étape 4'; + + @override + String get step_4_steps => 'Collez la valeur copiée de \"sp_dc\"'; + + @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_codec => 'Télécharger le codec musical'; + + @override + String get streaming_music_codec => 'Codec de musique en streaming'; + + @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 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.'; + + @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 => 'Connection denied. User denied access.'; +} diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart new file mode 100644 index 00000000..440868f5 --- /dev/null +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'अपने Spotify खाते से लॉग इन करें'; + + @override + String get connect_with_spotify => 'Spotify से कनेक्ट करें'; + + @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 add_spotify_credentials => 'शुरू होने के लिए अपने स्पॉटिफाई क्रेडेंशियल जोड़ें'; + + @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 spotify_cookie(Object name) { + return 'स्पॉटिफाई $name कुकी'; + } + + @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 login_if_not_logged_in => 'और यदि आप लॉगिन नहीं हैं तो लॉगिन / साइनअप करें'; + + @override + String get step_2 => '2 चरण'; + + @override + String get step_2_steps => '1. जब आप लॉगिन हो जाएँ, तो F12 दबाएं या माउस राइट क्लिक> निरीक्षण करें ताकि ब्राउज़र डेवटूल्स खुलें।\n2. फिर ब्राउज़र के \"एप्लिकेशन\" टैब (Chrome, Edge, Brave आदि) या \"स्टोरेज\" टैब (Firefox, Palemoon आदि) में जाएं\n3. \"कुकीज़\" अनुभाग में जाएं फिर \"https: //accounts.spotify.com\" उप-अनुभाग में जाएं'; + + @override + String get step_3 => 'स्टेप 3'; + + @override + String get step_3_steps => '\"sp_dc\" कुकी का मूल्य कॉपी करें'; + + @override + String get success_emoji => 'सफलता🥳'; + + @override + String get success_message => 'अब आप अपने स्पॉटिफाई अकाउंट से सफलतापूर्वक लॉगइन हो गए हैं। अच्छा काम किया!'; + + @override + String get step_4 => 'स्टेप 4'; + + @override + String get step_4_steps => 'कॉपी किए गए \"sp_dc\" मूल्य को पेस्ट करें'; + + @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_codec => 'संगीत कोडेक डाउनलोड करें'; + + @override + String get streaming_music_codec => 'स्ट्रीमिंग संगीत कोडेक'; + + @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 spotify_hipotetical_calculation => '*यो Spotify को प्रति स्ट्रीम भुगतानको आधारमा\n\$0.003 देखि \$0.005 को बीचमा गणना गरिएको हो। यो एक काल्पनिक\nगणना हो जसले प्रयोगकर्तालाई देखाउँछ कि उनीहरूले कति\nअर्टिस्टहरूलाई तिनीहरूका गीतहरू Spotify मा सुनेमा\nभुक्तान गर्नुपर्ने थियो।'; + + @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 => 'कनेक्शन अस्वीकृत। उपयोगकर्ता ने पहुंच अस्वीकृत कर दी।'; +} diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart new file mode 100644 index 00000000..d76adc71 --- /dev/null +++ b/lib/l10n/generated/app_localizations_id.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'Masuk dengan Spotify'; + + @override + String get connect_with_spotify => 'Hubungkan dengan Spotify'; + + @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 add_spotify_credentials => 'Tambahkan kredensial Spotify Anda untuk memulai'; + + @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 spotify_cookie(Object name) { + return 'Spotify $name Cookie'; + } + + @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 login_if_not_logged_in => 'dan Masuk/Daftar jika Anda belum masuk'; + + @override + String get step_2 => 'Langkah 2'; + + @override + String get 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\"'; + + @override + String get step_3 => 'Langkah 3'; + + @override + String get step_3_steps => 'Salin nilai Cookie \"sp_dc\" '; + + @override + String get success_emoji => 'Berhasil🥳'; + + @override + String get success_message => 'Sekarang Anda telah berhasil Masuk dengan akun Spotify Anda. Kerja bagus, sobat!'; + + @override + String get step_4 => 'Langkah 4'; + + @override + String get step_4_steps => 'Tempel nilai \"sp_dc\" yang disalin'; + + @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_codec => 'Unduh codec musik'; + + @override + String get streaming_music_codec => 'Streaming codec 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 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.'; + + @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.'; +} diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart new file mode 100644 index 00000000..82574b07 --- /dev/null +++ b/lib/l10n/generated/app_localizations_it.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'Login con il tuo account Spotify'; + + @override + String get connect_with_spotify => 'Connetti con Spotify'; + + @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 add_spotify_credentials => 'Aggiungi le tue credenziali spotify per iniziare'; + + @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 spotify_cookie(Object name) { + return 'Cookie Spotify $name'; + } + + @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 login_if_not_logged_in => 'ed effettua il login o iscrizione se non sei già acceduto'; + + @override + String get step_2 => 'Passo 2'; + + @override + String get 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\"'; + + @override + String get step_3 => 'Passo 3'; + + @override + String get step_3_steps => 'Copia il valore del cookie \"sp_dc\"'; + + @override + String get success_emoji => 'Successo🥳'; + + @override + String get success_message => 'Ora hai correttamente effettuato il login al tuo account Spotify. Bel lavoro, amico!'; + + @override + String get step_4 => 'Passo 4'; + + @override + String get step_4_steps => 'Incolla il valore copiato di \"sp_dc\"'; + + @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_codec => 'Codec musicale scaricamento'; + + @override + String get streaming_music_codec => 'Codec musicale streaming'; + + @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 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.'; + + @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.'; +} diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart new file mode 100644 index 00000000..b2f1a23d --- /dev/null +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'Spotify アカウントでログイン'; + + @override + String get connect_with_spotify => 'Spotify に接続'; + + @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 add_spotify_credentials => 'Spotify のログイン情報を追加してはじめましょう'; + + @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 spotify_cookie(Object name) { + return 'Spotify $name Cookies'; + } + + @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 login_if_not_logged_in => '、ログインしてないならログインまたは登録します'; + + @override + String get step_2 => 'ステップ 2'; + + @override + String get step_2_steps => '1. ログインしたら、F12を押すか、マウス右クリック > 調査(検証)でブラウザの開発者ツール (devtools) を開きます。\n2. アプリケーション (Application) タブ (Chrome, Edge, Brave など) またはストレージタブ (Firefox, Palemoon など)\n3. Cookies 欄を選択し、https://accounts.spotify.com の枝を選びます'; + + @override + String get step_3 => 'ステップ 3'; + + @override + String get step_3_steps => '\"sp_dc\" Cookieの値をコピー'; + + @override + String get success_emoji => '成功🥳'; + + @override + String get success_message => 'アカウントへのログインに成功しました。よくできました!'; + + @override + String get step_4 => 'ステップ 4'; + + @override + String get step_4_steps => 'コピーした\"sp_dc\"の値を貼り付け'; + + @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 => '分かりました'; + + @override + String get failed_to_encrypt => '暗号化に失敗しました'; + + @override + String get encryption_failed_warning => 'Spotubeはデータを安全に保存するために暗号化を使用しています。しかし、失敗しました。したがって、安全でないストレージにフォールバックします\nLinuxを使用している場合は、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_codec => '音楽コーデックをダウンロードする'; + + @override + String get streaming_music_codec => 'ストリーミング音楽コーデック'; + + @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 => '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 spotify_hipotetical_calculation => '*これは、Spotifyのストリームごとの支払い\nが \$0.003 から \$0.005 の範囲で計算されています。これは仮想的な\n計算で、Spotify で曲を聴いた場合に、アーティストに\nどれくらい支払ったかをユーザーに示すためのものです。'; + + @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系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 => '接続が拒否されました。ユーザーがアクセスを拒否しました。'; +} diff --git a/lib/l10n/generated/app_localizations_ka.dart b/lib/l10n/generated/app_localizations_ka.dart new file mode 100644 index 00000000..0044d144 --- /dev/null +++ b/lib/l10n/generated/app_localizations_ka.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'შედით თქვენი Spotify ანგარიშით'; + + @override + String get connect_with_spotify => 'დაუკავშირდით Spotify-ს'; + + @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 add_spotify_credentials => 'დასაწყებად დაამატეთ თქვენი Spotify მონაცემები'; + + @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 spotify_cookie(Object name) { + return 'Spotify $name ქუქი'; + } + + @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 login_if_not_logged_in => 'და შესვლა/რეგისტრაცია, თუ არ ხართ შესული'; + + @override + String get step_2 => 'ნაბიჯი 2'; + + @override + String get 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\" სუბსექციაში'; + + @override + String get step_3 => 'ნაბიჯი 3'; + + @override + String get step_3_steps => 'დააკოპირეთ \"sp_dc\" ქუქი-ფაილის მნიშვნელობა'; + + @override + String get success_emoji => 'წარმატება🥳'; + + @override + String get success_message => 'თქვენ წარმატებით შეხვედით თქვენი Spotify ანგარიშით.'; + + @override + String get step_4 => 'ნაბიჯი 4'; + + @override + String get step_4_steps => 'ჩასვით კოპირებული \"sp_dc\" მნიშვნელობა'; + + @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_codec => 'მუსიკის კოდეკის გადმოწერა'; + + @override + String get streaming_music_codec => 'სტრიმინგ მუსიკის კოდეკი'; + + @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 spotify_hipotetical_calculation => '*ეს გამოითვლება Spotify-ის თითოეულ სტრიმზე\nგადახდის შესაბამისად, რომელიც \$0.003 დან \$0.005-მდეა. ეს არის ჰიპოთეტური\nგამოთვლა, რომელიც აჩვენებს მომხმარებელს რამდენი გადაიხდიდა\nარტისტებს, თუკი ისინი უსმენდნენ მათ სიმღერებს Spotify-ზე.'; + + @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 => 'კავშირი უარყოფილია. მომხმარებელმა უარყო წვდომა.'; +} diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart new file mode 100644 index 00000000..a3f11040 --- /dev/null +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'Spotify 계정으로 로그인'; + + @override + String get connect_with_spotify => 'Spotify에 연결'; + + @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 add_spotify_credentials => '먼저 Spotify의 로그인정보를 추가하기'; + + @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 spotify_cookie(Object name) { + return 'Spotify $name Cookies'; + } + + @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 login_if_not_logged_in => '그리고 로그인을 하지 않았다면 로그인해주세요'; + + @override + String get step_2 => '2단계'; + + @override + String get step_2_steps => '1. 로그인에 성공하면、F12나 마우스 우클릭 > 검사(Inspect)을 눌러 브라우저의 개발자 도구(devtools)를 열어주세요.\n2. 애플리케이션 (Application) 탭 (Chrome, Edge, Brave 등) 또는 스토리지 탭 (Firefox, Palemoon 등)을 열어주세요.\n3. 쿠키 (Cookies) 섹션으로 들어가서, https://accounts.spotify.com 서브섹션으로 들어가주세요.'; + + @override + String get step_3 => '3단계'; + + @override + String get step_3_steps => '\"sp_dc\" 쿠키의 값을 복사'; + + @override + String get success_emoji => '성공🥳'; + + @override + String get success_message => '성공적으로 스포티파이 게정으로 로그인했습니다. 잘했어요!'; + + @override + String get step_4 => '4단계'; + + @override + String get step_4_steps => '복사한 \"sp_dc\"값을 붙여넣기'; + + @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_codec => '다운로드 음악 코덱'; + + @override + String get streaming_music_codec => '스트리밍 음악 코덱'; + + @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 spotify_hipotetical_calculation => '*Spotify의 스트림당 지불금 \$0.003에서 \$0.005까지의\n기준으로 계산되었습니다. 이는 사용자가 Spotify에서\n곡을 들을 때 아티스트에게 얼마를 지불했을지를\n알려주기 위한 가상의 계산입니다.'; + + @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 => '연결이 거부되었습니다. 사용자가 액세스를 거부했습니다.'; +} diff --git a/lib/l10n/generated/app_localizations_ne.dart b/lib/l10n/generated/app_localizations_ne.dart new file mode 100644 index 00000000..f1d0f657 --- /dev/null +++ b/lib/l10n/generated/app_localizations_ne.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'तपाईंको Spotify खातासँग लगइन गर्नुहोस्'; + + @override + String get connect_with_spotify => 'Spotify सँग जडान गर्नुहोस्'; + + @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 add_spotify_credentials => 'सुरु हुनका लागि तपाईंको स्पटिफाई क्रेडेन्शियल थप्नुहोस्'; + + @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 spotify_cookie(Object name) { + return 'Spotify $name कुकी'; + } + + @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 login_if_not_logged_in => 'र लगइन/साइनअप गर्नुहोस् जुन तपाईंले लगइन गरेनन्'; + + @override + String get step_2 => 'कदम 2'; + + @override + String get step_2_steps => '1. एकबार तपाईं लगइन गरे पछि, F12 थिच्नुहोस् वा माउस राइट क्लिक गर्नुहोस् > इन्स्पेक्ट गर्नुहोस् भने ब्राउजर डेभटुलहरू खुलाउनका लागि।\n2. तपाईंको \"एप्लिकेसन\" ट्याबमा जानुहोस् (Chrome, Edge, Brave इत्यादि) वा \"स्टोरेज\" ट्याबमा जानुहोस् (Firefox, Palemoon इत्यादि)\n3. तपाईंको इन्सेक्ट गरेको ब्राउजर डेभटुलहरूमा \"कुकीहरू\" खण्डमा जानुहोस् अनि \"https://accounts.spotify.com\" उपकोणमा जानुहोस्'; + + @override + String get step_3 => 'कदम 3'; + + @override + String get step_3_steps => '\"sp_dc\" र \"sp_key\" (वा sp_gaid) कुकीहरूको मानहरू प्रतिलिपि गर्नुहोस्'; + + @override + String get success_emoji => 'सफलता 🥳'; + + @override + String get success_message => 'हाम्रो सानो भाइ, अब तपाईं सफलतापूर्वक आफ्नो Spotify खातामा लगइन गरेका छौं। राम्रो काम गरेको!'; + + @override + String get step_4 => 'कदम 4'; + + @override + String get step_4_steps => 'प्रतिलिपि गरेको \"sp_dc\" र \"sp_key\" (वा sp_gaid) मानहरूलाई आफ्नो ठाउँमा पेस्ट गर्नुहोस्'; + + @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_codec => 'साङ्गीत कोडेक डाउनलोड गर्नुहोस्'; + + @override + String get streaming_music_codec => 'स्ट्रिमिङ साङ्गीत कोडेक'; + + @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 spotify_hipotetical_calculation => '*यो Spotify को प्रति स्ट्रीम भुगतानको आधारमा\n\$0.003 देखि \$0.005 को बीचमा गणना गरिएको हो। यो एक काल्पनिक\nगणना हो जसले प्रयोगकर्तालाई देखाउँछ कि उनीहरूले कति\nअर्टिस्टहरूलाई तिनीहरूका गीतहरू Spotify मा सुनेमा\nभुक्तान गर्नुपर्ने थियो।'; + + @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 => 'जडान अस्वीकृत। प्रयोगकर्ताले पहुँच अस्वीकृत गर्यो।'; +} diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart new file mode 100644 index 00000000..338a664a --- /dev/null +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -0,0 +1,1388 @@ +// 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 uitgaves'; + + @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 => 'Sorteer op Duur'; + + @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 => 'Alle 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 => 'Topsporen'; + + @override + String get fans_also_like => 'Liefhebbers willen 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 schuifelen'; + + @override + String get unshuffle_playlist => 'Afspeellijst onschuifelen'; + + @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 nummerbronnen'; + + @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 login_with_spotify => 'Inloggen met je Spotify-account'; + + @override + String get connect_with_spotify => 'Verbinden met Spotify'; + + @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 add_spotify_credentials => 'Voeg om te beginnen je spotify-aanmeldgegevens toe'; + + @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 spotify_cookie(Object name) { + return 'Spotify $name Cookie'; + } + + @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 login_if_not_logged_in => 'en Inloggen/Aanmelden als je niet bent ingelogd'; + + @override + String get step_2 => 'Stap 2'; + + @override + String get 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\".'; + + @override + String get step_3 => 'Stap 3'; + + @override + String get step_3_steps => 'De waarde van cookie \"sp_dc\" kopiëren'; + + @override + String get success_emoji => 'Succes🥳'; + + @override + String get success_message => 'Je bent nu ingelogd met je Spotify account. Goed gedaan!'; + + @override + String get step_4 => 'Stap 4'; + + @override + String get step_4_steps => 'De gekopieerde waarde \"sp_dc\" plakken'; + + @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 nieuwste toegevoegd'; + + @override + String get sort_oldest => 'Sorteren op oudste 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_codec => 'Download-codec'; + + @override + String get streaming_music_codec => 'Streaming-codec'; + + @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 => 'Start een Radio'; + + @override + String get how_to_start_radio => 'Hoe wilt u de radio starten?'; + + @override + String get replace_queue_question => 'Wilt u de huidige wachtrij vervangen of eraan toevoegen?'; + + @override + String get endless_playback => 'Eindeloze Afspelen'; + + @override + String get delete_playlist => 'Verwijder Afspeellijst'; + + @override + String get delete_playlist_confirmation => 'Weet u zeker dat u deze afspeellijst wilt verwijderen?'; + + @override + String get local_tracks => 'Lokale Nummers'; + + @override + String get local_tab => 'Lokaal'; + + @override + String get song_link => 'Nummer Link'; + + @override + String get skip_this_nonsense => 'Sla deze onzin over'; + + @override + String get freedom_of_music => '“Vrijheid van Muziek”'; + + @override + String get freedom_of_music_palm => '“Vrijheid van Muziek in de palm van je hand”'; + + @override + String get get_started => 'Laten we beginnen'; + + @override + String get youtube_source_description => 'Aanbevolen en werkt het beste.'; + + @override + String get piped_source_description => 'Voel je vrij? Hetzelfde als YouTube maar veel gratis.'; + + @override + String get jiosaavn_source_description => 'Het beste voor de Zuid-Aziatische regio.'; + + @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 => 'Selecteer Audiobron'; + + @override + String get endless_playback_description => 'Voeg automatisch nieuwe nummers toe aan het einde van de wachtrij'; + + @override + String get choose_your_region => 'Kies uw regio'; + + @override + String get choose_your_region_description => 'Dit zal Spotube helpen om de juiste inhoud voor uw locatie te tonen.'; + + @override + String get choose_your_language => 'Kies uw taal'; + + @override + String get help_project_grow => 'Help dit project groeien'; + + @override + String get help_project_grow_description => 'Spotube is een open-source project. U kunt dit project helpen groeien door bij te dragen aan het project, bugs te melden of nieuwe functies voor te stellen.'; + + @override + String get contribute_on_github => 'Bijdragen op GitHub'; + + @override + String get donate_on_open_collective => 'Doneren op Open Collective'; + + @override + String get browse_anonymously => 'Anoniem Bladeren'; + + @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 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.'; + + @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.'; +} diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart new file mode 100644 index 00000000..00e8cb9c --- /dev/null +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'Zaloguj się używając konta Spotify'; + + @override + String get connect_with_spotify => 'Połącz z Spotify'; + + @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 add_spotify_credentials => 'Dodaj swoje dane logowania Spotify, aby zacząć'; + + @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 spotify_cookie(Object name) { + return 'Spotify $name Ciasteczko'; + } + + @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 login_if_not_logged_in => 'i Zaloguj się/Zarejestruj jeśli nie jesteś zalogowany'; + + @override + String get step_2 => 'Krok 2'; + + @override + String get 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\"'; + + @override + String get step_3 => 'Krok 3'; + + @override + String get step_3_steps => 'Skopiuj wartość ciasteczka \"sp_dc\"'; + + @override + String get success_emoji => 'Sukces!🥳'; + + @override + String get success_message => 'Udało ci się zalogować! Dobra robota, stary!'; + + @override + String get step_4 => 'Krok 4'; + + @override + String get step_4_steps => 'Wklej skopiowaną wartość \"sp_dc\"'; + + @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_codec => 'Pobierz kodek muzyczny'; + + @override + String get streaming_music_codec => 'Kodek strumieniowy muzyki'; + + @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 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.'; + + @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.'; +} diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart new file mode 100644 index 00000000..87e065b6 --- /dev/null +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'Fazer login com sua conta do Spotify'; + + @override + String get connect_with_spotify => 'Conectar ao Spotify'; + + @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 add_spotify_credentials => 'Adicione suas credenciais do Spotify para começar'; + + @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 spotify_cookie(Object name) { + return 'Cookie do Spotify $name'; + } + + @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 login_if_not_logged_in => 'e faça login/cadastro se ainda não estiver logado'; + + @override + String get step_2 => 'Passo 2'; + + @override + String get 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\"'; + + @override + String get step_3 => 'Passo 3'; + + @override + String get step_3_steps => 'Copie o valor do cookie \"sp_dc\"'; + + @override + String get success_emoji => 'Sucesso🥳'; + + @override + String get success_message => 'Agora você está logado com sucesso em sua conta do Spotify. Bom trabalho!'; + + @override + String get step_4 => 'Passo 4'; + + @override + String get step_4_steps => 'Cole o valor copiado de \"sp_dc\"'; + + @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_codec => 'Descarregar codec de música'; + + @override + String get streaming_music_codec => 'Codec de streaming de música'; + + @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 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.'; + + @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 .'; +} diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart new file mode 100644 index 00000000..baca93f3 --- /dev/null +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'Войдите с помощью своей учетной записи Spotify'; + + @override + String get connect_with_spotify => 'Подключитесь к Spotify'; + + @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 add_spotify_credentials => 'Добавьте ваши учетные данные Spotify, чтобы начать'; + + @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 spotify_cookie(Object name) { + return 'Spotify $name Cookie'; + } + + @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 login_if_not_logged_in => 'и войдите или зарегистрируйтесь, если вы не вошли в систему'; + + @override + String get step_2 => 'Шаг 2'; + + @override + String get step_2_steps => '1. После входа в систему нажмите F12 или щелкните правой кнопкой мыши > «Проверить», чтобы открыть инструменты разработчика браузера.\n2. Затем перейдите на вкладку \"Application\" (Chrome, Edge, Brave и т.д..) or \"Storage\" (Firefox, Palemoon и т.д..)\n3. Перейдите в раздел \"Cookies\", а затем в подраздел \"https://accounts.spotify.com\"'; + + @override + String get step_3 => 'Шаг 3'; + + @override + String get step_3_steps => 'Скопируйте значение Cookie \"sp_dc\"'; + + @override + String get success_emoji => 'Успешно🥳'; + + @override + String get success_message => 'Теперь вы успешно вошли в свою учетную запись Spotify. Отличная работа, приятель!'; + + @override + String get step_4 => 'Шаг 4'; + + @override + String get step_4_steps => 'Вставьте скопированное значение \"sp_dc\"'; + + @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_codec => 'Загрузить кодек для музыки'; + + @override + String get streaming_music_codec => 'Кодек потоковой передачи музыки'; + + @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 spotify_hipotetical_calculation => '*Это рассчитано на основе выплат Spotify за стрим\nот \$0.003 до \$0.005. Это гипотетический расчет,\nчтобы дать пользователю представление о том, сколько бы он\nзаплатил артистам, если бы слушал их песни на Spotify.'; + + @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 => 'Подключение отклонено. Пользователь отказал в доступе.'; +} diff --git a/lib/l10n/generated/app_localizations_ta.dart b/lib/l10n/generated/app_localizations_ta.dart new file mode 100644 index 00000000..ec29a427 --- /dev/null +++ b/lib/l10n/generated/app_localizations_ta.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'உங்கள் Spotify கணக்கில் உள்நுழைக'; + + @override + String get connect_with_spotify => 'Spotify உடன் இணைக்கவும்'; + + @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 add_spotify_credentials => 'தொடங்குவதற்கு உங்கள் spotify சான்றுகளைச் சேர்க்கவும்'; + + @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 spotify_cookie(Object name) { + return 'Spotify $name நட்புநிரல்'; + } + + @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 login_if_not_logged_in => 'நீங்கள் உள்நுழையவில்லை என்றால் உள்நுழைக/பதிவுசெய்க'; + + @override + String get step_2 => 'இரண்டாம் படி'; + + @override + String get step_2_steps => '1. நீங்கள் உள்நுழைந்தவுடன், F12 ஐ அழுத்தவும் அல்லது வலது கிளிக் செய்து > ஆய்வு செய்யவும் உலாவி டெவ்டூல்களைத் திறக்கவும்.\n2. பின்னர் \"பயன்பாடு\" தாவலுக்குச் செல்லவும் (Chrome, Edge, Brave போன்றவை) அல்லது \"சேமிப்பகம்\" தாவல் (Firefox, Palemoon போன்றவை)\n3. \"குக்கிகள்\" பிரிவுக்குச் சென்று பின்னர் \"https://accounts.spotify.com\" பிரிவுக்குச் செல்லவும்'; + + @override + String get step_3 => 'மூன்றாம் படி'; + + @override + String get step_3_steps => '\"sp_dc\" நட்புநிரலின் மதிப்பை நகலெடுக்கவும்'; + + @override + String get success_emoji => 'வெற்றி🥳'; + + @override + String get success_message => 'இப்போது நீங்கள் உங்கள் Spotify கணக்கில் வெற்றிகரமாக உள்நுழைந்துள்ளீர்கள். நல்லது, நண்பரே!'; + + @override + String get step_4 => 'நான்காம் படி'; + + @override + String get step_4_steps => 'நகலெடுக்கப்பட்ட \"sp_dc\" மதிப்பை ஒட்டவும்'; + + @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_codec => 'இசை கோடெக்கை பதிவிறக்கு'; + + @override + String get streaming_music_codec => 'இசை கோடெக்கை ஸ்ட்ரீம் செய்'; + + @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 spotify_hipotetical_calculation => '*இது Spotify இன் ஒவ்வொரு ஸ்ட்ரீமிற்கும்\n\$0.003 முதல் \$0.005 வரை அளவீடு அடிப்படையில் கணக்கிடப்படுகிறது. இது ஒரு கற்பனை\nகணக்கீடு ஆகும், பயனர் எந்த அளவிற்கு கலைஞர்களுக்கு\nஅதோர் பாடலை Spotify மென்பொருளில் கேட்டால் எவ்வளவு பணம் செலுத்தினார்கள் என்பதைக் கண்டுபிடிக்க.'; + + @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 => 'இணைப்பு மறுக்கப்பட்டது. பயனர் அணுகலை மறுத்தார்.'; +} diff --git a/lib/l10n/generated/app_localizations_th.dart b/lib/l10n/generated/app_localizations_th.dart new file mode 100644 index 00000000..d421fa81 --- /dev/null +++ b/lib/l10n/generated/app_localizations_th.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'เข้าสู่ระบบด้วยบัญชี Spotify'; + + @override + String get connect_with_spotify => 'เชื่อมต่อกับ Spotify'; + + @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 add_spotify_credentials => 'เพิ่มข้อมูลรับรอง Spotify ของคุณเพื่อเริ่มต้น'; + + @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 spotify_cookie(Object name) { + return 'คุกกี้ Spotify $name'; + } + + @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 login_if_not_logged_in => 'ยังไม่ได้เข้าสู่ระบบ ให้เข้าสู่ระบบ/ลงทะเบียน'; + + @override + String get step_2 => 'ขั้นที่ 2'; + + @override + String get step_2_steps => '1. หลังจากเข้าสู่ระบบแล้ว กด F12 หรือ คลิกขวาที่เมาส์ > ตรวจสอบเพื่อเปิด Devtools เบราว์เซอร์\n2. จากนั้นไปที่แท็บ \"แอปพลิเคชัน\" (Chrome, Edge, Brave เป็นต้น) หรือแท็บ \"ที่เก็บข้อมูล\" (Firefox, Palemoon เป็นต้น)\n3. ไปที่ส่วน \"คุกกี้\" แล้วไปที่ subsection \"https: //accounts.spotify.com\"'; + + @override + String get step_3 => 'ขั้นที่ 3'; + + @override + String get step_3_steps => 'คัดลอกค่าคุกกี้ \"sp_dc\"'; + + @override + String get success_emoji => 'สำเร็จ'; + + @override + String get success_message => 'ตอนนี้คุณเข้าสู่ระบบด้วยบัญชี Spotify ของคุณเรียบร้อยแล้ว ยอดเยี่ยม!'; + + @override + String get step_4 => 'ขั้นที่ 4'; + + @override + String get step_4_steps => 'วางค่า \"sp_dc\" ที่คัดลอกมา'; + + @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_codec => 'ดาวน์โหลดโคเดคเพลง'; + + @override + String get streaming_music_codec => 'สตรีมมิ่งโคเดคเพลง'; + + @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 spotify_hipotetical_calculation => '*คำนวณตามการจ่ายต่อสตรีมของ Spotify\nซึ่งอยู่ในช่วง \$0.003 ถึง \$0.005 นี่เป็นการคำนวณสมมุติ\nเพื่อให้ผู้ใช้ทราบว่าพวกเขาจะจ่ายเงินให้ศิลปินเท่าไหร่\nหากพวกเขาฟังเพลงของพวกเขาใน Spotify.'; + + @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 => 'การเชื่อมต่อล้มเหลว ผู้ใช้ปฏิเสธการเข้าถึง'; +} diff --git a/lib/l10n/generated/app_localizations_tl.dart b/lib/l10n/generated/app_localizations_tl.dart new file mode 100644 index 00000000..30a4cdf9 --- /dev/null +++ b/lib/l10n/generated/app_localizations_tl.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'Mag-login gamit ang iyong Spotify account'; + + @override + String get connect_with_spotify => 'Kumonekta sa Spotify'; + + @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 add_spotify_credentials => 'Idagdag ang iyong mga kredensyal sa spotify para makapagsimula'; + + @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 spotify_cookie(Object name) { + return 'Spotify $name Cookie'; + } + + @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 login_if_not_logged_in => 'at Mag-login/Mag-signup kung hindi ka naka-log in'; + + @override + String get step_2 => 'Hakbang 2'; + + @override + String get 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'; + + @override + String get step_3 => 'Hakbang 3'; + + @override + String get step_3_steps => 'Kopyahin ang halaga ng \"sp_dc\" Cookie'; + + @override + String get success_emoji => 'Tagumpay🥳'; + + @override + String get success_message => 'Ngayon ay matagumpay kang Naka-log in gamit ang iyong Spotify account. Magaling, kaibigan!'; + + @override + String get step_4 => 'Hakbang 4'; + + @override + String get step_4_steps => 'I-paste ang na-kopyang halaga ng \"sp_dc\"'; + + @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_codec => 'Codec para sa pag-download ng musika'; + + @override + String get streaming_music_codec => 'Codec para sa pag-stream 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 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.'; + + @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.'; +} diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart new file mode 100644 index 00000000..1df5a965 --- /dev/null +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'Spotify hesabı ile giriş yap'; + + @override + String get connect_with_spotify => 'Spotify ile bağlan'; + + @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 add_spotify_credentials => 'Başlamak için spotify kimlik bilgilerinizi ekleyin'; + + @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 spotify_cookie(Object name) { + return 'Spotify $name çerezi'; + } + + @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 login_if_not_logged_in => 've oturum açmadıysanız Oturum açın/Kaydolun'; + + @override + String get step_2 => '2. Adım'; + + @override + String get 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'; + + @override + String get step_3 => '3. Adım'; + + @override + String get step_3_steps => '\"sp_dc\" Çerezinin değerini kopyalayın'; + + @override + String get success_emoji => 'Başarılı🥳'; + + @override + String get success_message => 'Artık Spotify hesabınızla başarıyla giriş yaptınız. Tebrik ederim!'; + + @override + String get step_4 => '4. Adım'; + + @override + String get step_4_steps => 'Kopyalanan \"sp_dc\" değerini yapıştırın'; + + @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_codec => 'Müzik codec bileşenini indir'; + + @override + String get streaming_music_codec => 'Müzik codec\'i akışı'; + + @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 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.'; + + @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.'; +} diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart new file mode 100644 index 00000000..21ffca26 --- /dev/null +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'Увійти за допомогою облікового запису Spotify'; + + @override + String get connect_with_spotify => 'Підключитися до Spotify'; + + @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 add_spotify_credentials => 'Додайте свої облікові дані Spotify, щоб почати'; + + @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 spotify_cookie(Object name) { + return 'Кукі-файл Spotify $name'; + } + + @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 login_if_not_logged_in => 'та Увійдіть/Зареєструйтесь, якщо ви не ввійшли'; + + @override + String get step_2 => 'Крок 2'; + + @override + String get step_2_steps => '1. Після входу натисніть F12 або клацніть правою кнопкою миші > Інспектувати, щоб відкрити інструменти розробки браузера.\n2. Потім перейдіть на вкладку \'Програма\' (Chrome, Edge, Brave тощо) або вкладку \'Сховище\' (Firefox, Palemoon тощо).\n3. Перейдіть до розділу \'Кукі-файли\', а потім до підрозділу \'https://accounts.spotify.com\''; + + @override + String get step_3 => 'Крок 3'; + + @override + String get step_3_steps => 'Скопіюйте значення cookie \"sp_dc\"'; + + @override + String get success_emoji => 'Успіх🥳'; + + @override + String get success_message => 'Тепер ви успішно ввійшли у свій обліковий запис Spotify. Гарна робота, друже!'; + + @override + String get step_4 => 'Крок 4'; + + @override + String get step_4_steps => 'Вставте скопійоване значення \"sp_dc\"'; + + @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_codec => 'Завантажити кодек для музики'; + + @override + String get streaming_music_codec => 'Кодек потокової передачі музики'; + + @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 spotify_hipotetical_calculation => '*Це розраховано на основі виплат Spotify за стрім\nвід \$0.003 до \$0.005. Це гіпотетичний розрахунок,\nщоб дати користувачеві уявлення про те, скільки б він заплатив\nартистам, якби слухав їхні пісні на Spotify.'; + + @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 => 'Підключення відхилено. Користувач відмовив у доступі.'; +} diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart new file mode 100644 index 00000000..53e5aa7a --- /dev/null +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => 'Đăng nhập bằng tài khoản Spotify của bạn'; + + @override + String get connect_with_spotify => 'Liên kết với Spotify'; + + @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 add_spotify_credentials => 'Điền thông tin đăng nhập Spotify của bạn'; + + @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 spotify_cookie(Object name) { + return 'Cookie Spotify $name'; + } + + @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 login_if_not_logged_in => 'và Đăng nhập/Đăng ký nếu chưa có tài khoản'; + + @override + String get step_2 => 'Bước 2'; + + @override + String get 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\"'; + + @override + String get step_3 => 'Bước 3'; + + @override + String get step_3_steps => 'Sao chép giá trị của Cookie \"sp_dc\" và \"sp_key\" (hoặc sp_gaid)'; + + @override + String get success_emoji => 'Thành công🥳'; + + @override + String get 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!'; + + @override + String get step_4 => 'Bước 4'; + + @override + String get 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'; + + @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_codec => 'Định dạng tải xuống'; + + @override + String get streaming_music_codec => 'Định dạng nghe'; + + @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 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.'; + + @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.'; +} diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart new file mode 100644 index 00000000..eb97a506 --- /dev/null +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -0,0 +1,1388 @@ +// 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 login_with_spotify => '使用 Spotify 登录'; + + @override + String get connect_with_spotify => '与 Spotify 账户连接'; + + @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 add_spotify_credentials => '添加你的 Spotify 登录信息以开始使用'; + + @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 spotify_cookie(Object name) { + return 'Spotify $name Cookie'; + } + + @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 login_if_not_logged_in => '如果尚未登录,请登录或者注册一个账户'; + + @override + String get step_2 => '步骤 2'; + + @override + String get step_2_steps => '1. 一旦你已经完成登录, 按 F12 键或者鼠标右击网页空白区域 > 选择“检查”以打开浏览器开发者工具(DevTools)\n2. 然后选择 \"应用(Application)\" 标签页(Chrome, Edge, Brave 等基于 Chromium 的浏览器) 或 \"存储(Storage)\" 标签页 (Firefox, Palemoon 等基于 Firefox 的浏览器))\n3. 选择 \"Cookies\" 栏目然后选择 \"https://accounts.spotify.com\" 子栏目'; + + @override + String get step_3 => '步骤 3'; + + @override + String get step_3_steps => '复制\"sp_dc\" Cookie的值'; + + @override + String get success_emoji => '成功🥳'; + + @override + String get success_message => '你已经成功使用 Spotify 登录。干得漂亮!'; + + @override + String get step_4 => '步骤 4'; + + @override + String get step_4_steps => '粘贴复制的\"sp_dc\"值'; + + @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 => '歌曲时长 (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_codec => '下载音乐编解码器'; + + @override + String get streaming_music_codec => '流媒体音乐编解码器'; + + @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 spotify_hipotetical_calculation => '*根据 Spotify 每次流媒体的支付金额\n\$0.003 到 \$0.005 进行计算。这是一个假设性的\n计算,用于给用户了解他们如果在 Spotify 上\n收听歌曲会支付给艺术家的金额。'; + + @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 => '连接被拒绝。用户拒绝访问。'; +} diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 7aec682a..0b974641 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -7,11 +7,18 @@ /// TexturedPolak@github => Polish /// yuri-val@github => Ukrainian /// energywave@github, ncvescera@github, OpenCode@github => Italian -/// mdksec@github => Turkish +/// mikropsoft@github => Turkish /// Stephan-P@github, SecularSteve@github => Dutch /// doannc2212@github => Vietnamese /// sappho192@github => Korean -import 'package:flutter/material.dart'; +/// watchakorn-18k@github => Thai +/// llama3, vishnumur777@github => Tamil +/// Microsoft Copilot, Tutislav@github => Czech + +library l10n; + +import 'package:shadcn_flutter/shadcn_flutter.dart'; +export 'package:spotube/l10n/generated/app_localizations.dart'; class L10n { static final all = [ @@ -19,22 +26,30 @@ class L10n { 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('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('vi', 'VN'), + const Locale('eu', 'ES'), ]; } diff --git a/lib/main.dart b/lib/main.dart index 01e418dd..f178b663 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,202 +1,166 @@ -import 'package:catcher_2/catcher_2.dart'; -import 'package:dart_discord_rpc/dart_discord_rpc.dart'; -import 'package:device_preview/device_preview.dart'; -import 'package:fl_query/fl_query.dart'; +import 'dart:async'; +import 'dart:ui'; + +import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.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:hive/hive.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:shared_preferences/shared_preferences.dart'; +import 'package:smtc_windows/smtc_windows.dart'; +import 'package:spotube/collections/env.dart'; import 'package:spotube/collections/initializers.dart'; -import 'package:spotube/collections/routes.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/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/models/logger.dart'; -import 'package:spotube/models/skip_segment.dart'; -import 'package:spotube/models/source_match.dart'; -import 'package:spotube/provider/palette_provider.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/connectivity_adapter.dart'; +import 'package:spotube/services/kv_store/encrypted_kv_store.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; -import 'package:spotube/themes/theme.dart'; -import 'package:spotube/utils/persisted_state_notifier.dart'; -import 'package:system_theme/system_theme.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:spotube/hooks/configurators/use_init_sys_tray.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.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"]); - final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); + AppLogger.runZoned(() async { + final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); - await registerWindowsScheme("spotify"); + await registerWindowsScheme("spotify"); - FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); + tz.initializeTimeZones(); - MediaKit.ensureInitialized(); + FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); - // force High Refresh Rate on some Android devices (like One Plus) - if (DesktopTools.platform.isAndroid) { - await FlutterDisplayMode.setHighRefreshRate(); - } + MediaKit.ensureInitialized(); - if (DesktopTools.platform.isDesktop) { - await DesktopTools.window.setPreventClose(true); - } + await migrateMacOsFromSandboxToNoSandbox(); - await SystemTheme.accentColor.load(); + // force High Refresh Rate on some Android devices (like One Plus) + if (kIsAndroid) { + await FlutterDisplayMode.setHighRefreshRate(); + await NewPipeExtractor.init(); + } - if (!kIsWeb) { - MetadataGod.initialize(); - } + if (!kIsWeb) { + MetadataGod.initialize(); + } - if (DesktopTools.platform.isWindows || DesktopTools.platform.isLinux) { - DiscordRPC.initialize(); - } + await KVStoreService.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); + } - final hiveCacheDir = - kIsWeb ? null : (await getApplicationSupportDirectory()).path; + if (kIsWindows) { + await SMTCWindows.initialize(); + } - await QueryClient.initialize( - cachePrefix: "oss.krtirtho.spotube", - cacheDir: hiveCacheDir, - connectivity: FlQueryInternetConnectionCheckerAdapter(), - ); + await EncryptedKvStoreService.initialize(); - Hive.registerAdapter(SkipSegmentAdapter()); + final database = AppDatabase(); - Hive.registerAdapter(SourceMatchAdapter()); - Hive.registerAdapter(SourceTypeAdapter()); + if (kIsDesktop) { + await localNotifier.setup(appName: "Spotube"); + await WindowManagerTools.initialize(); + } - // Cache versioning entities with Adapter - SourceMatch.version = 'v1'; - SkipSegment.version = 'v1'; + if (kIsIOS) { + HomeWidget.setAppGroupId("group.spotube_home_player_widget"); + } - await Hive.openLazyBox( - SourceMatch.boxName, - path: hiveCacheDir, - ); - await Hive.openLazyBox( - SkipSegment.boxName, - path: hiveCacheDir, - ); - await PersistedStateNotifier.initializeBoxes( - path: hiveCacheDir, - ); - - await DesktopTools.ensureInitialized( - DesktopWindowOptions( - hideTitleBar: true, - title: "Spotube", - backgroundColor: Colors.transparent, - minimumSize: const Size(300, 700), - ), - ); - - Catcher2( - enableLogger: arguments["verbose"], - debugConfig: Catcher2Options( - SilentReportMode(), - [ - ConsoleHandler( - enableDeviceParameters: false, - enableApplicationParameters: false, - ), - if (!kIsWeb) FileHandler(await getLogsPath(), printLogs: false), - ], - ), - releaseConfig: Catcher2Options( - SilentReportMode(), - [ - if (arguments["verbose"] ?? false) ConsoleHandler(), - if (!kIsWeb) - FileHandler( - await getLogsPath(), - printLogs: false, - ), - ], - ), - runAppFunction: () { - runApp( - DevicePreview( - availableLocales: L10n.all, - enabled: false, - data: const DevicePreviewData( - isEnabled: false, - orientation: Orientation.portrait, - ), - builder: (context) { - return ProviderScope( - child: QueryClientProvider( - staleDuration: const Duration(minutes: 30), - child: const Spotube(), - ), - ); - }, - ), - ); - }, - ); + runApp( + ProviderScope( + overrides: [ + databaseProvider.overrideWith((ref) => database), + ], + observers: const [ + AppLoggerProviderObserver(), + ], + child: const Spotube(), + ), + ); + }); } -class Spotube extends StatefulHookConsumerWidget { - const Spotube({Key? key}) : super(key: key); +class Spotube extends HookConsumerWidget { + const Spotube({super.key}); @override - SpotubeState createState() => SpotubeState(); - - static SpotubeState of(BuildContext context) => - context.findAncestorStateOfType()!; -} - -class SpotubeState extends ConsumerState { - final logger = getLogger(Spotube); - SharedPreferences? localStorage; - - @override - void initState() { - super.initState(); - SharedPreferences.getInstance().then(((value) => localStorage = value)); - } - - @override - Widget build(BuildContext context) { + 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 isAmoledTheme = - ref.watch(userPreferencesProvider.select((s) => s.amoledDarkTheme)); - final locale = ref.watch(userPreferencesProvider.select((s) => s.locale)); - final paletteColor = - ref.watch(paletteProvider.select((s) => s?.dominantColor?.color)); - final router = ref.watch(routerProvider); + final router = useMemoized(() => AppRouter(ref), []); + final hasTouchSupport = useHasTouch(); + ref.listen(audioPlayerStreamListenersProvider, (_, __) {}); + ref.listen(bonsoirProvider, (_, __) {}); + ref.listen(connectClientsProvider, (_, __) {}); + ref.listen(serverProvider, (_, __) {}); + ref.listen(trayManagerProvider, (_, __) {}); + + useFixWindowStretching(); useDisableBatteryOptimizations(); - useInitSysTray(ref); - useDeepLinking(ref); + 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; @@ -204,20 +168,7 @@ class SpotubeState extends ConsumerState { }; }, []); - final lightTheme = useMemoized( - () => theme(paletteColor ?? accentMaterialColor, Brightness.light, false), - [paletteColor, accentMaterialColor], - ); - final darkTheme = useMemoized( - () => theme( - paletteColor ?? accentMaterialColor, - Brightness.dark, - isAmoledTheme, - ), - [paletteColor, accentMaterialColor, isAmoledTheme], - ); - - return MaterialApp.router( + return ShadcnApp.router( supportedLocales: L10n.all, locale: locale.languageCode == "system" ? null : locale, localizationsDelegates: const [ @@ -226,20 +177,66 @@ class SpotubeState extends ConsumerState { GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], - routerConfig: router, + routerConfig: router.config(), debugShowCheckedModeBanner: false, title: 'Spotube', builder: (context, child) { - return DevicePreview.appBuilder( - context, - DesktopTools.platform.isDesktop - ? DragToResizeArea(child: child!) - : 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) ?? + ColorSchemes.lightOrange(), + surfaceOpacity: .8, + surfaceBlur: 10, + ), + darkTheme: ThemeData( + radius: .5, + iconTheme: const IconThemeProperties(), + colorScheme: + colorSchemeMap[accentMaterialColor.name]?.call(ThemeMode.dark) ?? + ColorSchemes.darkOrange(), + 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, - theme: lightTheme, - darkTheme: darkTheme, shortcuts: { ...WidgetsApp.defaultShortcuts.map((key, value) { return MapEntry( @@ -254,22 +251,42 @@ class SpotubeState extends ConsumerState { LogicalKeyboardKey.digit1, LogicalKeyboardKey.control, LogicalKeyboardKey.shift, - ): HomeTabIntent(ref, tab: HomeTabs.browse), + ): HomeTabIntent(router, tab: HomeTabs.browse), LogicalKeySet( LogicalKeyboardKey.digit2, LogicalKeyboardKey.control, LogicalKeyboardKey.shift, - ): HomeTabIntent(ref, tab: HomeTabs.search), + ): HomeTabIntent(router, tab: HomeTabs.search), LogicalKeySet( LogicalKeyboardKey.digit3, LogicalKeyboardKey.control, LogicalKeyboardKey.shift, - ): HomeTabIntent(ref, tab: HomeTabs.library), + ): HomeTabIntent(router, tab: HomeTabs.lyrics), LogicalKeySet( LogicalKeyboardKey.digit4, LogicalKeyboardKey.control, LogicalKeyboardKey.shift, - ): HomeTabIntent(ref, tab: HomeTabs.lyrics), + ): 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, diff --git a/lib/models/connect/connect.dart b/lib/models/connect/connect.dart new file mode 100644 index 00000000..a70520ad --- /dev/null +++ b/lib/models/connect/connect.dart @@ -0,0 +1,15 @@ +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:spotify/spotify.dart' hide Playlist; +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 new file mode 100644 index 00000000..9103dd2b --- /dev/null +++ b/lib/models/connect/connect.freezed.dart @@ -0,0 +1,631 @@ +// 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 { + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks => throw _privateConstructorUsedError; + Object? get collection => throw _privateConstructorUsedError; + int? get initialIndex => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult when({ + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex) + playlist, + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex) + album, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? 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( + {@JsonKey(name: 'tracks', toJson: _tracksJson) 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( + {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex}); +} + +/// @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 PlaylistSimple?, + initialIndex: freezed == initialIndex + ? _value.initialIndex + : initialIndex // ignore: cast_nullable_to_non_nullable + as int?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$WebSocketLoadEventDataPlaylistImpl + extends WebSocketLoadEventDataPlaylist { + _$WebSocketLoadEventDataPlaylistImpl( + {@JsonKey(name: 'tracks', toJson: _tracksJson) + 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 + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks { + if (_tracks is EqualUnmodifiableListView) return _tracks; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tracks); + } + + @override + final PlaylistSimple? 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( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex) + playlist, + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex) + album, + }) { + return playlist(tracks, collection, initialIndex); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + }) { + return playlist?.call(tracks, collection, initialIndex); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? 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( + {@JsonKey(name: 'tracks', toJson: _tracksJson) + required final List tracks, + final PlaylistSimple? collection, + final int? initialIndex}) = _$WebSocketLoadEventDataPlaylistImpl; + WebSocketLoadEventDataPlaylist._() : super._(); + + factory WebSocketLoadEventDataPlaylist.fromJson(Map json) = + _$WebSocketLoadEventDataPlaylistImpl.fromJson; + + @override + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks; + @override + PlaylistSimple? 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( + {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex}); +} + +/// @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 AlbumSimple?, + initialIndex: freezed == initialIndex + ? _value.initialIndex + : initialIndex // ignore: cast_nullable_to_non_nullable + as int?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$WebSocketLoadEventDataAlbumImpl extends WebSocketLoadEventDataAlbum { + _$WebSocketLoadEventDataAlbumImpl( + {@JsonKey(name: 'tracks', toJson: _tracksJson) + 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 + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks { + if (_tracks is EqualUnmodifiableListView) return _tracks; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tracks); + } + + @override + final AlbumSimple? 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( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex) + playlist, + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex) + album, + }) { + return album(tracks, collection, initialIndex); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + }) { + return album?.call(tracks, collection, initialIndex); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? 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( + {@JsonKey(name: 'tracks', toJson: _tracksJson) + required final List tracks, + final AlbumSimple? collection, + final int? initialIndex}) = _$WebSocketLoadEventDataAlbumImpl; + WebSocketLoadEventDataAlbum._() : super._(); + + factory WebSocketLoadEventDataAlbum.fromJson(Map json) = + _$WebSocketLoadEventDataAlbumImpl.fromJson; + + @override + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks; + @override + AlbumSimple? 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 new file mode 100644 index 00000000..10f46c65 --- /dev/null +++ b/lib/models/connect/connect.g.dart @@ -0,0 +1,53 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'connect.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$WebSocketLoadEventDataPlaylistImpl + _$$WebSocketLoadEventDataPlaylistImplFromJson(Map json) => + _$WebSocketLoadEventDataPlaylistImpl( + tracks: (json['tracks'] as List) + .map((e) => Track.fromJson(Map.from(e as Map))) + .toList(), + collection: json['collection'] == null + ? null + : PlaylistSimple.fromJson( + Map.from(json['collection'] as Map)), + initialIndex: (json['initialIndex'] as num?)?.toInt(), + $type: json['runtimeType'] as String?, + ); + +Map _$$WebSocketLoadEventDataPlaylistImplToJson( + _$WebSocketLoadEventDataPlaylistImpl instance) => + { + 'tracks': _tracksJson(instance.tracks), + 'collection': instance.collection?.toJson(), + 'initialIndex': instance.initialIndex, + 'runtimeType': instance.$type, + }; + +_$WebSocketLoadEventDataAlbumImpl _$$WebSocketLoadEventDataAlbumImplFromJson( + Map json) => + _$WebSocketLoadEventDataAlbumImpl( + tracks: (json['tracks'] as List) + .map((e) => Track.fromJson(Map.from(e as Map))) + .toList(), + collection: json['collection'] == null + ? null + : AlbumSimple.fromJson( + Map.from(json['collection'] as Map)), + initialIndex: (json['initialIndex'] as num?)?.toInt(), + $type: json['runtimeType'] as String?, + ); + +Map _$$WebSocketLoadEventDataAlbumImplToJson( + _$WebSocketLoadEventDataAlbumImpl instance) => + { + 'tracks': _tracksJson(instance.tracks), + 'collection': instance.collection?.toJson(), + 'initialIndex': instance.initialIndex, + 'runtimeType': instance.$type, + }; diff --git a/lib/models/connect/load.dart b/lib/models/connect/load.dart new file mode 100644 index 00000000..bf0e164d --- /dev/null +++ b/lib/models/connect/load.dart @@ -0,0 +1,40 @@ +part of 'connect.dart'; + +List> _tracksJson(List tracks) { + return tracks.map((e) => e.toJson()).toList(); +} + +@freezed +class WebSocketLoadEventData with _$WebSocketLoadEventData { + const WebSocketLoadEventData._(); + + factory WebSocketLoadEventData.playlist({ + @JsonKey(name: 'tracks', toJson: _tracksJson) required List tracks, + PlaylistSimple? collection, + int? initialIndex, + }) = WebSocketLoadEventDataPlaylist; + + factory WebSocketLoadEventData.album({ + @JsonKey(name: 'tracks', toJson: _tracksJson) required List tracks, + AlbumSimple? 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 new file mode 100644 index 00000000..d1047646 --- /dev/null +++ b/lib/models/connect/ws_event.dart @@ -0,0 +1,378 @@ +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(Track data) : super(WsEvent.addTrack, data); + + WebSocketAddTrackEvent.fromJson(Map json) + : super( + WsEvent.addTrack, + Track.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/current_playlist.dart b/lib/models/current_playlist.dart index 53ea2799..7e55e393 100644 --- a/lib/models/current_playlist.dart +++ b/lib/models/current_playlist.dart @@ -1,6 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; class CurrentPlaylist { diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart new file mode 100644 index 00000000..151bbc78 --- /dev/null +++ b/lib/models/database/database.dart @@ -0,0 +1,151 @@ +library database; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:drift/drift.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:spotify/spotify.dart' hide Playlist; +import 'package:spotube/models/database/database.steps.dart'; +import 'package:spotube/models/lyrics.dart'; +import 'package:spotube/services/kv_store/encrypted_kv_store.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; +import 'package:spotube/services/sourced_track/enums.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/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: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 'typeconverters/color.dart'; +part 'typeconverters/locale.dart'; +part 'typeconverters/string_list.dart'; +part 'typeconverters/encrypted_text.dart'; +part 'typeconverters/map.dart'; +part 'typeconverters/subtitle.dart'; + +@DriftDatabase( + tables: [ + AuthenticationTable, + BlacklistTable, + PreferencesTable, + ScrobblerTable, + SkipSegmentTable, + SourceMatchTable, + AudioPlayerStateTable, + PlaylistTable, + PlaylistMediaTable, + HistoryTable, + LyricsTable, + ], +) +class AppDatabase extends _$AppDatabase { + AppDatabase() : super(_openConnection()); + + @override + int get schemaVersion => 6; + + @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 'Orange:0xFFf97315'", + ); + await customStatement( + "UPDATE $tableName " + "SET $columnName = $columnNameOld", + ); + await customStatement( + "ALTER TABLE $tableName " + "DROP COLUMN $columnNameOld", + ); + await customStatement( + "UPDATE $tableName " + "SET $columnName = 'Orange:0xFFf97315' WHERE $columnName = 'Blue:0xFF2196F3'", + ); + }, + from5To6: (m, schema) async { + // Add new column to preferences table + await m.addColumn( + schema.preferencesTable, + schema.preferencesTable.connectPort, + ); + }, + ), + ); + } +} + +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 new file mode 100644 index 00000000..5dfe8db8 --- /dev/null +++ b/lib/models/database/database.g.dart @@ -0,0 +1,6938 @@ +// 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')); + static const VerificationMeta _cookieMeta = const VerificationMeta('cookie'); + @override + late final GeneratedColumnWithTypeConverter cookie = + GeneratedColumn('cookie', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) + .withConverter( + $AuthenticationTableTable.$convertercookie); + static const VerificationMeta _accessTokenMeta = + const VerificationMeta('accessToken'); + @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)); + } + context.handle(_cookieMeta, const VerificationResult.success()); + context.handle(_accessTokenMeta, const VerificationResult.success()); + 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); + static const VerificationMeta _elementTypeMeta = + const VerificationMeta('elementType'); + @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); + } + context.handle(_elementTypeMeta, const VerificationResult.success()); + 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 _audioQualityMeta = + const VerificationMeta('audioQuality'); + @override + late final GeneratedColumnWithTypeConverter + audioQuality = GeneratedColumn( + 'audio_quality', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(SourceQualities.high.name)) + .withConverter( + $PreferencesTableTable.$converteraudioQuality); + 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)); + static const VerificationMeta _closeBehaviorMeta = + const VerificationMeta('closeBehavior'); + @override + late final GeneratedColumnWithTypeConverter + closeBehavior = GeneratedColumn( + 'close_behavior', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(CloseBehavior.close.name)) + .withConverter( + $PreferencesTableTable.$convertercloseBehavior); + static const VerificationMeta _accentColorSchemeMeta = + const VerificationMeta('accentColorScheme'); + @override + late final GeneratedColumnWithTypeConverter + accentColorScheme = GeneratedColumn( + 'accent_color_scheme', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant("Orange:0xFFf97315")) + .withConverter( + $PreferencesTableTable.$converteraccentColorScheme); + static const VerificationMeta _layoutModeMeta = + const VerificationMeta('layoutMode'); + @override + late final GeneratedColumnWithTypeConverter layoutMode = + GeneratedColumn('layout_mode', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(LayoutMode.adaptive.name)) + .withConverter( + $PreferencesTableTable.$converterlayoutMode); + static const VerificationMeta _localeMeta = const VerificationMeta('locale'); + @override + late final GeneratedColumnWithTypeConverter locale = + GeneratedColumn('locale', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant( + '{"languageCode":"system","countryCode":"system"}')) + .withConverter($PreferencesTableTable.$converterlocale); + static const VerificationMeta _marketMeta = const VerificationMeta('market'); + @override + late final GeneratedColumnWithTypeConverter market = + GeneratedColumn('market', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(Market.US.name)) + .withConverter($PreferencesTableTable.$convertermarket); + static const VerificationMeta _searchModeMeta = + const VerificationMeta('searchMode'); + @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("")); + static const VerificationMeta _localLibraryLocationMeta = + const VerificationMeta('localLibraryLocation'); + @override + late final GeneratedColumnWithTypeConverter, String> + localLibraryLocation = GeneratedColumn( + 'local_library_location', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant("")) + .withConverter>( + $PreferencesTableTable.$converterlocalLibraryLocation); + static const VerificationMeta _pipedInstanceMeta = + const VerificationMeta('pipedInstance'); + @override + late final GeneratedColumn pipedInstance = GeneratedColumn( + 'piped_instance', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant("https://pipedapi.kavin.rocks")); + static const VerificationMeta _invidiousInstanceMeta = + const VerificationMeta('invidiousInstance'); + @override + late final GeneratedColumn invidiousInstance = + GeneratedColumn('invidious_instance', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant("https://inv.nadeko.net")); + static const VerificationMeta _themeModeMeta = + const VerificationMeta('themeMode'); + @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 _audioSourceMeta = + const VerificationMeta('audioSource'); + @override + late final GeneratedColumnWithTypeConverter audioSource = + GeneratedColumn('audio_source', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(AudioSource.youtube.name)) + .withConverter( + $PreferencesTableTable.$converteraudioSource); + static const VerificationMeta _youtubeClientEngineMeta = + const VerificationMeta('youtubeClientEngine'); + @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 _streamMusicCodecMeta = + const VerificationMeta('streamMusicCodec'); + @override + late final GeneratedColumnWithTypeConverter + streamMusicCodec = GeneratedColumn( + 'stream_music_codec', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(SourceCodecs.weba.name)) + .withConverter( + $PreferencesTableTable.$converterstreamMusicCodec); + static const VerificationMeta _downloadMusicCodecMeta = + const VerificationMeta('downloadMusicCodec'); + @override + late final GeneratedColumnWithTypeConverter + downloadMusicCodec = GeneratedColumn( + 'download_music_codec', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(SourceCodecs.m4a.name)) + .withConverter( + $PreferencesTableTable.$converterdownloadMusicCodec); + 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, + audioQuality, + albumColorSync, + amoledDarkTheme, + checkUpdate, + normalizeAudio, + showSystemTrayIcon, + systemTitleBar, + skipNonMusic, + closeBehavior, + accentColorScheme, + layoutMode, + locale, + market, + searchMode, + downloadLocation, + localLibraryLocation, + pipedInstance, + invidiousInstance, + themeMode, + audioSource, + youtubeClientEngine, + streamMusicCodec, + downloadMusicCodec, + 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)); + } + context.handle(_audioQualityMeta, const VerificationResult.success()); + 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)); + } + context.handle(_closeBehaviorMeta, const VerificationResult.success()); + context.handle(_accentColorSchemeMeta, const VerificationResult.success()); + context.handle(_layoutModeMeta, const VerificationResult.success()); + context.handle(_localeMeta, const VerificationResult.success()); + context.handle(_marketMeta, const VerificationResult.success()); + context.handle(_searchModeMeta, const VerificationResult.success()); + if (data.containsKey('download_location')) { + context.handle( + _downloadLocationMeta, + downloadLocation.isAcceptableOrUnknown( + data['download_location']!, _downloadLocationMeta)); + } + context.handle( + _localLibraryLocationMeta, const VerificationResult.success()); + if (data.containsKey('piped_instance')) { + context.handle( + _pipedInstanceMeta, + pipedInstance.isAcceptableOrUnknown( + data['piped_instance']!, _pipedInstanceMeta)); + } + if (data.containsKey('invidious_instance')) { + context.handle( + _invidiousInstanceMeta, + invidiousInstance.isAcceptableOrUnknown( + data['invidious_instance']!, _invidiousInstanceMeta)); + } + context.handle(_themeModeMeta, const VerificationResult.success()); + context.handle(_audioSourceMeta, const VerificationResult.success()); + context.handle( + _youtubeClientEngineMeta, const VerificationResult.success()); + context.handle(_streamMusicCodecMeta, const VerificationResult.success()); + context.handle(_downloadMusicCodecMeta, const VerificationResult.success()); + 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'])!, + audioQuality: $PreferencesTableTable.$converteraudioQuality.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}audio_quality'])!), + 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'])!), + pipedInstance: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}piped_instance'])!, + invidiousInstance: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}invidious_instance'])!, + themeMode: $PreferencesTableTable.$converterthemeMode.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}theme_mode'])!), + audioSource: $PreferencesTableTable.$converteraudioSource.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}audio_source'])!), + youtubeClientEngine: $PreferencesTableTable.$converteryoutubeClientEngine + .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, + data['${effectivePrefix}youtube_client_engine'])!), + streamMusicCodec: $PreferencesTableTable.$converterstreamMusicCodec + .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, + data['${effectivePrefix}stream_music_codec'])!), + downloadMusicCodec: $PreferencesTableTable.$converterdownloadMusicCodec + .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, + data['${effectivePrefix}download_music_codec'])!), + 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 + $converteraudioQuality = + const EnumNameConverter(SourceQualities.values); + 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 $converteraudioSource = + const EnumNameConverter(AudioSource.values); + static JsonTypeConverter2 + $converteryoutubeClientEngine = + const EnumNameConverter(YoutubeClientEngine.values); + static JsonTypeConverter2 + $converterstreamMusicCodec = + const EnumNameConverter(SourceCodecs.values); + static JsonTypeConverter2 + $converterdownloadMusicCodec = + const EnumNameConverter(SourceCodecs.values); +} + +class PreferencesTableData extends DataClass + implements Insertable { + final int id; + final SourceQualities audioQuality; + 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 String pipedInstance; + final String invidiousInstance; + final ThemeMode themeMode; + final AudioSource audioSource; + final YoutubeClientEngine youtubeClientEngine; + final SourceCodecs streamMusicCodec; + final SourceCodecs downloadMusicCodec; + final bool discordPresence; + final bool endlessPlayback; + final bool enableConnect; + final int connectPort; + final bool cacheMusic; + const PreferencesTableData( + {required this.id, + required this.audioQuality, + 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.pipedInstance, + required this.invidiousInstance, + required this.themeMode, + required this.audioSource, + required this.youtubeClientEngine, + required this.streamMusicCodec, + required this.downloadMusicCodec, + 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['audio_quality'] = Variable( + $PreferencesTableTable.$converteraudioQuality.toSql(audioQuality)); + } + 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['piped_instance'] = Variable(pipedInstance); + map['invidious_instance'] = Variable(invidiousInstance); + { + map['theme_mode'] = Variable( + $PreferencesTableTable.$converterthemeMode.toSql(themeMode)); + } + { + map['audio_source'] = Variable( + $PreferencesTableTable.$converteraudioSource.toSql(audioSource)); + } + { + map['youtube_client_engine'] = Variable($PreferencesTableTable + .$converteryoutubeClientEngine + .toSql(youtubeClientEngine)); + } + { + map['stream_music_codec'] = Variable($PreferencesTableTable + .$converterstreamMusicCodec + .toSql(streamMusicCodec)); + } + { + map['download_music_codec'] = Variable($PreferencesTableTable + .$converterdownloadMusicCodec + .toSql(downloadMusicCodec)); + } + 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), + audioQuality: Value(audioQuality), + 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), + pipedInstance: Value(pipedInstance), + invidiousInstance: Value(invidiousInstance), + themeMode: Value(themeMode), + audioSource: Value(audioSource), + youtubeClientEngine: Value(youtubeClientEngine), + streamMusicCodec: Value(streamMusicCodec), + downloadMusicCodec: Value(downloadMusicCodec), + 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']), + audioQuality: $PreferencesTableTable.$converteraudioQuality + .fromJson(serializer.fromJson(json['audioQuality'])), + 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']), + pipedInstance: serializer.fromJson(json['pipedInstance']), + invidiousInstance: serializer.fromJson(json['invidiousInstance']), + themeMode: $PreferencesTableTable.$converterthemeMode + .fromJson(serializer.fromJson(json['themeMode'])), + audioSource: $PreferencesTableTable.$converteraudioSource + .fromJson(serializer.fromJson(json['audioSource'])), + youtubeClientEngine: $PreferencesTableTable.$converteryoutubeClientEngine + .fromJson(serializer.fromJson(json['youtubeClientEngine'])), + streamMusicCodec: $PreferencesTableTable.$converterstreamMusicCodec + .fromJson(serializer.fromJson(json['streamMusicCodec'])), + downloadMusicCodec: $PreferencesTableTable.$converterdownloadMusicCodec + .fromJson(serializer.fromJson(json['downloadMusicCodec'])), + 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), + 'audioQuality': serializer.toJson( + $PreferencesTableTable.$converteraudioQuality.toJson(audioQuality)), + '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), + 'pipedInstance': serializer.toJson(pipedInstance), + 'invidiousInstance': serializer.toJson(invidiousInstance), + 'themeMode': serializer.toJson( + $PreferencesTableTable.$converterthemeMode.toJson(themeMode)), + 'audioSource': serializer.toJson( + $PreferencesTableTable.$converteraudioSource.toJson(audioSource)), + 'youtubeClientEngine': serializer.toJson($PreferencesTableTable + .$converteryoutubeClientEngine + .toJson(youtubeClientEngine)), + 'streamMusicCodec': serializer.toJson($PreferencesTableTable + .$converterstreamMusicCodec + .toJson(streamMusicCodec)), + 'downloadMusicCodec': serializer.toJson($PreferencesTableTable + .$converterdownloadMusicCodec + .toJson(downloadMusicCodec)), + 'discordPresence': serializer.toJson(discordPresence), + 'endlessPlayback': serializer.toJson(endlessPlayback), + 'enableConnect': serializer.toJson(enableConnect), + 'connectPort': serializer.toJson(connectPort), + 'cacheMusic': serializer.toJson(cacheMusic), + }; + } + + PreferencesTableData copyWith( + {int? id, + SourceQualities? audioQuality, + 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, + String? pipedInstance, + String? invidiousInstance, + ThemeMode? themeMode, + AudioSource? audioSource, + YoutubeClientEngine? youtubeClientEngine, + SourceCodecs? streamMusicCodec, + SourceCodecs? downloadMusicCodec, + bool? discordPresence, + bool? endlessPlayback, + bool? enableConnect, + int? connectPort, + bool? cacheMusic}) => + PreferencesTableData( + id: id ?? this.id, + audioQuality: audioQuality ?? this.audioQuality, + 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, + pipedInstance: pipedInstance ?? this.pipedInstance, + invidiousInstance: invidiousInstance ?? this.invidiousInstance, + themeMode: themeMode ?? this.themeMode, + audioSource: audioSource ?? this.audioSource, + youtubeClientEngine: youtubeClientEngine ?? this.youtubeClientEngine, + streamMusicCodec: streamMusicCodec ?? this.streamMusicCodec, + downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, + 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, + audioQuality: data.audioQuality.present + ? data.audioQuality.value + : this.audioQuality, + 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, + pipedInstance: data.pipedInstance.present + ? data.pipedInstance.value + : this.pipedInstance, + invidiousInstance: data.invidiousInstance.present + ? data.invidiousInstance.value + : this.invidiousInstance, + themeMode: data.themeMode.present ? data.themeMode.value : this.themeMode, + audioSource: + data.audioSource.present ? data.audioSource.value : this.audioSource, + youtubeClientEngine: data.youtubeClientEngine.present + ? data.youtubeClientEngine.value + : this.youtubeClientEngine, + streamMusicCodec: data.streamMusicCodec.present + ? data.streamMusicCodec.value + : this.streamMusicCodec, + downloadMusicCodec: data.downloadMusicCodec.present + ? data.downloadMusicCodec.value + : this.downloadMusicCodec, + 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('audioQuality: $audioQuality, ') + ..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('pipedInstance: $pipedInstance, ') + ..write('invidiousInstance: $invidiousInstance, ') + ..write('themeMode: $themeMode, ') + ..write('audioSource: $audioSource, ') + ..write('youtubeClientEngine: $youtubeClientEngine, ') + ..write('streamMusicCodec: $streamMusicCodec, ') + ..write('downloadMusicCodec: $downloadMusicCodec, ') + ..write('discordPresence: $discordPresence, ') + ..write('endlessPlayback: $endlessPlayback, ') + ..write('enableConnect: $enableConnect, ') + ..write('connectPort: $connectPort, ') + ..write('cacheMusic: $cacheMusic') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hashAll([ + id, + audioQuality, + albumColorSync, + amoledDarkTheme, + checkUpdate, + normalizeAudio, + showSystemTrayIcon, + systemTitleBar, + skipNonMusic, + closeBehavior, + accentColorScheme, + layoutMode, + locale, + market, + searchMode, + downloadLocation, + localLibraryLocation, + pipedInstance, + invidiousInstance, + themeMode, + audioSource, + youtubeClientEngine, + streamMusicCodec, + downloadMusicCodec, + discordPresence, + endlessPlayback, + enableConnect, + connectPort, + cacheMusic + ]); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PreferencesTableData && + other.id == this.id && + other.audioQuality == this.audioQuality && + 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.pipedInstance == this.pipedInstance && + other.invidiousInstance == this.invidiousInstance && + other.themeMode == this.themeMode && + other.audioSource == this.audioSource && + other.youtubeClientEngine == this.youtubeClientEngine && + other.streamMusicCodec == this.streamMusicCodec && + other.downloadMusicCodec == this.downloadMusicCodec && + 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 audioQuality; + 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 pipedInstance; + final Value invidiousInstance; + final Value themeMode; + final Value audioSource; + final Value youtubeClientEngine; + final Value streamMusicCodec; + final Value downloadMusicCodec; + final Value discordPresence; + final Value endlessPlayback; + final Value enableConnect; + final Value connectPort; + final Value cacheMusic; + const PreferencesTableCompanion({ + this.id = const Value.absent(), + this.audioQuality = 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.pipedInstance = const Value.absent(), + this.invidiousInstance = const Value.absent(), + this.themeMode = const Value.absent(), + this.audioSource = const Value.absent(), + this.youtubeClientEngine = const Value.absent(), + this.streamMusicCodec = const Value.absent(), + this.downloadMusicCodec = 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.audioQuality = 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.pipedInstance = const Value.absent(), + this.invidiousInstance = const Value.absent(), + this.themeMode = const Value.absent(), + this.audioSource = const Value.absent(), + this.youtubeClientEngine = const Value.absent(), + this.streamMusicCodec = const Value.absent(), + this.downloadMusicCodec = 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? audioQuality, + 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? pipedInstance, + Expression? invidiousInstance, + Expression? themeMode, + Expression? audioSource, + Expression? youtubeClientEngine, + Expression? streamMusicCodec, + Expression? downloadMusicCodec, + Expression? discordPresence, + Expression? endlessPlayback, + Expression? enableConnect, + Expression? connectPort, + Expression? cacheMusic, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (audioQuality != null) 'audio_quality': audioQuality, + 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 (pipedInstance != null) 'piped_instance': pipedInstance, + if (invidiousInstance != null) 'invidious_instance': invidiousInstance, + if (themeMode != null) 'theme_mode': themeMode, + if (audioSource != null) 'audio_source': audioSource, + if (youtubeClientEngine != null) + 'youtube_client_engine': youtubeClientEngine, + if (streamMusicCodec != null) 'stream_music_codec': streamMusicCodec, + if (downloadMusicCodec != null) + 'download_music_codec': downloadMusicCodec, + 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? audioQuality, + 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? pipedInstance, + Value? invidiousInstance, + Value? themeMode, + Value? audioSource, + Value? youtubeClientEngine, + Value? streamMusicCodec, + Value? downloadMusicCodec, + Value? discordPresence, + Value? endlessPlayback, + Value? enableConnect, + Value? connectPort, + Value? cacheMusic}) { + return PreferencesTableCompanion( + id: id ?? this.id, + audioQuality: audioQuality ?? this.audioQuality, + 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, + pipedInstance: pipedInstance ?? this.pipedInstance, + invidiousInstance: invidiousInstance ?? this.invidiousInstance, + themeMode: themeMode ?? this.themeMode, + audioSource: audioSource ?? this.audioSource, + youtubeClientEngine: youtubeClientEngine ?? this.youtubeClientEngine, + streamMusicCodec: streamMusicCodec ?? this.streamMusicCodec, + downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, + 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 (audioQuality.present) { + map['audio_quality'] = Variable($PreferencesTableTable + .$converteraudioQuality + .toSql(audioQuality.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 (pipedInstance.present) { + map['piped_instance'] = Variable(pipedInstance.value); + } + if (invidiousInstance.present) { + map['invidious_instance'] = Variable(invidiousInstance.value); + } + if (themeMode.present) { + map['theme_mode'] = Variable( + $PreferencesTableTable.$converterthemeMode.toSql(themeMode.value)); + } + if (audioSource.present) { + map['audio_source'] = Variable($PreferencesTableTable + .$converteraudioSource + .toSql(audioSource.value)); + } + if (youtubeClientEngine.present) { + map['youtube_client_engine'] = Variable($PreferencesTableTable + .$converteryoutubeClientEngine + .toSql(youtubeClientEngine.value)); + } + if (streamMusicCodec.present) { + map['stream_music_codec'] = Variable($PreferencesTableTable + .$converterstreamMusicCodec + .toSql(streamMusicCodec.value)); + } + if (downloadMusicCodec.present) { + map['download_music_codec'] = Variable($PreferencesTableTable + .$converterdownloadMusicCodec + .toSql(downloadMusicCodec.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('audioQuality: $audioQuality, ') + ..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('pipedInstance: $pipedInstance, ') + ..write('invidiousInstance: $invidiousInstance, ') + ..write('themeMode: $themeMode, ') + ..write('audioSource: $audioSource, ') + ..write('youtubeClientEngine: $youtubeClientEngine, ') + ..write('streamMusicCodec: $streamMusicCodec, ') + ..write('downloadMusicCodec: $downloadMusicCodec, ') + ..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); + static const VerificationMeta _passwordHashMeta = + const VerificationMeta('passwordHash'); + @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); + } + context.handle(_passwordHashMeta, const VerificationResult.success()); + 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 _sourceIdMeta = + const VerificationMeta('sourceId'); + @override + late final GeneratedColumn sourceId = GeneratedColumn( + 'source_id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _sourceTypeMeta = + const VerificationMeta('sourceType'); + @override + late final GeneratedColumnWithTypeConverter sourceType = + GeneratedColumn('source_type', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(SourceType.youtube.name)) + .withConverter( + $SourceMatchTableTable.$convertersourceType); + 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, sourceId, 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_id')) { + context.handle(_sourceIdMeta, + sourceId.isAcceptableOrUnknown(data['source_id']!, _sourceIdMeta)); + } else if (isInserting) { + context.missing(_sourceIdMeta); + } + context.handle(_sourceTypeMeta, const VerificationResult.success()); + 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'])!, + sourceId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}source_id'])!, + sourceType: $SourceMatchTableTable.$convertersourceType.fromSql( + 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); + } + + static JsonTypeConverter2 $convertersourceType = + const EnumNameConverter(SourceType.values); +} + +class SourceMatchTableData extends DataClass + implements Insertable { + final int id; + final String trackId; + final String sourceId; + final SourceType sourceType; + final DateTime createdAt; + const SourceMatchTableData( + {required this.id, + required this.trackId, + required this.sourceId, + required this.sourceType, + required this.createdAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['track_id'] = Variable(trackId); + map['source_id'] = Variable(sourceId); + { + map['source_type'] = Variable( + $SourceMatchTableTable.$convertersourceType.toSql(sourceType)); + } + map['created_at'] = Variable(createdAt); + return map; + } + + SourceMatchTableCompanion toCompanion(bool nullToAbsent) { + return SourceMatchTableCompanion( + id: Value(id), + trackId: Value(trackId), + sourceId: Value(sourceId), + 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']), + sourceId: serializer.fromJson(json['sourceId']), + sourceType: $SourceMatchTableTable.$convertersourceType + .fromJson(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), + 'sourceId': serializer.toJson(sourceId), + 'sourceType': serializer.toJson( + $SourceMatchTableTable.$convertersourceType.toJson(sourceType)), + 'createdAt': serializer.toJson(createdAt), + }; + } + + SourceMatchTableData copyWith( + {int? id, + String? trackId, + String? sourceId, + SourceType? sourceType, + DateTime? createdAt}) => + SourceMatchTableData( + id: id ?? this.id, + trackId: trackId ?? this.trackId, + sourceId: sourceId ?? this.sourceId, + 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, + sourceId: data.sourceId.present ? data.sourceId.value : this.sourceId, + 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('sourceId: $sourceId, ') + ..write('sourceType: $sourceType, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, trackId, sourceId, sourceType, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SourceMatchTableData && + other.id == this.id && + other.trackId == this.trackId && + other.sourceId == this.sourceId && + other.sourceType == this.sourceType && + other.createdAt == this.createdAt); +} + +class SourceMatchTableCompanion extends UpdateCompanion { + final Value id; + final Value trackId; + final Value sourceId; + final Value sourceType; + final Value createdAt; + const SourceMatchTableCompanion({ + this.id = const Value.absent(), + this.trackId = const Value.absent(), + this.sourceId = const Value.absent(), + this.sourceType = const Value.absent(), + this.createdAt = const Value.absent(), + }); + SourceMatchTableCompanion.insert({ + this.id = const Value.absent(), + required String trackId, + required String sourceId, + this.sourceType = const Value.absent(), + this.createdAt = const Value.absent(), + }) : trackId = Value(trackId), + sourceId = Value(sourceId); + static Insertable custom({ + Expression? id, + Expression? trackId, + Expression? sourceId, + Expression? sourceType, + Expression? createdAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (trackId != null) 'track_id': trackId, + if (sourceId != null) 'source_id': sourceId, + if (sourceType != null) 'source_type': sourceType, + if (createdAt != null) 'created_at': createdAt, + }); + } + + SourceMatchTableCompanion copyWith( + {Value? id, + Value? trackId, + Value? sourceId, + Value? sourceType, + Value? createdAt}) { + return SourceMatchTableCompanion( + id: id ?? this.id, + trackId: trackId ?? this.trackId, + sourceId: sourceId ?? this.sourceId, + 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 (sourceId.present) { + map['source_id'] = Variable(sourceId.value); + } + if (sourceType.present) { + map['source_type'] = Variable( + $SourceMatchTableTable.$convertersourceType.toSql(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('sourceId: $sourceId, ') + ..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))')); + static const VerificationMeta _loopModeMeta = + const VerificationMeta('loopMode'); + @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))')); + static const VerificationMeta _collectionsMeta = + const VerificationMeta('collections'); + @override + late final GeneratedColumnWithTypeConverter, String> + collections = GeneratedColumn('collections', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) + .withConverter>( + $AudioPlayerStateTableTable.$convertercollections); + @override + List get $columns => + [id, playing, loopMode, shuffled, collections]; + @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); + } + context.handle(_loopModeMeta, const VerificationResult.success()); + if (data.containsKey('shuffled')) { + context.handle(_shuffledMeta, + shuffled.isAcceptableOrUnknown(data['shuffled']!, _shuffledMeta)); + } else if (isInserting) { + context.missing(_shuffledMeta); + } + context.handle(_collectionsMeta, const VerificationResult.success()); + 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'])!), + ); + } + + @override + $AudioPlayerStateTableTable createAlias(String alias) { + return $AudioPlayerStateTableTable(attachedDatabase, alias); + } + + static JsonTypeConverter2 $converterloopMode = + const EnumNameConverter(PlaylistMode.values); + static TypeConverter, String> $convertercollections = + const StringListConverter(); +} + +class AudioPlayerStateTableData extends DataClass + implements Insertable { + final int id; + final bool playing; + final PlaylistMode loopMode; + final bool shuffled; + final List collections; + const AudioPlayerStateTableData( + {required this.id, + required this.playing, + required this.loopMode, + required this.shuffled, + required this.collections}); + @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)); + } + return map; + } + + AudioPlayerStateTableCompanion toCompanion(bool nullToAbsent) { + return AudioPlayerStateTableCompanion( + id: Value(id), + playing: Value(playing), + loopMode: Value(loopMode), + shuffled: Value(shuffled), + collections: Value(collections), + ); + } + + 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']), + ); + } + @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), + }; + } + + AudioPlayerStateTableData copyWith( + {int? id, + bool? playing, + PlaylistMode? loopMode, + bool? shuffled, + List? collections}) => + AudioPlayerStateTableData( + id: id ?? this.id, + playing: playing ?? this.playing, + loopMode: loopMode ?? this.loopMode, + shuffled: shuffled ?? this.shuffled, + collections: collections ?? this.collections, + ); + 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, + ); + } + + @override + String toString() { + return (StringBuffer('AudioPlayerStateTableData(') + ..write('id: $id, ') + ..write('playing: $playing, ') + ..write('loopMode: $loopMode, ') + ..write('shuffled: $shuffled, ') + ..write('collections: $collections') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, playing, loopMode, shuffled, collections); + @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); +} + +class AudioPlayerStateTableCompanion + extends UpdateCompanion { + final Value id; + final Value playing; + final Value loopMode; + final Value shuffled; + final Value> collections; + 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(), + }); + AudioPlayerStateTableCompanion.insert({ + this.id = const Value.absent(), + required bool playing, + required PlaylistMode loopMode, + required bool shuffled, + required List collections, + }) : 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, + }) { + 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, + }); + } + + AudioPlayerStateTableCompanion copyWith( + {Value? id, + Value? playing, + Value? loopMode, + Value? shuffled, + Value>? collections}) { + return AudioPlayerStateTableCompanion( + id: id ?? this.id, + playing: playing ?? this.playing, + loopMode: loopMode ?? this.loopMode, + shuffled: shuffled ?? this.shuffled, + collections: collections ?? this.collections, + ); + } + + @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)); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AudioPlayerStateTableCompanion(') + ..write('id: $id, ') + ..write('playing: $playing, ') + ..write('loopMode: $loopMode, ') + ..write('shuffled: $shuffled, ') + ..write('collections: $collections') + ..write(')')) + .toString(); + } +} + +class $PlaylistTableTable extends PlaylistTable + with TableInfo<$PlaylistTableTable, PlaylistTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $PlaylistTableTable(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 _audioPlayerStateIdMeta = + const VerificationMeta('audioPlayerStateId'); + @override + late final GeneratedColumn audioPlayerStateId = GeneratedColumn( + 'audio_player_state_id', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES audio_player_state_table (id)')); + static const VerificationMeta _indexMeta = const VerificationMeta('index'); + @override + late final GeneratedColumn index = GeneratedColumn( + 'index', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + @override + List get $columns => [id, audioPlayerStateId, index]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'playlist_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('audio_player_state_id')) { + context.handle( + _audioPlayerStateIdMeta, + audioPlayerStateId.isAcceptableOrUnknown( + data['audio_player_state_id']!, _audioPlayerStateIdMeta)); + } else if (isInserting) { + context.missing(_audioPlayerStateIdMeta); + } + if (data.containsKey('index')) { + context.handle( + _indexMeta, index.isAcceptableOrUnknown(data['index']!, _indexMeta)); + } else if (isInserting) { + context.missing(_indexMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + PlaylistTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PlaylistTableData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + audioPlayerStateId: attachedDatabase.typeMapping.read( + DriftSqlType.int, data['${effectivePrefix}audio_player_state_id'])!, + index: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}index'])!, + ); + } + + @override + $PlaylistTableTable createAlias(String alias) { + return $PlaylistTableTable(attachedDatabase, alias); + } +} + +class PlaylistTableData extends DataClass + implements Insertable { + final int id; + final int audioPlayerStateId; + final int index; + const PlaylistTableData( + {required this.id, + required this.audioPlayerStateId, + required this.index}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['audio_player_state_id'] = Variable(audioPlayerStateId); + map['index'] = Variable(index); + return map; + } + + PlaylistTableCompanion toCompanion(bool nullToAbsent) { + return PlaylistTableCompanion( + id: Value(id), + audioPlayerStateId: Value(audioPlayerStateId), + index: Value(index), + ); + } + + factory PlaylistTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PlaylistTableData( + id: serializer.fromJson(json['id']), + audioPlayerStateId: serializer.fromJson(json['audioPlayerStateId']), + index: serializer.fromJson(json['index']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'audioPlayerStateId': serializer.toJson(audioPlayerStateId), + 'index': serializer.toJson(index), + }; + } + + PlaylistTableData copyWith({int? id, int? audioPlayerStateId, int? index}) => + PlaylistTableData( + id: id ?? this.id, + audioPlayerStateId: audioPlayerStateId ?? this.audioPlayerStateId, + index: index ?? this.index, + ); + PlaylistTableData copyWithCompanion(PlaylistTableCompanion data) { + return PlaylistTableData( + id: data.id.present ? data.id.value : this.id, + audioPlayerStateId: data.audioPlayerStateId.present + ? data.audioPlayerStateId.value + : this.audioPlayerStateId, + index: data.index.present ? data.index.value : this.index, + ); + } + + @override + String toString() { + return (StringBuffer('PlaylistTableData(') + ..write('id: $id, ') + ..write('audioPlayerStateId: $audioPlayerStateId, ') + ..write('index: $index') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, audioPlayerStateId, index); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PlaylistTableData && + other.id == this.id && + other.audioPlayerStateId == this.audioPlayerStateId && + other.index == this.index); +} + +class PlaylistTableCompanion extends UpdateCompanion { + final Value id; + final Value audioPlayerStateId; + final Value index; + const PlaylistTableCompanion({ + this.id = const Value.absent(), + this.audioPlayerStateId = const Value.absent(), + this.index = const Value.absent(), + }); + PlaylistTableCompanion.insert({ + this.id = const Value.absent(), + required int audioPlayerStateId, + required int index, + }) : audioPlayerStateId = Value(audioPlayerStateId), + index = Value(index); + static Insertable custom({ + Expression? id, + Expression? audioPlayerStateId, + Expression? index, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (audioPlayerStateId != null) + 'audio_player_state_id': audioPlayerStateId, + if (index != null) 'index': index, + }); + } + + PlaylistTableCompanion copyWith( + {Value? id, Value? audioPlayerStateId, Value? index}) { + return PlaylistTableCompanion( + id: id ?? this.id, + audioPlayerStateId: audioPlayerStateId ?? this.audioPlayerStateId, + index: index ?? this.index, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (audioPlayerStateId.present) { + map['audio_player_state_id'] = Variable(audioPlayerStateId.value); + } + if (index.present) { + map['index'] = Variable(index.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PlaylistTableCompanion(') + ..write('id: $id, ') + ..write('audioPlayerStateId: $audioPlayerStateId, ') + ..write('index: $index') + ..write(')')) + .toString(); + } +} + +class $PlaylistMediaTableTable extends PlaylistMediaTable + with TableInfo<$PlaylistMediaTableTable, PlaylistMediaTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $PlaylistMediaTableTable(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 _playlistIdMeta = + const VerificationMeta('playlistId'); + @override + late final GeneratedColumn playlistId = GeneratedColumn( + 'playlist_id', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: true, + defaultConstraints: + GeneratedColumn.constraintIsAlways('REFERENCES playlist_table (id)')); + static const VerificationMeta _uriMeta = const VerificationMeta('uri'); + @override + late final GeneratedColumn uri = GeneratedColumn( + 'uri', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _extrasMeta = const VerificationMeta('extras'); + @override + late final GeneratedColumnWithTypeConverter?, String> + extras = GeneratedColumn('extras', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false) + .withConverter?>( + $PlaylistMediaTableTable.$converterextrasn); + static const VerificationMeta _httpHeadersMeta = + const VerificationMeta('httpHeaders'); + @override + late final GeneratedColumnWithTypeConverter?, String> + httpHeaders = GeneratedColumn('http_headers', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false) + .withConverter?>( + $PlaylistMediaTableTable.$converterhttpHeadersn); + @override + List get $columns => + [id, playlistId, uri, extras, httpHeaders]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'playlist_media_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('playlist_id')) { + context.handle( + _playlistIdMeta, + playlistId.isAcceptableOrUnknown( + data['playlist_id']!, _playlistIdMeta)); + } else if (isInserting) { + context.missing(_playlistIdMeta); + } + if (data.containsKey('uri')) { + context.handle( + _uriMeta, uri.isAcceptableOrUnknown(data['uri']!, _uriMeta)); + } else if (isInserting) { + context.missing(_uriMeta); + } + context.handle(_extrasMeta, const VerificationResult.success()); + context.handle(_httpHeadersMeta, const VerificationResult.success()); + return context; + } + + @override + Set get $primaryKey => {id}; + @override + PlaylistMediaTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PlaylistMediaTableData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + playlistId: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}playlist_id'])!, + uri: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}uri'])!, + extras: $PlaylistMediaTableTable.$converterextrasn.fromSql( + attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}extras'])), + httpHeaders: $PlaylistMediaTableTable.$converterhttpHeadersn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}http_headers'])), + ); + } + + @override + $PlaylistMediaTableTable createAlias(String alias) { + return $PlaylistMediaTableTable(attachedDatabase, alias); + } + + static TypeConverter, String> $converterextras = + const MapTypeConverter(); + static TypeConverter?, String?> $converterextrasn = + NullAwareTypeConverter.wrap($converterextras); + static TypeConverter, String> $converterhttpHeaders = + const MapTypeConverter(); + static TypeConverter?, String?> $converterhttpHeadersn = + NullAwareTypeConverter.wrap($converterhttpHeaders); +} + +class PlaylistMediaTableData extends DataClass + implements Insertable { + final int id; + final int playlistId; + final String uri; + final Map? extras; + final Map? httpHeaders; + const PlaylistMediaTableData( + {required this.id, + required this.playlistId, + required this.uri, + this.extras, + this.httpHeaders}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['playlist_id'] = Variable(playlistId); + map['uri'] = Variable(uri); + if (!nullToAbsent || extras != null) { + map['extras'] = Variable( + $PlaylistMediaTableTable.$converterextrasn.toSql(extras)); + } + if (!nullToAbsent || httpHeaders != null) { + map['http_headers'] = Variable( + $PlaylistMediaTableTable.$converterhttpHeadersn.toSql(httpHeaders)); + } + return map; + } + + PlaylistMediaTableCompanion toCompanion(bool nullToAbsent) { + return PlaylistMediaTableCompanion( + id: Value(id), + playlistId: Value(playlistId), + uri: Value(uri), + extras: + extras == null && nullToAbsent ? const Value.absent() : Value(extras), + httpHeaders: httpHeaders == null && nullToAbsent + ? const Value.absent() + : Value(httpHeaders), + ); + } + + factory PlaylistMediaTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PlaylistMediaTableData( + id: serializer.fromJson(json['id']), + playlistId: serializer.fromJson(json['playlistId']), + uri: serializer.fromJson(json['uri']), + extras: serializer.fromJson?>(json['extras']), + httpHeaders: + serializer.fromJson?>(json['httpHeaders']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'playlistId': serializer.toJson(playlistId), + 'uri': serializer.toJson(uri), + 'extras': serializer.toJson?>(extras), + 'httpHeaders': serializer.toJson?>(httpHeaders), + }; + } + + PlaylistMediaTableData copyWith( + {int? id, + int? playlistId, + String? uri, + Value?> extras = const Value.absent(), + Value?> httpHeaders = const Value.absent()}) => + PlaylistMediaTableData( + id: id ?? this.id, + playlistId: playlistId ?? this.playlistId, + uri: uri ?? this.uri, + extras: extras.present ? extras.value : this.extras, + httpHeaders: httpHeaders.present ? httpHeaders.value : this.httpHeaders, + ); + PlaylistMediaTableData copyWithCompanion(PlaylistMediaTableCompanion data) { + return PlaylistMediaTableData( + id: data.id.present ? data.id.value : this.id, + playlistId: + data.playlistId.present ? data.playlistId.value : this.playlistId, + uri: data.uri.present ? data.uri.value : this.uri, + extras: data.extras.present ? data.extras.value : this.extras, + httpHeaders: + data.httpHeaders.present ? data.httpHeaders.value : this.httpHeaders, + ); + } + + @override + String toString() { + return (StringBuffer('PlaylistMediaTableData(') + ..write('id: $id, ') + ..write('playlistId: $playlistId, ') + ..write('uri: $uri, ') + ..write('extras: $extras, ') + ..write('httpHeaders: $httpHeaders') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, playlistId, uri, extras, httpHeaders); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PlaylistMediaTableData && + other.id == this.id && + other.playlistId == this.playlistId && + other.uri == this.uri && + other.extras == this.extras && + other.httpHeaders == this.httpHeaders); +} + +class PlaylistMediaTableCompanion + extends UpdateCompanion { + final Value id; + final Value playlistId; + final Value uri; + final Value?> extras; + final Value?> httpHeaders; + const PlaylistMediaTableCompanion({ + this.id = const Value.absent(), + this.playlistId = const Value.absent(), + this.uri = const Value.absent(), + this.extras = const Value.absent(), + this.httpHeaders = const Value.absent(), + }); + PlaylistMediaTableCompanion.insert({ + this.id = const Value.absent(), + required int playlistId, + required String uri, + this.extras = const Value.absent(), + this.httpHeaders = const Value.absent(), + }) : playlistId = Value(playlistId), + uri = Value(uri); + static Insertable custom({ + Expression? id, + Expression? playlistId, + Expression? uri, + Expression? extras, + Expression? httpHeaders, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (playlistId != null) 'playlist_id': playlistId, + if (uri != null) 'uri': uri, + if (extras != null) 'extras': extras, + if (httpHeaders != null) 'http_headers': httpHeaders, + }); + } + + PlaylistMediaTableCompanion copyWith( + {Value? id, + Value? playlistId, + Value? uri, + Value?>? extras, + Value?>? httpHeaders}) { + return PlaylistMediaTableCompanion( + id: id ?? this.id, + playlistId: playlistId ?? this.playlistId, + uri: uri ?? this.uri, + extras: extras ?? this.extras, + httpHeaders: httpHeaders ?? this.httpHeaders, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (playlistId.present) { + map['playlist_id'] = Variable(playlistId.value); + } + if (uri.present) { + map['uri'] = Variable(uri.value); + } + if (extras.present) { + map['extras'] = Variable( + $PlaylistMediaTableTable.$converterextrasn.toSql(extras.value)); + } + if (httpHeaders.present) { + map['http_headers'] = Variable($PlaylistMediaTableTable + .$converterhttpHeadersn + .toSql(httpHeaders.value)); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PlaylistMediaTableCompanion(') + ..write('id: $id, ') + ..write('playlistId: $playlistId, ') + ..write('uri: $uri, ') + ..write('extras: $extras, ') + ..write('httpHeaders: $httpHeaders') + ..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); + static const VerificationMeta _typeMeta = const VerificationMeta('type'); + @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); + static const VerificationMeta _dataMeta = const VerificationMeta('data'); + @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)); + } + context.handle(_typeMeta, const VerificationResult.success()); + if (data.containsKey('item_id')) { + context.handle(_itemIdMeta, + itemId.isAcceptableOrUnknown(data['item_id']!, _itemIdMeta)); + } else if (isInserting) { + context.missing(_itemIdMeta); + } + context.handle(_dataMeta, const VerificationResult.success()); + 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); + static const VerificationMeta _dataMeta = const VerificationMeta('data'); + @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); + } + context.handle(_dataMeta, const VerificationResult.success()); + 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(); + } +} + +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 $PlaylistTableTable playlistTable = $PlaylistTableTable(this); + late final $PlaylistMediaTableTable playlistMediaTable = + $PlaylistMediaTableTable(this); + late final $HistoryTableTable historyTable = $HistoryTableTable(this); + late final $LyricsTableTable lyricsTable = $LyricsTableTable(this); + late final Index uniqueBlacklist = Index('unique_blacklist', + 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); + late final Index uniqTrackMatch = Index('uniq_track_match', + 'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_id, source_type)'); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + authenticationTable, + blacklistTable, + preferencesTable, + scrobblerTable, + skipSegmentTable, + sourceMatchTable, + audioPlayerStateTable, + playlistTable, + playlistMediaTable, + historyTable, + lyricsTable, + uniqueBlacklist, + uniqTrackMatch + ]; +} + +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 audioQuality, + 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 pipedInstance, + Value invidiousInstance, + Value themeMode, + Value audioSource, + Value youtubeClientEngine, + Value streamMusicCodec, + Value downloadMusicCodec, + Value discordPresence, + Value endlessPlayback, + Value enableConnect, + Value connectPort, + Value cacheMusic, +}); +typedef $$PreferencesTableTableUpdateCompanionBuilder + = PreferencesTableCompanion Function({ + Value id, + Value audioQuality, + 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 pipedInstance, + Value invidiousInstance, + Value themeMode, + Value audioSource, + Value youtubeClientEngine, + Value streamMusicCodec, + Value downloadMusicCodec, + 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)); + + ColumnWithTypeConverterFilters + get audioQuality => $composableBuilder( + column: $table.audioQuality, + builder: (column) => ColumnWithTypeConverterFilters(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)); + + ColumnFilters get pipedInstance => $composableBuilder( + column: $table.pipedInstance, builder: (column) => ColumnFilters(column)); + + ColumnFilters get invidiousInstance => $composableBuilder( + column: $table.invidiousInstance, + builder: (column) => ColumnFilters(column)); + + ColumnWithTypeConverterFilters get themeMode => + $composableBuilder( + column: $table.themeMode, + builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnWithTypeConverterFilters + get audioSource => $composableBuilder( + column: $table.audioSource, + builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnWithTypeConverterFilters + get youtubeClientEngine => $composableBuilder( + column: $table.youtubeClientEngine, + builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnWithTypeConverterFilters + get streamMusicCodec => $composableBuilder( + column: $table.streamMusicCodec, + builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnWithTypeConverterFilters + get downloadMusicCodec => $composableBuilder( + column: $table.downloadMusicCodec, + 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 audioQuality => $composableBuilder( + column: $table.audioQuality, + 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 pipedInstance => $composableBuilder( + column: $table.pipedInstance, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get invidiousInstance => $composableBuilder( + column: $table.invidiousInstance, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get themeMode => $composableBuilder( + column: $table.themeMode, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get audioSource => $composableBuilder( + column: $table.audioSource, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get youtubeClientEngine => $composableBuilder( + column: $table.youtubeClientEngine, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get streamMusicCodec => $composableBuilder( + column: $table.streamMusicCodec, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get downloadMusicCodec => $composableBuilder( + column: $table.downloadMusicCodec, + 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); + + GeneratedColumnWithTypeConverter get audioQuality => + $composableBuilder( + column: $table.audioQuality, 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); + + GeneratedColumn get pipedInstance => $composableBuilder( + column: $table.pipedInstance, builder: (column) => column); + + GeneratedColumn get invidiousInstance => $composableBuilder( + column: $table.invidiousInstance, builder: (column) => column); + + GeneratedColumnWithTypeConverter get themeMode => + $composableBuilder(column: $table.themeMode, builder: (column) => column); + + GeneratedColumnWithTypeConverter get audioSource => + $composableBuilder( + column: $table.audioSource, builder: (column) => column); + + GeneratedColumnWithTypeConverter + get youtubeClientEngine => $composableBuilder( + column: $table.youtubeClientEngine, builder: (column) => column); + + GeneratedColumnWithTypeConverter get streamMusicCodec => + $composableBuilder( + column: $table.streamMusicCodec, builder: (column) => column); + + GeneratedColumnWithTypeConverter + get downloadMusicCodec => $composableBuilder( + column: $table.downloadMusicCodec, 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 audioQuality = 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 pipedInstance = const Value.absent(), + Value invidiousInstance = const Value.absent(), + Value themeMode = const Value.absent(), + Value audioSource = const Value.absent(), + Value youtubeClientEngine = + const Value.absent(), + Value streamMusicCodec = const Value.absent(), + Value downloadMusicCodec = 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, + audioQuality: audioQuality, + 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, + pipedInstance: pipedInstance, + invidiousInstance: invidiousInstance, + themeMode: themeMode, + audioSource: audioSource, + youtubeClientEngine: youtubeClientEngine, + streamMusicCodec: streamMusicCodec, + downloadMusicCodec: downloadMusicCodec, + discordPresence: discordPresence, + endlessPlayback: endlessPlayback, + enableConnect: enableConnect, + connectPort: connectPort, + cacheMusic: cacheMusic, + ), + createCompanionCallback: ({ + Value id = const Value.absent(), + Value audioQuality = 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 pipedInstance = const Value.absent(), + Value invidiousInstance = const Value.absent(), + Value themeMode = const Value.absent(), + Value audioSource = const Value.absent(), + Value youtubeClientEngine = + const Value.absent(), + Value streamMusicCodec = const Value.absent(), + Value downloadMusicCodec = 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, + audioQuality: audioQuality, + 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, + pipedInstance: pipedInstance, + invidiousInstance: invidiousInstance, + themeMode: themeMode, + audioSource: audioSource, + youtubeClientEngine: youtubeClientEngine, + streamMusicCodec: streamMusicCodec, + downloadMusicCodec: downloadMusicCodec, + 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, + required String sourceId, + Value sourceType, + Value createdAt, +}); +typedef $$SourceMatchTableTableUpdateCompanionBuilder + = SourceMatchTableCompanion Function({ + Value id, + Value trackId, + Value sourceId, + 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 sourceId => $composableBuilder( + column: $table.sourceId, builder: (column) => ColumnFilters(column)); + + ColumnWithTypeConverterFilters + get sourceType => $composableBuilder( + column: $table.sourceType, + builder: (column) => ColumnWithTypeConverterFilters(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 sourceId => $composableBuilder( + column: $table.sourceId, 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 sourceId => + $composableBuilder(column: $table.sourceId, builder: (column) => column); + + GeneratedColumnWithTypeConverter 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 sourceId = const Value.absent(), + Value sourceType = const Value.absent(), + Value createdAt = const Value.absent(), + }) => + SourceMatchTableCompanion( + id: id, + trackId: trackId, + sourceId: sourceId, + sourceType: sourceType, + createdAt: createdAt, + ), + createCompanionCallback: ({ + Value id = const Value.absent(), + required String trackId, + required String sourceId, + Value sourceType = const Value.absent(), + Value createdAt = const Value.absent(), + }) => + SourceMatchTableCompanion.insert( + id: id, + trackId: trackId, + sourceId: sourceId, + 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, +}); +typedef $$AudioPlayerStateTableTableUpdateCompanionBuilder + = AudioPlayerStateTableCompanion Function({ + Value id, + Value playing, + Value loopMode, + Value shuffled, + Value> collections, +}); + +final class $$AudioPlayerStateTableTableReferences extends BaseReferences< + _$AppDatabase, $AudioPlayerStateTableTable, AudioPlayerStateTableData> { + $$AudioPlayerStateTableTableReferences( + super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey<$PlaylistTableTable, List> + _playlistTableRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable(db.playlistTable, + aliasName: $_aliasNameGenerator(db.audioPlayerStateTable.id, + db.playlistTable.audioPlayerStateId)); + + $$PlaylistTableTableProcessedTableManager get playlistTableRefs { + final manager = $$PlaylistTableTableTableManager($_db, $_db.playlistTable) + .filter((f) => f.audioPlayerStateId.id($_item.id)); + + final cache = $_typedResult.readTableOrNull(_playlistTableRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache)); + } +} + +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)); + + Expression playlistTableRefs( + Expression Function($$PlaylistTableTableFilterComposer f) f) { + final $$PlaylistTableTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.playlistTable, + getReferencedColumn: (t) => t.audioPlayerStateId, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$PlaylistTableTableFilterComposer( + $db: $db, + $table: $db.playlistTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return f(composer); + } +} + +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)); +} + +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); + + Expression playlistTableRefs( + Expression Function($$PlaylistTableTableAnnotationComposer a) f) { + final $$PlaylistTableTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.playlistTable, + getReferencedColumn: (t) => t.audioPlayerStateId, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$PlaylistTableTableAnnotationComposer( + $db: $db, + $table: $db.playlistTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return f(composer); + } +} + +class $$AudioPlayerStateTableTableTableManager extends RootTableManager< + _$AppDatabase, + $AudioPlayerStateTableTable, + AudioPlayerStateTableData, + $$AudioPlayerStateTableTableFilterComposer, + $$AudioPlayerStateTableTableOrderingComposer, + $$AudioPlayerStateTableTableAnnotationComposer, + $$AudioPlayerStateTableTableCreateCompanionBuilder, + $$AudioPlayerStateTableTableUpdateCompanionBuilder, + (AudioPlayerStateTableData, $$AudioPlayerStateTableTableReferences), + AudioPlayerStateTableData, + PrefetchHooks Function({bool playlistTableRefs})> { + $$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(), + }) => + AudioPlayerStateTableCompanion( + id: id, + playing: playing, + loopMode: loopMode, + shuffled: shuffled, + collections: collections, + ), + createCompanionCallback: ({ + Value id = const Value.absent(), + required bool playing, + required PlaylistMode loopMode, + required bool shuffled, + required List collections, + }) => + AudioPlayerStateTableCompanion.insert( + id: id, + playing: playing, + loopMode: loopMode, + shuffled: shuffled, + collections: collections, + ), + withReferenceMapper: (p0) => p0 + .map((e) => ( + e.readTable(table), + $$AudioPlayerStateTableTableReferences(db, table, e) + )) + .toList(), + prefetchHooksCallback: ({playlistTableRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (playlistTableRefs) db.playlistTable + ], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (playlistTableRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $$AudioPlayerStateTableTableReferences + ._playlistTableRefsTable(db), + managerFromTypedResult: (p0) => + $$AudioPlayerStateTableTableReferences( + db, table, p0) + .playlistTableRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems + .where((e) => e.audioPlayerStateId == item.id), + typedResults: items) + ]; + }, + ); + }, + )); +} + +typedef $$AudioPlayerStateTableTableProcessedTableManager + = ProcessedTableManager< + _$AppDatabase, + $AudioPlayerStateTableTable, + AudioPlayerStateTableData, + $$AudioPlayerStateTableTableFilterComposer, + $$AudioPlayerStateTableTableOrderingComposer, + $$AudioPlayerStateTableTableAnnotationComposer, + $$AudioPlayerStateTableTableCreateCompanionBuilder, + $$AudioPlayerStateTableTableUpdateCompanionBuilder, + (AudioPlayerStateTableData, $$AudioPlayerStateTableTableReferences), + AudioPlayerStateTableData, + PrefetchHooks Function({bool playlistTableRefs})>; +typedef $$PlaylistTableTableCreateCompanionBuilder = PlaylistTableCompanion + Function({ + Value id, + required int audioPlayerStateId, + required int index, +}); +typedef $$PlaylistTableTableUpdateCompanionBuilder = PlaylistTableCompanion + Function({ + Value id, + Value audioPlayerStateId, + Value index, +}); + +final class $$PlaylistTableTableReferences extends BaseReferences<_$AppDatabase, + $PlaylistTableTable, PlaylistTableData> { + $$PlaylistTableTableReferences( + super.$_db, super.$_table, super.$_typedResult); + + static $AudioPlayerStateTableTable _audioPlayerStateIdTable( + _$AppDatabase db) => + db.audioPlayerStateTable.createAlias($_aliasNameGenerator( + db.playlistTable.audioPlayerStateId, db.audioPlayerStateTable.id)); + + $$AudioPlayerStateTableTableProcessedTableManager get audioPlayerStateId { + final manager = $$AudioPlayerStateTableTableTableManager( + $_db, $_db.audioPlayerStateTable) + .filter((f) => f.id($_item.audioPlayerStateId!)); + final item = $_typedResult.readTableOrNull(_audioPlayerStateIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item])); + } + + static MultiTypedResultKey<$PlaylistMediaTableTable, + List> _playlistMediaTableRefsTable( + _$AppDatabase db) => + MultiTypedResultKey.fromTable(db.playlistMediaTable, + aliasName: $_aliasNameGenerator( + db.playlistTable.id, db.playlistMediaTable.playlistId)); + + $$PlaylistMediaTableTableProcessedTableManager get playlistMediaTableRefs { + final manager = + $$PlaylistMediaTableTableTableManager($_db, $_db.playlistMediaTable) + .filter((f) => f.playlistId.id($_item.id)); + + final cache = + $_typedResult.readTableOrNull(_playlistMediaTableRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache)); + } +} + +class $$PlaylistTableTableFilterComposer + extends Composer<_$AppDatabase, $PlaylistTableTable> { + $$PlaylistTableTableFilterComposer({ + 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 index => $composableBuilder( + column: $table.index, builder: (column) => ColumnFilters(column)); + + $$AudioPlayerStateTableTableFilterComposer get audioPlayerStateId { + final $$AudioPlayerStateTableTableFilterComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.audioPlayerStateId, + referencedTable: $db.audioPlayerStateTable, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$AudioPlayerStateTableTableFilterComposer( + $db: $db, + $table: $db.audioPlayerStateTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } + + Expression playlistMediaTableRefs( + Expression Function($$PlaylistMediaTableTableFilterComposer f) f) { + final $$PlaylistMediaTableTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.playlistMediaTable, + getReferencedColumn: (t) => t.playlistId, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$PlaylistMediaTableTableFilterComposer( + $db: $db, + $table: $db.playlistMediaTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return f(composer); + } +} + +class $$PlaylistTableTableOrderingComposer + extends Composer<_$AppDatabase, $PlaylistTableTable> { + $$PlaylistTableTableOrderingComposer({ + 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 index => $composableBuilder( + column: $table.index, builder: (column) => ColumnOrderings(column)); + + $$AudioPlayerStateTableTableOrderingComposer get audioPlayerStateId { + final $$AudioPlayerStateTableTableOrderingComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.audioPlayerStateId, + referencedTable: $db.audioPlayerStateTable, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$AudioPlayerStateTableTableOrderingComposer( + $db: $db, + $table: $db.audioPlayerStateTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$PlaylistTableTableAnnotationComposer + extends Composer<_$AppDatabase, $PlaylistTableTable> { + $$PlaylistTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get index => + $composableBuilder(column: $table.index, builder: (column) => column); + + $$AudioPlayerStateTableTableAnnotationComposer get audioPlayerStateId { + final $$AudioPlayerStateTableTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.audioPlayerStateId, + referencedTable: $db.audioPlayerStateTable, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$AudioPlayerStateTableTableAnnotationComposer( + $db: $db, + $table: $db.audioPlayerStateTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } + + Expression playlistMediaTableRefs( + Expression Function($$PlaylistMediaTableTableAnnotationComposer a) f) { + final $$PlaylistMediaTableTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.playlistMediaTable, + getReferencedColumn: (t) => t.playlistId, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$PlaylistMediaTableTableAnnotationComposer( + $db: $db, + $table: $db.playlistMediaTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return f(composer); + } +} + +class $$PlaylistTableTableTableManager extends RootTableManager< + _$AppDatabase, + $PlaylistTableTable, + PlaylistTableData, + $$PlaylistTableTableFilterComposer, + $$PlaylistTableTableOrderingComposer, + $$PlaylistTableTableAnnotationComposer, + $$PlaylistTableTableCreateCompanionBuilder, + $$PlaylistTableTableUpdateCompanionBuilder, + (PlaylistTableData, $$PlaylistTableTableReferences), + PlaylistTableData, + PrefetchHooks Function( + {bool audioPlayerStateId, bool playlistMediaTableRefs})> { + $$PlaylistTableTableTableManager(_$AppDatabase db, $PlaylistTableTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$PlaylistTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$PlaylistTableTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$PlaylistTableTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value audioPlayerStateId = const Value.absent(), + Value index = const Value.absent(), + }) => + PlaylistTableCompanion( + id: id, + audioPlayerStateId: audioPlayerStateId, + index: index, + ), + createCompanionCallback: ({ + Value id = const Value.absent(), + required int audioPlayerStateId, + required int index, + }) => + PlaylistTableCompanion.insert( + id: id, + audioPlayerStateId: audioPlayerStateId, + index: index, + ), + withReferenceMapper: (p0) => p0 + .map((e) => ( + e.readTable(table), + $$PlaylistTableTableReferences(db, table, e) + )) + .toList(), + prefetchHooksCallback: ( + {audioPlayerStateId = false, playlistMediaTableRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (playlistMediaTableRefs) db.playlistMediaTable + ], + addJoins: < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic>>(state) { + if (audioPlayerStateId) { + state = state.withJoin( + currentTable: table, + currentColumn: table.audioPlayerStateId, + referencedTable: $$PlaylistTableTableReferences + ._audioPlayerStateIdTable(db), + referencedColumn: $$PlaylistTableTableReferences + ._audioPlayerStateIdTable(db) + .id, + ) as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return [ + if (playlistMediaTableRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $$PlaylistTableTableReferences + ._playlistMediaTableRefsTable(db), + managerFromTypedResult: (p0) => + $$PlaylistTableTableReferences(db, table, p0) + .playlistMediaTableRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems + .where((e) => e.playlistId == item.id), + typedResults: items) + ]; + }, + ); + }, + )); +} + +typedef $$PlaylistTableTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $PlaylistTableTable, + PlaylistTableData, + $$PlaylistTableTableFilterComposer, + $$PlaylistTableTableOrderingComposer, + $$PlaylistTableTableAnnotationComposer, + $$PlaylistTableTableCreateCompanionBuilder, + $$PlaylistTableTableUpdateCompanionBuilder, + (PlaylistTableData, $$PlaylistTableTableReferences), + PlaylistTableData, + PrefetchHooks Function( + {bool audioPlayerStateId, bool playlistMediaTableRefs})>; +typedef $$PlaylistMediaTableTableCreateCompanionBuilder + = PlaylistMediaTableCompanion Function({ + Value id, + required int playlistId, + required String uri, + Value?> extras, + Value?> httpHeaders, +}); +typedef $$PlaylistMediaTableTableUpdateCompanionBuilder + = PlaylistMediaTableCompanion Function({ + Value id, + Value playlistId, + Value uri, + Value?> extras, + Value?> httpHeaders, +}); + +final class $$PlaylistMediaTableTableReferences extends BaseReferences< + _$AppDatabase, $PlaylistMediaTableTable, PlaylistMediaTableData> { + $$PlaylistMediaTableTableReferences( + super.$_db, super.$_table, super.$_typedResult); + + static $PlaylistTableTable _playlistIdTable(_$AppDatabase db) => + db.playlistTable.createAlias($_aliasNameGenerator( + db.playlistMediaTable.playlistId, db.playlistTable.id)); + + $$PlaylistTableTableProcessedTableManager get playlistId { + final manager = $$PlaylistTableTableTableManager($_db, $_db.playlistTable) + .filter((f) => f.id($_item.playlistId!)); + final item = $_typedResult.readTableOrNull(_playlistIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item])); + } +} + +class $$PlaylistMediaTableTableFilterComposer + extends Composer<_$AppDatabase, $PlaylistMediaTableTable> { + $$PlaylistMediaTableTableFilterComposer({ + 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 uri => $composableBuilder( + column: $table.uri, builder: (column) => ColumnFilters(column)); + + ColumnWithTypeConverterFilters?, Map, + String> + get extras => $composableBuilder( + column: $table.extras, + builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnWithTypeConverterFilters?, Map, + String> + get httpHeaders => $composableBuilder( + column: $table.httpHeaders, + builder: (column) => ColumnWithTypeConverterFilters(column)); + + $$PlaylistTableTableFilterComposer get playlistId { + final $$PlaylistTableTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.playlistId, + referencedTable: $db.playlistTable, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$PlaylistTableTableFilterComposer( + $db: $db, + $table: $db.playlistTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$PlaylistMediaTableTableOrderingComposer + extends Composer<_$AppDatabase, $PlaylistMediaTableTable> { + $$PlaylistMediaTableTableOrderingComposer({ + 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 uri => $composableBuilder( + column: $table.uri, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get extras => $composableBuilder( + column: $table.extras, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get httpHeaders => $composableBuilder( + column: $table.httpHeaders, builder: (column) => ColumnOrderings(column)); + + $$PlaylistTableTableOrderingComposer get playlistId { + final $$PlaylistTableTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.playlistId, + referencedTable: $db.playlistTable, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$PlaylistTableTableOrderingComposer( + $db: $db, + $table: $db.playlistTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$PlaylistMediaTableTableAnnotationComposer + extends Composer<_$AppDatabase, $PlaylistMediaTableTable> { + $$PlaylistMediaTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get uri => + $composableBuilder(column: $table.uri, builder: (column) => column); + + GeneratedColumnWithTypeConverter?, String> get extras => + $composableBuilder(column: $table.extras, builder: (column) => column); + + GeneratedColumnWithTypeConverter?, String> + get httpHeaders => $composableBuilder( + column: $table.httpHeaders, builder: (column) => column); + + $$PlaylistTableTableAnnotationComposer get playlistId { + final $$PlaylistTableTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.playlistId, + referencedTable: $db.playlistTable, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$PlaylistTableTableAnnotationComposer( + $db: $db, + $table: $db.playlistTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$PlaylistMediaTableTableTableManager extends RootTableManager< + _$AppDatabase, + $PlaylistMediaTableTable, + PlaylistMediaTableData, + $$PlaylistMediaTableTableFilterComposer, + $$PlaylistMediaTableTableOrderingComposer, + $$PlaylistMediaTableTableAnnotationComposer, + $$PlaylistMediaTableTableCreateCompanionBuilder, + $$PlaylistMediaTableTableUpdateCompanionBuilder, + (PlaylistMediaTableData, $$PlaylistMediaTableTableReferences), + PlaylistMediaTableData, + PrefetchHooks Function({bool playlistId})> { + $$PlaylistMediaTableTableTableManager( + _$AppDatabase db, $PlaylistMediaTableTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$PlaylistMediaTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$PlaylistMediaTableTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$PlaylistMediaTableTableAnnotationComposer( + $db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value playlistId = const Value.absent(), + Value uri = const Value.absent(), + Value?> extras = const Value.absent(), + Value?> httpHeaders = const Value.absent(), + }) => + PlaylistMediaTableCompanion( + id: id, + playlistId: playlistId, + uri: uri, + extras: extras, + httpHeaders: httpHeaders, + ), + createCompanionCallback: ({ + Value id = const Value.absent(), + required int playlistId, + required String uri, + Value?> extras = const Value.absent(), + Value?> httpHeaders = const Value.absent(), + }) => + PlaylistMediaTableCompanion.insert( + id: id, + playlistId: playlistId, + uri: uri, + extras: extras, + httpHeaders: httpHeaders, + ), + withReferenceMapper: (p0) => p0 + .map((e) => ( + e.readTable(table), + $$PlaylistMediaTableTableReferences(db, table, e) + )) + .toList(), + prefetchHooksCallback: ({playlistId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic>>(state) { + if (playlistId) { + state = state.withJoin( + currentTable: table, + currentColumn: table.playlistId, + referencedTable: $$PlaylistMediaTableTableReferences + ._playlistIdTable(db), + referencedColumn: $$PlaylistMediaTableTableReferences + ._playlistIdTable(db) + .id, + ) as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + )); +} + +typedef $$PlaylistMediaTableTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $PlaylistMediaTableTable, + PlaylistMediaTableData, + $$PlaylistMediaTableTableFilterComposer, + $$PlaylistMediaTableTableOrderingComposer, + $$PlaylistMediaTableTableAnnotationComposer, + $$PlaylistMediaTableTableCreateCompanionBuilder, + $$PlaylistMediaTableTableUpdateCompanionBuilder, + (PlaylistMediaTableData, $$PlaylistMediaTableTableReferences), + PlaylistMediaTableData, + PrefetchHooks Function({bool playlistId})>; +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()>; + +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); + $$PlaylistTableTableTableManager get playlistTable => + $$PlaylistTableTableTableManager(_db, _db.playlistTable); + $$PlaylistMediaTableTableTableManager get playlistMediaTable => + $$PlaylistMediaTableTableTableManager(_db, _db.playlistMediaTable); + $$HistoryTableTableTableManager get historyTable => + $$HistoryTableTableTableManager(_db, _db.historyTable); + $$LyricsTableTableTableManager get lyricsTable => + $$LyricsTableTableTableManager(_db, _db.lyricsTable); +} diff --git a/lib/models/database/database.steps.dart b/lib/models/database/database.steps.dart new file mode 100644 index 00000000..3e416ea5 --- /dev/null +++ b/lib/models/database/database.steps.dart @@ -0,0 +1,1749 @@ +// 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:spotify/spotify.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/services/sourced_track/enums.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(SourceQualities.high.name)); +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(AudioSource.youtube.name)); +i1.GeneratedColumn _column_27(String aliasedName) => + i1.GeneratedColumn('stream_music_codec', aliasedName, false, + type: i1.DriftSqlType.string, + defaultValue: Constant(SourceCodecs.weba.name)); +i1.GeneratedColumn _column_28(String aliasedName) => + i1.GeneratedColumn('download_music_codec', aliasedName, false, + type: i1.DriftSqlType.string, + defaultValue: Constant(SourceCodecs.m4a.name)); +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(SourceType.youtube.name)); + +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)); +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, +}) { + 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; + 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, +}) => + i0.VersionedSchema.stepByStepHelper( + step: migrationSteps( + from1To2: from1To2, + from2To3: from2To3, + from3To4: from3To4, + from4To5: from4To5, + from5To6: from5To6, + )); diff --git a/lib/models/database/tables/audio_player_state.dart b/lib/models/database/tables/audio_player_state.dart new file mode 100644 index 00000000..3e49cf6f --- /dev/null +++ b/lib/models/database/tables/audio_player_state.dart @@ -0,0 +1,27 @@ +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())(); +} + +class PlaylistTable extends Table { + IntColumn get id => integer().autoIncrement()(); + IntColumn get audioPlayerStateId => + integer().references(AudioPlayerStateTable, #id)(); + IntColumn get index => integer()(); +} + +class PlaylistMediaTable extends Table { + IntColumn get id => integer().autoIncrement()(); + IntColumn get playlistId => integer().references(PlaylistTable, #id)(); + + TextColumn get uri => text()(); + TextColumn get extras => + text().nullable().map(const MapTypeConverter())(); + TextColumn get httpHeaders => + text().nullable().map(const MapTypeConverter())(); +} diff --git a/lib/models/database/tables/authentication.dart b/lib/models/database/tables/authentication.dart new file mode 100644 index 00000000..96041952 --- /dev/null +++ b/lib/models/database/tables/authentication.dart @@ -0,0 +1,8 @@ +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 new file mode 100644 index 00000000..8a8d9dee --- /dev/null +++ b/lib/models/database/tables/blacklist.dart @@ -0,0 +1,18 @@ +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 new file mode 100644 index 00000000..23c16f17 --- /dev/null +++ b/lib/models/database/tables/history.dart @@ -0,0 +1,25 @@ +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 { + PlaylistSimple? get playlist => + type == HistoryEntryType.playlist ? PlaylistSimple.fromJson(data) : null; + AlbumSimple? get album => + type == HistoryEntryType.album ? AlbumSimple.fromJson(data) : null; + Track? get track => + type == HistoryEntryType.track ? Track.fromJson(data) : null; +} diff --git a/lib/models/database/tables/lyrics.dart b/lib/models/database/tables/lyrics.dart new file mode 100644 index 00000000..7c4c7f8f --- /dev/null +++ b/lib/models/database/tables/lyrics.dart @@ -0,0 +1,8 @@ +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/preferences.dart b/lib/models/database/tables/preferences.dart new file mode 100644 index 00000000..377f288d --- /dev/null +++ b/lib/models/database/tables/preferences.dart @@ -0,0 +1,155 @@ +part of '../database.dart'; + +enum LayoutMode { + compact, + extended, + adaptive, +} + +enum CloseBehavior { + minimizeToTray, + close, +} + +enum AudioSource { + youtube, + piped, + jiosaavn, + invidious; + + String get label => name[0].toUpperCase() + name.substring(1); +} + +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 MusicCodec { + m4a._("M4a (Best for downloaded music)"), + weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"); + + final String label; + const MusicCodec._(this.label); +} + +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()(); + TextColumn get audioQuality => textEnum() + .withDefault(Constant(SourceQualities.high.name))(); + 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("Orange:0xFFf97315")) + .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 pipedInstance => + text().withDefault(const Constant("https://pipedapi.kavin.rocks"))(); + TextColumn get invidiousInstance => + text().withDefault(const Constant("https://inv.nadeko.net"))(); + TextColumn get themeMode => + textEnum().withDefault(Constant(ThemeMode.system.name))(); + TextColumn get audioSource => + textEnum().withDefault(Constant(AudioSource.youtube.name))(); + TextColumn get youtubeClientEngine => textEnum() + .withDefault(Constant(YoutubeClientEngine.youtubeExplode.name))(); + TextColumn get streamMusicCodec => + textEnum().withDefault(Constant(SourceCodecs.weba.name))(); + TextColumn get downloadMusicCodec => + textEnum().withDefault(Constant(SourceCodecs.m4a.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, + audioQuality: SourceQualities.high, + albumColorSync: true, + amoledDarkTheme: false, + checkUpdate: true, + normalizeAudio: false, + showSystemTrayIcon: false, + systemTitleBar: false, + skipNonMusic: false, + closeBehavior: CloseBehavior.close, + accentColorScheme: SpotubeColor(Colors.orange.value, name: "Orange"), + layoutMode: LayoutMode.adaptive, + locale: const Locale("system", "system"), + market: Market.US, + searchMode: SearchMode.youtube, + downloadLocation: "", + localLibraryLocation: [], + pipedInstance: "https://pipedapi.kavin.rocks", + invidiousInstance: "https://inv.nadeko.net", + themeMode: ThemeMode.system, + audioSource: AudioSource.youtube, + youtubeClientEngine: YoutubeClientEngine.youtubeExplode, + streamMusicCodec: SourceCodecs.m4a, + downloadMusicCodec: SourceCodecs.m4a, + 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 new file mode 100644 index 00000000..481c441e --- /dev/null +++ b/lib/models/database/tables/scrobbler.dart @@ -0,0 +1,8 @@ +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 new file mode 100644 index 00000000..719f2617 --- /dev/null +++ b/lib/models/database/tables/skip_segment.dart @@ -0,0 +1,9 @@ +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 new file mode 100644 index 00000000..78d0eb05 --- /dev/null +++ b/lib/models/database/tables/source_match.dart @@ -0,0 +1,25 @@ +part of '../database.dart'; + +enum SourceType { + youtube._("YouTube"), + youtubeMusic._("YouTube Music"), + jiosaavn._("JioSaavn"); + + final String label; + + const SourceType._(this.label); +} + +@TableIndex( + name: "uniq_track_match", + columns: {#trackId, #sourceId, #sourceType}, + unique: true, +) +class SourceMatchTable extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get trackId => text()(); + TextColumn get sourceId => text()(); + TextColumn get sourceType => + textEnum().withDefault(Constant(SourceType.youtube.name))(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); +} diff --git a/lib/models/database/typeconverters/color.dart b/lib/models/database/typeconverters/color.dart new file mode 100644 index 00000000..513921a2 --- /dev/null +++ b/lib/models/database/typeconverters/color.dart @@ -0,0 +1,29 @@ +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 new file mode 100644 index 00000000..6afa8210 --- /dev/null +++ b/lib/models/database/typeconverters/encrypted_text.dart @@ -0,0 +1,44 @@ +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 new file mode 100644 index 00000000..c460088e --- /dev/null +++ b/lib/models/database/typeconverters/locale.dart @@ -0,0 +1,19 @@ +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 new file mode 100644 index 00000000..0b0ff7e0 --- /dev/null +++ b/lib/models/database/typeconverters/map.dart @@ -0,0 +1,15 @@ +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/string_list.dart b/lib/models/database/typeconverters/string_list.dart new file mode 100644 index 00000000..466ae4c4 --- /dev/null +++ b/lib/models/database/typeconverters/string_list.dart @@ -0,0 +1,15 @@ +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 new file mode 100644 index 00000000..25fa4ad5 --- /dev/null +++ b/lib/models/database/typeconverters/subtitle.dart @@ -0,0 +1,13 @@ +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/local_track.dart b/lib/models/local_track.dart index 134cd327..def3b64f 100644 --- a/lib/models/local_track.dart +++ b/lib/models/local_track.dart @@ -1,5 +1,4 @@ import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; class LocalTrack extends Track { final String path; @@ -35,9 +34,10 @@ class LocalTrack extends Track { ); } + @override Map toJson() { return { - ...TrackJson.trackToJson(this), + ...super.toJson(), 'path': path, }; } diff --git a/lib/models/logger.dart b/lib/models/logger.dart deleted file mode 100644 index 4f687d09..00000000 --- a/lib/models/logger.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:logger/logger.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:path/path.dart' as path; -import 'package:spotube/utils/platform.dart'; - -final _loggerFactory = SpotubeLogger(); -final logEnv = { - if (!kIsWeb) ...Platform.environment, -}; - -SpotubeLogger getLogger(T owner) { - _loggerFactory.owner = owner is String ? owner : owner.toString(); - return _loggerFactory; -} - -Future getLogsPath() async { - String dir = (await getApplicationDocumentsDirectory()).path; - if (kIsAndroid) { - dir = (await getExternalStorageDirectory())?.path ?? ""; - } - - if (kIsMacOS) { - dir = path.join((await getLibraryDirectory()).path, "Logs"); - } - final file = File(path.join(dir, ".spotube_logs")); - if (!await file.exists()) { - await file.create(); - } - return file; -} - -class SpotubeLogger extends Logger { - String? owner; - SpotubeLogger([this.owner]) : super(filter: _SpotubeLogFilter()); - - @override - void log(Level level, dynamic message, - {Object? error, StackTrace? stackTrace, DateTime? time}) async { - if (!kIsWeb) { - if (level == Level.error) { - String dir = (await getApplicationDocumentsDirectory()).path; - - if (kIsAndroid) { - dir = (await getExternalStorageDirectory())?.path ?? ""; - } - - if (kIsMacOS) { - dir = path.join((await getLibraryDirectory()).path, "Logs"); - } - - await File(path.join(dir, ".spotube_logs")).writeAsString( - "[${DateTime.now()}]\n$message\n$stackTrace", - mode: FileMode.writeOnlyAppend); - } - } - - super.log(level, "[$owner] $message", error: error, stackTrace: stackTrace); - } -} - -class _SpotubeLogFilter extends DevelopmentFilter { - @override - bool shouldLog(LogEvent event) { - if ((logEnv["DEBUG"] == "true" && event.level == Level.debug) || - (logEnv["VERBOSE"] == "true" && event.level == Level.trace) || - (logEnv["ERROR"] == "true" && event.level == Level.error)) { - return true; - } - return super.shouldLog(event); - } -} diff --git a/lib/models/lyrics.dart b/lib/models/lyrics.dart index c800b040..f6457287 100644 --- a/lib/models/lyrics.dart +++ b/lib/models/lyrics.dart @@ -1,13 +1,18 @@ +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) { @@ -18,6 +23,7 @@ class SubtitleSimple { .map((e) => LyricSlice.fromJson(e as Map)) .toList(), rating: json["rating"] as int, + provider: json["provider"] as String? ?? "unknown", ); } @@ -27,6 +33,7 @@ class SubtitleSimple { "name": name, "lyrics": lyrics.map((e) => e.toJson()).toList(), "rating": rating, + "provider": provider, }; } } @@ -37,6 +44,13 @@ class LyricSlice { 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"]), diff --git a/lib/models/parser/range_headers.dart b/lib/models/parser/range_headers.dart new file mode 100644 index 00000000..08025cbf --- /dev/null +++ b/lib/models/parser/range_headers.dart @@ -0,0 +1,71 @@ +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/skip_segment.dart b/lib/models/skip_segment.dart deleted file mode 100644 index 90f20f5a..00000000 --- a/lib/models/skip_segment.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:hive/hive.dart'; - -part 'skip_segment.g.dart'; - -@HiveType(typeId: 2) -class SkipSegment { - @HiveField(0) - final int start; - @HiveField(1) - final int end; - SkipSegment(this.start, this.end); - - static String version = 'v1'; - static final boxName = "oss.krtirtho.spotube.skip_segments.$version"; - static LazyBox get box => Hive.lazyBox(boxName); - - SkipSegment.fromJson(Map json) - : start = json['start'], - end = json['end']; - - Map toJson() => { - 'start': start, - 'end': end, - }; -} diff --git a/lib/models/skip_segment.g.dart b/lib/models/skip_segment.g.dart deleted file mode 100644 index f2ad4459..00000000 --- a/lib/models/skip_segment.g.dart +++ /dev/null @@ -1,44 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'skip_segment.dart'; - -// ************************************************************************** -// TypeAdapterGenerator -// ************************************************************************** - -class SkipSegmentAdapter extends TypeAdapter { - @override - final int typeId = 2; - - @override - SkipSegment read(BinaryReader reader) { - final numOfFields = reader.readByte(); - final fields = { - for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), - }; - return SkipSegment( - fields[0] as int, - fields[1] as int, - ); - } - - @override - void write(BinaryWriter writer, SkipSegment obj) { - writer - ..writeByte(2) - ..writeByte(0) - ..write(obj.start) - ..writeByte(1) - ..write(obj.end); - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SkipSegmentAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} diff --git a/lib/models/source_match.dart b/lib/models/source_match.dart deleted file mode 100644 index 57a9f963..00000000 --- a/lib/models/source_match.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:hive/hive.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'source_match.g.dart'; - -@JsonEnum() -@HiveType(typeId: 5) -enum SourceType { - @HiveField(0) - youtube._("YouTube"), - - @HiveField(1) - youtubeMusic._("YouTube Music"), - - @HiveField(2) - jiosaavn._("JioSaavn"); - - final String label; - - const SourceType._(this.label); -} - -@JsonSerializable() -@HiveType(typeId: 6) -class SourceMatch { - @HiveField(0) - String id; - - @HiveField(1) - String sourceId; - - @HiveField(2) - SourceType sourceType; - - @HiveField(3) - DateTime createdAt; - - SourceMatch({ - required this.id, - required this.sourceId, - required this.sourceType, - required this.createdAt, - }); - - factory SourceMatch.fromJson(Map json) => - _$SourceMatchFromJson(json); - - Map toJson() => _$SourceMatchToJson(this); - - static String version = 'v1'; - static final boxName = "oss.krtirtho.spotube.source_matches.$version"; - - static LazyBox get box => Hive.lazyBox(boxName); -} diff --git a/lib/models/source_match.g.dart b/lib/models/source_match.g.dart deleted file mode 100644 index 11f34bf3..00000000 --- a/lib/models/source_match.g.dart +++ /dev/null @@ -1,119 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'source_match.dart'; - -// ************************************************************************** -// TypeAdapterGenerator -// ************************************************************************** - -class SourceMatchAdapter extends TypeAdapter { - @override - final int typeId = 6; - - @override - SourceMatch read(BinaryReader reader) { - final numOfFields = reader.readByte(); - final fields = { - for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), - }; - return SourceMatch( - id: fields[0] as String, - sourceId: fields[1] as String, - sourceType: fields[2] as SourceType, - createdAt: fields[3] as DateTime, - ); - } - - @override - void write(BinaryWriter writer, SourceMatch obj) { - writer - ..writeByte(4) - ..writeByte(0) - ..write(obj.id) - ..writeByte(1) - ..write(obj.sourceId) - ..writeByte(2) - ..write(obj.sourceType) - ..writeByte(3) - ..write(obj.createdAt); - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SourceMatchAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} - -class SourceTypeAdapter extends TypeAdapter { - @override - final int typeId = 5; - - @override - SourceType read(BinaryReader reader) { - switch (reader.readByte()) { - case 0: - return SourceType.youtube; - case 1: - return SourceType.youtubeMusic; - case 2: - return SourceType.jiosaavn; - default: - return SourceType.youtube; - } - } - - @override - void write(BinaryWriter writer, SourceType obj) { - switch (obj) { - case SourceType.youtube: - writer.writeByte(0); - break; - case SourceType.youtubeMusic: - writer.writeByte(1); - break; - case SourceType.jiosaavn: - writer.writeByte(2); - break; - } - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SourceTypeAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -SourceMatch _$SourceMatchFromJson(Map json) => SourceMatch( - id: json['id'] as String, - sourceId: json['sourceId'] as String, - sourceType: $enumDecode(_$SourceTypeEnumMap, json['sourceType']), - createdAt: DateTime.parse(json['createdAt'] as String), - ); - -Map _$SourceMatchToJson(SourceMatch instance) => - { - 'id': instance.id, - 'sourceId': instance.sourceId, - 'sourceType': _$SourceTypeEnumMap[instance.sourceType]!, - 'createdAt': instance.createdAt.toIso8601String(), - }; - -const _$SourceTypeEnumMap = { - SourceType.youtube: 'youtube', - SourceType.youtubeMusic: 'youtubeMusic', - SourceType.jiosaavn: 'jiosaavn', -}; diff --git a/lib/models/spotify/home_feed.dart b/lib/models/spotify/home_feed.dart new file mode 100644 index 00000000..ad764304 --- /dev/null +++ b/lib/models/spotify/home_feed.dart @@ -0,0 +1,247 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:spotify/spotify.dart'; + +part 'home_feed.freezed.dart'; +part 'home_feed.g.dart'; + +@freezed +class SpotifySectionPlaylist with _$SpotifySectionPlaylist { + const SpotifySectionPlaylist._(); + + const factory SpotifySectionPlaylist({ + required String description, + required String format, + required List images, + required String name, + required String owner, + required String uri, + }) = _SpotifySectionPlaylist; + + factory SpotifySectionPlaylist.fromJson(Map json) => + _$SpotifySectionPlaylistFromJson(json); + + String get id => uri.split(":").last; + + Playlist get asPlaylist { + return Playlist() + ..id = id + ..name = name + ..description = description + ..collaborative = false + ..images = images.map((e) => e.asImage).toList() + ..owner = (User()..displayName = owner) + ..uri = uri + ..type = "playlist"; + } +} + +@freezed +class SpotifySectionArtist with _$SpotifySectionArtist { + const SpotifySectionArtist._(); + + const factory SpotifySectionArtist({ + required String name, + required String uri, + required List images, + }) = _SpotifySectionArtist; + + factory SpotifySectionArtist.fromJson(Map json) => + _$SpotifySectionArtistFromJson(json); + + String get id => uri.split(":").last; + + Artist get asArtist { + return Artist() + ..id = id + ..name = name + ..images = images.map((e) => e.asImage).toList() + ..type = "artist" + ..uri = uri; + } +} + +@freezed +class SpotifySectionAlbum with _$SpotifySectionAlbum { + const SpotifySectionAlbum._(); + + const factory SpotifySectionAlbum({ + required List artists, + required List images, + required String name, + required String uri, + }) = _SpotifySectionAlbum; + + factory SpotifySectionAlbum.fromJson(Map json) => + _$SpotifySectionAlbumFromJson(json); + + String get id => uri.split(":").last; + + Album get asAlbum { + return Album() + ..id = id + ..name = name + ..artists = artists.map((a) => a.asArtist).toList() + ..albumType = AlbumType.album + ..images = images.map((e) => e.asImage).toList() + ..uri = uri; + } +} + +@freezed +class SpotifySectionAlbumArtist with _$SpotifySectionAlbumArtist { + const SpotifySectionAlbumArtist._(); + + const factory SpotifySectionAlbumArtist({ + required String name, + required String uri, + }) = _SpotifySectionAlbumArtist; + + factory SpotifySectionAlbumArtist.fromJson(Map json) => + _$SpotifySectionAlbumArtistFromJson(json); + + String get id => uri.split(":").last; + + Artist get asArtist { + return Artist() + ..id = id + ..name = name + ..type = "artist" + ..uri = uri; + } +} + +@freezed +class SpotifySectionItemImage with _$SpotifySectionItemImage { + const SpotifySectionItemImage._(); + + const factory SpotifySectionItemImage({ + required num? height, + required String url, + required num? width, + }) = _SpotifySectionItemImage; + + factory SpotifySectionItemImage.fromJson(Map json) => + _$SpotifySectionItemImageFromJson(json); + + Image get asImage { + return Image() + ..height = height?.toInt() + ..width = width?.toInt() + ..url = url; + } +} + +@freezed +class SpotifyHomeFeedSectionItem with _$SpotifyHomeFeedSectionItem { + factory SpotifyHomeFeedSectionItem({ + required String typename, + SpotifySectionPlaylist? playlist, + SpotifySectionArtist? artist, + SpotifySectionAlbum? album, + }) = _SpotifyHomeFeedSectionItem; + + factory SpotifyHomeFeedSectionItem.fromJson(Map json) => + _$SpotifyHomeFeedSectionItemFromJson(json); +} + +@freezed +class SpotifyHomeFeedSection with _$SpotifyHomeFeedSection { + factory SpotifyHomeFeedSection({ + required String typename, + String? title, + required String uri, + required List items, + }) = _SpotifyHomeFeedSection; + + factory SpotifyHomeFeedSection.fromJson(Map json) => + _$SpotifyHomeFeedSectionFromJson(json); +} + +@freezed +class SpotifyHomeFeed with _$SpotifyHomeFeed { + factory SpotifyHomeFeed({ + required String greeting, + required List sections, + }) = _SpotifyHomeFeed; + + factory SpotifyHomeFeed.fromJson(Map json) => + _$SpotifyHomeFeedFromJson(json); +} + +Map transformSectionItemTypeJsonMap( + Map json) { + final data = json["content"]["data"]; + final objType = json["content"]["data"]["__typename"]; + return { + "typename": json["content"]["__typename"], + if (objType == "Playlist") + "playlist": { + "name": data["name"], + "description": data["description"], + "format": data["format"], + "images": (data["images"]["items"] as List) + .expand((j) => j["sources"] as dynamic) + .toList() + .cast>(), + "owner": data["ownerV2"]["data"]["name"], + "uri": data["uri"] + }, + if (objType == "Artist") + "artist": { + "name": data["profile"]["name"], + "uri": data["uri"], + "images": data["visuals"]["avatarImage"]["sources"], + }, + if (objType == "Album") + "album": { + "name": data["name"], + "uri": data["uri"], + "images": data["coverArt"]["sources"], + "artists": data["artists"]["items"] + .map( + (artist) => { + "name": artist["profile"]["name"], + "uri": artist["uri"], + }, + ) + .toList() + }, + }; +} + +Map transformSectionItemJsonMap(Map json) { + return { + "typename": json["data"]["__typename"], + "title": json["data"]?["title"]?["text"], + "uri": json["uri"], + "items": (json["sectionItems"]["items"] as List) + .map( + (data) => + transformSectionItemTypeJsonMap(data as Map) + as dynamic, + ) + .where( + (w) => + w["playlist"] != null || + w["artist"] != null || + w["album"] != null, + ) + .toList() + .cast>() + }; +} + +Map transformHomeFeedJsonMap(Map json) { + return { + "greeting": json["data"]["home"]["greeting"]["text"], + "sections": + (json["data"]["home"]["sectionContainer"]["sections"]["items"] as List) + .map( + (item) => + transformSectionItemJsonMap(item as Map) + as dynamic, + ) + .toList() + .cast>() + }; +} diff --git a/lib/models/spotify/home_feed.freezed.dart b/lib/models/spotify/home_feed.freezed.dart new file mode 100644 index 00000000..5076da29 --- /dev/null +++ b/lib/models/spotify/home_feed.freezed.dart @@ -0,0 +1,1776 @@ +// 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 'home_feed.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'); + +SpotifySectionPlaylist _$SpotifySectionPlaylistFromJson( + Map json) { + return _SpotifySectionPlaylist.fromJson(json); +} + +/// @nodoc +mixin _$SpotifySectionPlaylist { + String get description => throw _privateConstructorUsedError; + String get format => throw _privateConstructorUsedError; + List get images => + throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + String get owner => throw _privateConstructorUsedError; + String get uri => throw _privateConstructorUsedError; + + /// Serializes this SpotifySectionPlaylist to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of SpotifySectionPlaylist + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $SpotifySectionPlaylistCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SpotifySectionPlaylistCopyWith<$Res> { + factory $SpotifySectionPlaylistCopyWith(SpotifySectionPlaylist value, + $Res Function(SpotifySectionPlaylist) then) = + _$SpotifySectionPlaylistCopyWithImpl<$Res, SpotifySectionPlaylist>; + @useResult + $Res call( + {String description, + String format, + List images, + String name, + String owner, + String uri}); +} + +/// @nodoc +class _$SpotifySectionPlaylistCopyWithImpl<$Res, + $Val extends SpotifySectionPlaylist> + implements $SpotifySectionPlaylistCopyWith<$Res> { + _$SpotifySectionPlaylistCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of SpotifySectionPlaylist + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? description = null, + Object? format = null, + Object? images = null, + Object? name = null, + Object? owner = null, + Object? uri = null, + }) { + return _then(_value.copyWith( + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + format: null == format + ? _value.format + : format // ignore: cast_nullable_to_non_nullable + as String, + images: null == images + ? _value.images + : images // ignore: cast_nullable_to_non_nullable + as List, + 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, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SpotifySectionPlaylistImplCopyWith<$Res> + implements $SpotifySectionPlaylistCopyWith<$Res> { + factory _$$SpotifySectionPlaylistImplCopyWith( + _$SpotifySectionPlaylistImpl value, + $Res Function(_$SpotifySectionPlaylistImpl) then) = + __$$SpotifySectionPlaylistImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String description, + String format, + List images, + String name, + String owner, + String uri}); +} + +/// @nodoc +class __$$SpotifySectionPlaylistImplCopyWithImpl<$Res> + extends _$SpotifySectionPlaylistCopyWithImpl<$Res, + _$SpotifySectionPlaylistImpl> + implements _$$SpotifySectionPlaylistImplCopyWith<$Res> { + __$$SpotifySectionPlaylistImplCopyWithImpl( + _$SpotifySectionPlaylistImpl _value, + $Res Function(_$SpotifySectionPlaylistImpl) _then) + : super(_value, _then); + + /// Create a copy of SpotifySectionPlaylist + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? description = null, + Object? format = null, + Object? images = null, + Object? name = null, + Object? owner = null, + Object? uri = null, + }) { + return _then(_$SpotifySectionPlaylistImpl( + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + format: null == format + ? _value.format + : format // ignore: cast_nullable_to_non_nullable + as String, + images: null == images + ? _value._images + : images // ignore: cast_nullable_to_non_nullable + as List, + 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, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SpotifySectionPlaylistImpl extends _SpotifySectionPlaylist { + const _$SpotifySectionPlaylistImpl( + {required this.description, + required this.format, + required final List images, + required this.name, + required this.owner, + required this.uri}) + : _images = images, + super._(); + + factory _$SpotifySectionPlaylistImpl.fromJson(Map json) => + _$$SpotifySectionPlaylistImplFromJson(json); + + @override + final String description; + @override + final String format; + final List _images; + @override + List get images { + if (_images is EqualUnmodifiableListView) return _images; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_images); + } + + @override + final String name; + @override + final String owner; + @override + final String uri; + + @override + String toString() { + return 'SpotifySectionPlaylist(description: $description, format: $format, images: $images, name: $name, owner: $owner, uri: $uri)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SpotifySectionPlaylistImpl && + (identical(other.description, description) || + other.description == description) && + (identical(other.format, format) || other.format == format) && + const DeepCollectionEquality().equals(other._images, _images) && + (identical(other.name, name) || other.name == name) && + (identical(other.owner, owner) || other.owner == owner) && + (identical(other.uri, uri) || other.uri == uri)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, description, format, + const DeepCollectionEquality().hash(_images), name, owner, uri); + + /// Create a copy of SpotifySectionPlaylist + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SpotifySectionPlaylistImplCopyWith<_$SpotifySectionPlaylistImpl> + get copyWith => __$$SpotifySectionPlaylistImplCopyWithImpl< + _$SpotifySectionPlaylistImpl>(this, _$identity); + + @override + Map toJson() { + return _$$SpotifySectionPlaylistImplToJson( + this, + ); + } +} + +abstract class _SpotifySectionPlaylist extends SpotifySectionPlaylist { + const factory _SpotifySectionPlaylist( + {required final String description, + required final String format, + required final List images, + required final String name, + required final String owner, + required final String uri}) = _$SpotifySectionPlaylistImpl; + const _SpotifySectionPlaylist._() : super._(); + + factory _SpotifySectionPlaylist.fromJson(Map json) = + _$SpotifySectionPlaylistImpl.fromJson; + + @override + String get description; + @override + String get format; + @override + List get images; + @override + String get name; + @override + String get owner; + @override + String get uri; + + /// Create a copy of SpotifySectionPlaylist + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SpotifySectionPlaylistImplCopyWith<_$SpotifySectionPlaylistImpl> + get copyWith => throw _privateConstructorUsedError; +} + +SpotifySectionArtist _$SpotifySectionArtistFromJson(Map json) { + return _SpotifySectionArtist.fromJson(json); +} + +/// @nodoc +mixin _$SpotifySectionArtist { + String get name => throw _privateConstructorUsedError; + String get uri => throw _privateConstructorUsedError; + List get images => + throw _privateConstructorUsedError; + + /// Serializes this SpotifySectionArtist to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of SpotifySectionArtist + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $SpotifySectionArtistCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SpotifySectionArtistCopyWith<$Res> { + factory $SpotifySectionArtistCopyWith(SpotifySectionArtist value, + $Res Function(SpotifySectionArtist) then) = + _$SpotifySectionArtistCopyWithImpl<$Res, SpotifySectionArtist>; + @useResult + $Res call({String name, String uri, List images}); +} + +/// @nodoc +class _$SpotifySectionArtistCopyWithImpl<$Res, + $Val extends SpotifySectionArtist> + implements $SpotifySectionArtistCopyWith<$Res> { + _$SpotifySectionArtistCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of SpotifySectionArtist + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? uri = null, + Object? images = null, + }) { + return _then(_value.copyWith( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + images: null == images + ? _value.images + : images // ignore: cast_nullable_to_non_nullable + as List, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SpotifySectionArtistImplCopyWith<$Res> + implements $SpotifySectionArtistCopyWith<$Res> { + factory _$$SpotifySectionArtistImplCopyWith(_$SpotifySectionArtistImpl value, + $Res Function(_$SpotifySectionArtistImpl) then) = + __$$SpotifySectionArtistImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String name, String uri, List images}); +} + +/// @nodoc +class __$$SpotifySectionArtistImplCopyWithImpl<$Res> + extends _$SpotifySectionArtistCopyWithImpl<$Res, _$SpotifySectionArtistImpl> + implements _$$SpotifySectionArtistImplCopyWith<$Res> { + __$$SpotifySectionArtistImplCopyWithImpl(_$SpotifySectionArtistImpl _value, + $Res Function(_$SpotifySectionArtistImpl) _then) + : super(_value, _then); + + /// Create a copy of SpotifySectionArtist + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? uri = null, + Object? images = null, + }) { + return _then(_$SpotifySectionArtistImpl( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + images: null == images + ? _value._images + : images // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SpotifySectionArtistImpl extends _SpotifySectionArtist { + const _$SpotifySectionArtistImpl( + {required this.name, + required this.uri, + required final List images}) + : _images = images, + super._(); + + factory _$SpotifySectionArtistImpl.fromJson(Map json) => + _$$SpotifySectionArtistImplFromJson(json); + + @override + final String name; + @override + final String uri; + final List _images; + @override + List get images { + if (_images is EqualUnmodifiableListView) return _images; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_images); + } + + @override + String toString() { + return 'SpotifySectionArtist(name: $name, uri: $uri, images: $images)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SpotifySectionArtistImpl && + (identical(other.name, name) || other.name == name) && + (identical(other.uri, uri) || other.uri == uri) && + const DeepCollectionEquality().equals(other._images, _images)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, name, uri, const DeepCollectionEquality().hash(_images)); + + /// Create a copy of SpotifySectionArtist + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SpotifySectionArtistImplCopyWith<_$SpotifySectionArtistImpl> + get copyWith => + __$$SpotifySectionArtistImplCopyWithImpl<_$SpotifySectionArtistImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$SpotifySectionArtistImplToJson( + this, + ); + } +} + +abstract class _SpotifySectionArtist extends SpotifySectionArtist { + const factory _SpotifySectionArtist( + {required final String name, + required final String uri, + required final List images}) = + _$SpotifySectionArtistImpl; + const _SpotifySectionArtist._() : super._(); + + factory _SpotifySectionArtist.fromJson(Map json) = + _$SpotifySectionArtistImpl.fromJson; + + @override + String get name; + @override + String get uri; + @override + List get images; + + /// Create a copy of SpotifySectionArtist + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SpotifySectionArtistImplCopyWith<_$SpotifySectionArtistImpl> + get copyWith => throw _privateConstructorUsedError; +} + +SpotifySectionAlbum _$SpotifySectionAlbumFromJson(Map json) { + return _SpotifySectionAlbum.fromJson(json); +} + +/// @nodoc +mixin _$SpotifySectionAlbum { + List get artists => + throw _privateConstructorUsedError; + List get images => + throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + String get uri => throw _privateConstructorUsedError; + + /// Serializes this SpotifySectionAlbum to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of SpotifySectionAlbum + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $SpotifySectionAlbumCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SpotifySectionAlbumCopyWith<$Res> { + factory $SpotifySectionAlbumCopyWith( + SpotifySectionAlbum value, $Res Function(SpotifySectionAlbum) then) = + _$SpotifySectionAlbumCopyWithImpl<$Res, SpotifySectionAlbum>; + @useResult + $Res call( + {List artists, + List images, + String name, + String uri}); +} + +/// @nodoc +class _$SpotifySectionAlbumCopyWithImpl<$Res, $Val extends SpotifySectionAlbum> + implements $SpotifySectionAlbumCopyWith<$Res> { + _$SpotifySectionAlbumCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of SpotifySectionAlbum + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? artists = null, + Object? images = null, + Object? name = null, + Object? uri = null, + }) { + return _then(_value.copyWith( + 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, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SpotifySectionAlbumImplCopyWith<$Res> + implements $SpotifySectionAlbumCopyWith<$Res> { + factory _$$SpotifySectionAlbumImplCopyWith(_$SpotifySectionAlbumImpl value, + $Res Function(_$SpotifySectionAlbumImpl) then) = + __$$SpotifySectionAlbumImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {List artists, + List images, + String name, + String uri}); +} + +/// @nodoc +class __$$SpotifySectionAlbumImplCopyWithImpl<$Res> + extends _$SpotifySectionAlbumCopyWithImpl<$Res, _$SpotifySectionAlbumImpl> + implements _$$SpotifySectionAlbumImplCopyWith<$Res> { + __$$SpotifySectionAlbumImplCopyWithImpl(_$SpotifySectionAlbumImpl _value, + $Res Function(_$SpotifySectionAlbumImpl) _then) + : super(_value, _then); + + /// Create a copy of SpotifySectionAlbum + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? artists = null, + Object? images = null, + Object? name = null, + Object? uri = null, + }) { + return _then(_$SpotifySectionAlbumImpl( + 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, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SpotifySectionAlbumImpl extends _SpotifySectionAlbum { + const _$SpotifySectionAlbumImpl( + {required final List artists, + required final List images, + required this.name, + required this.uri}) + : _artists = artists, + _images = images, + super._(); + + factory _$SpotifySectionAlbumImpl.fromJson(Map json) => + _$$SpotifySectionAlbumImplFromJson(json); + + final List _artists; + @override + List get artists { + if (_artists is EqualUnmodifiableListView) return _artists; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_artists); + } + + final List _images; + @override + List get images { + if (_images is EqualUnmodifiableListView) return _images; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_images); + } + + @override + final String name; + @override + final String uri; + + @override + String toString() { + return 'SpotifySectionAlbum(artists: $artists, images: $images, name: $name, uri: $uri)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SpotifySectionAlbumImpl && + const DeepCollectionEquality().equals(other._artists, _artists) && + const DeepCollectionEquality().equals(other._images, _images) && + (identical(other.name, name) || other.name == name) && + (identical(other.uri, uri) || other.uri == uri)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(_artists), + const DeepCollectionEquality().hash(_images), + name, + uri); + + /// Create a copy of SpotifySectionAlbum + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SpotifySectionAlbumImplCopyWith<_$SpotifySectionAlbumImpl> get copyWith => + __$$SpotifySectionAlbumImplCopyWithImpl<_$SpotifySectionAlbumImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$SpotifySectionAlbumImplToJson( + this, + ); + } +} + +abstract class _SpotifySectionAlbum extends SpotifySectionAlbum { + const factory _SpotifySectionAlbum( + {required final List artists, + required final List images, + required final String name, + required final String uri}) = _$SpotifySectionAlbumImpl; + const _SpotifySectionAlbum._() : super._(); + + factory _SpotifySectionAlbum.fromJson(Map json) = + _$SpotifySectionAlbumImpl.fromJson; + + @override + List get artists; + @override + List get images; + @override + String get name; + @override + String get uri; + + /// Create a copy of SpotifySectionAlbum + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SpotifySectionAlbumImplCopyWith<_$SpotifySectionAlbumImpl> get copyWith => + throw _privateConstructorUsedError; +} + +SpotifySectionAlbumArtist _$SpotifySectionAlbumArtistFromJson( + Map json) { + return _SpotifySectionAlbumArtist.fromJson(json); +} + +/// @nodoc +mixin _$SpotifySectionAlbumArtist { + String get name => throw _privateConstructorUsedError; + String get uri => throw _privateConstructorUsedError; + + /// Serializes this SpotifySectionAlbumArtist to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of SpotifySectionAlbumArtist + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $SpotifySectionAlbumArtistCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SpotifySectionAlbumArtistCopyWith<$Res> { + factory $SpotifySectionAlbumArtistCopyWith(SpotifySectionAlbumArtist value, + $Res Function(SpotifySectionAlbumArtist) then) = + _$SpotifySectionAlbumArtistCopyWithImpl<$Res, SpotifySectionAlbumArtist>; + @useResult + $Res call({String name, String uri}); +} + +/// @nodoc +class _$SpotifySectionAlbumArtistCopyWithImpl<$Res, + $Val extends SpotifySectionAlbumArtist> + implements $SpotifySectionAlbumArtistCopyWith<$Res> { + _$SpotifySectionAlbumArtistCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of SpotifySectionAlbumArtist + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? uri = null, + }) { + return _then(_value.copyWith( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SpotifySectionAlbumArtistImplCopyWith<$Res> + implements $SpotifySectionAlbumArtistCopyWith<$Res> { + factory _$$SpotifySectionAlbumArtistImplCopyWith( + _$SpotifySectionAlbumArtistImpl value, + $Res Function(_$SpotifySectionAlbumArtistImpl) then) = + __$$SpotifySectionAlbumArtistImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String name, String uri}); +} + +/// @nodoc +class __$$SpotifySectionAlbumArtistImplCopyWithImpl<$Res> + extends _$SpotifySectionAlbumArtistCopyWithImpl<$Res, + _$SpotifySectionAlbumArtistImpl> + implements _$$SpotifySectionAlbumArtistImplCopyWith<$Res> { + __$$SpotifySectionAlbumArtistImplCopyWithImpl( + _$SpotifySectionAlbumArtistImpl _value, + $Res Function(_$SpotifySectionAlbumArtistImpl) _then) + : super(_value, _then); + + /// Create a copy of SpotifySectionAlbumArtist + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? uri = null, + }) { + return _then(_$SpotifySectionAlbumArtistImpl( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SpotifySectionAlbumArtistImpl extends _SpotifySectionAlbumArtist { + const _$SpotifySectionAlbumArtistImpl({required this.name, required this.uri}) + : super._(); + + factory _$SpotifySectionAlbumArtistImpl.fromJson(Map json) => + _$$SpotifySectionAlbumArtistImplFromJson(json); + + @override + final String name; + @override + final String uri; + + @override + String toString() { + return 'SpotifySectionAlbumArtist(name: $name, uri: $uri)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SpotifySectionAlbumArtistImpl && + (identical(other.name, name) || other.name == name) && + (identical(other.uri, uri) || other.uri == uri)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, name, uri); + + /// Create a copy of SpotifySectionAlbumArtist + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SpotifySectionAlbumArtistImplCopyWith<_$SpotifySectionAlbumArtistImpl> + get copyWith => __$$SpotifySectionAlbumArtistImplCopyWithImpl< + _$SpotifySectionAlbumArtistImpl>(this, _$identity); + + @override + Map toJson() { + return _$$SpotifySectionAlbumArtistImplToJson( + this, + ); + } +} + +abstract class _SpotifySectionAlbumArtist extends SpotifySectionAlbumArtist { + const factory _SpotifySectionAlbumArtist( + {required final String name, + required final String uri}) = _$SpotifySectionAlbumArtistImpl; + const _SpotifySectionAlbumArtist._() : super._(); + + factory _SpotifySectionAlbumArtist.fromJson(Map json) = + _$SpotifySectionAlbumArtistImpl.fromJson; + + @override + String get name; + @override + String get uri; + + /// Create a copy of SpotifySectionAlbumArtist + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SpotifySectionAlbumArtistImplCopyWith<_$SpotifySectionAlbumArtistImpl> + get copyWith => throw _privateConstructorUsedError; +} + +SpotifySectionItemImage _$SpotifySectionItemImageFromJson( + Map json) { + return _SpotifySectionItemImage.fromJson(json); +} + +/// @nodoc +mixin _$SpotifySectionItemImage { + num? get height => throw _privateConstructorUsedError; + String get url => throw _privateConstructorUsedError; + num? get width => throw _privateConstructorUsedError; + + /// Serializes this SpotifySectionItemImage to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of SpotifySectionItemImage + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $SpotifySectionItemImageCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SpotifySectionItemImageCopyWith<$Res> { + factory $SpotifySectionItemImageCopyWith(SpotifySectionItemImage value, + $Res Function(SpotifySectionItemImage) then) = + _$SpotifySectionItemImageCopyWithImpl<$Res, SpotifySectionItemImage>; + @useResult + $Res call({num? height, String url, num? width}); +} + +/// @nodoc +class _$SpotifySectionItemImageCopyWithImpl<$Res, + $Val extends SpotifySectionItemImage> + implements $SpotifySectionItemImageCopyWith<$Res> { + _$SpotifySectionItemImageCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of SpotifySectionItemImage + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? height = freezed, + Object? url = null, + Object? width = freezed, + }) { + return _then(_value.copyWith( + height: freezed == height + ? _value.height + : height // ignore: cast_nullable_to_non_nullable + as num?, + 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 num?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SpotifySectionItemImageImplCopyWith<$Res> + implements $SpotifySectionItemImageCopyWith<$Res> { + factory _$$SpotifySectionItemImageImplCopyWith( + _$SpotifySectionItemImageImpl value, + $Res Function(_$SpotifySectionItemImageImpl) then) = + __$$SpotifySectionItemImageImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({num? height, String url, num? width}); +} + +/// @nodoc +class __$$SpotifySectionItemImageImplCopyWithImpl<$Res> + extends _$SpotifySectionItemImageCopyWithImpl<$Res, + _$SpotifySectionItemImageImpl> + implements _$$SpotifySectionItemImageImplCopyWith<$Res> { + __$$SpotifySectionItemImageImplCopyWithImpl( + _$SpotifySectionItemImageImpl _value, + $Res Function(_$SpotifySectionItemImageImpl) _then) + : super(_value, _then); + + /// Create a copy of SpotifySectionItemImage + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? height = freezed, + Object? url = null, + Object? width = freezed, + }) { + return _then(_$SpotifySectionItemImageImpl( + height: freezed == height + ? _value.height + : height // ignore: cast_nullable_to_non_nullable + as num?, + 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 num?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SpotifySectionItemImageImpl extends _SpotifySectionItemImage { + const _$SpotifySectionItemImageImpl( + {required this.height, required this.url, required this.width}) + : super._(); + + factory _$SpotifySectionItemImageImpl.fromJson(Map json) => + _$$SpotifySectionItemImageImplFromJson(json); + + @override + final num? height; + @override + final String url; + @override + final num? width; + + @override + String toString() { + return 'SpotifySectionItemImage(height: $height, url: $url, width: $width)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SpotifySectionItemImageImpl && + (identical(other.height, height) || other.height == height) && + (identical(other.url, url) || other.url == url) && + (identical(other.width, width) || other.width == width)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, height, url, width); + + /// Create a copy of SpotifySectionItemImage + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SpotifySectionItemImageImplCopyWith<_$SpotifySectionItemImageImpl> + get copyWith => __$$SpotifySectionItemImageImplCopyWithImpl< + _$SpotifySectionItemImageImpl>(this, _$identity); + + @override + Map toJson() { + return _$$SpotifySectionItemImageImplToJson( + this, + ); + } +} + +abstract class _SpotifySectionItemImage extends SpotifySectionItemImage { + const factory _SpotifySectionItemImage( + {required final num? height, + required final String url, + required final num? width}) = _$SpotifySectionItemImageImpl; + const _SpotifySectionItemImage._() : super._(); + + factory _SpotifySectionItemImage.fromJson(Map json) = + _$SpotifySectionItemImageImpl.fromJson; + + @override + num? get height; + @override + String get url; + @override + num? get width; + + /// Create a copy of SpotifySectionItemImage + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SpotifySectionItemImageImplCopyWith<_$SpotifySectionItemImageImpl> + get copyWith => throw _privateConstructorUsedError; +} + +SpotifyHomeFeedSectionItem _$SpotifyHomeFeedSectionItemFromJson( + Map json) { + return _SpotifyHomeFeedSectionItem.fromJson(json); +} + +/// @nodoc +mixin _$SpotifyHomeFeedSectionItem { + String get typename => throw _privateConstructorUsedError; + SpotifySectionPlaylist? get playlist => throw _privateConstructorUsedError; + SpotifySectionArtist? get artist => throw _privateConstructorUsedError; + SpotifySectionAlbum? get album => throw _privateConstructorUsedError; + + /// Serializes this SpotifyHomeFeedSectionItem to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of SpotifyHomeFeedSectionItem + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $SpotifyHomeFeedSectionItemCopyWith + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SpotifyHomeFeedSectionItemCopyWith<$Res> { + factory $SpotifyHomeFeedSectionItemCopyWith(SpotifyHomeFeedSectionItem value, + $Res Function(SpotifyHomeFeedSectionItem) then) = + _$SpotifyHomeFeedSectionItemCopyWithImpl<$Res, + SpotifyHomeFeedSectionItem>; + @useResult + $Res call( + {String typename, + SpotifySectionPlaylist? playlist, + SpotifySectionArtist? artist, + SpotifySectionAlbum? album}); + + $SpotifySectionPlaylistCopyWith<$Res>? get playlist; + $SpotifySectionArtistCopyWith<$Res>? get artist; + $SpotifySectionAlbumCopyWith<$Res>? get album; +} + +/// @nodoc +class _$SpotifyHomeFeedSectionItemCopyWithImpl<$Res, + $Val extends SpotifyHomeFeedSectionItem> + implements $SpotifyHomeFeedSectionItemCopyWith<$Res> { + _$SpotifyHomeFeedSectionItemCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of SpotifyHomeFeedSectionItem + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? typename = null, + Object? playlist = freezed, + Object? artist = freezed, + Object? album = freezed, + }) { + return _then(_value.copyWith( + typename: null == typename + ? _value.typename + : typename // ignore: cast_nullable_to_non_nullable + as String, + playlist: freezed == playlist + ? _value.playlist + : playlist // ignore: cast_nullable_to_non_nullable + as SpotifySectionPlaylist?, + artist: freezed == artist + ? _value.artist + : artist // ignore: cast_nullable_to_non_nullable + as SpotifySectionArtist?, + album: freezed == album + ? _value.album + : album // ignore: cast_nullable_to_non_nullable + as SpotifySectionAlbum?, + ) as $Val); + } + + /// Create a copy of SpotifyHomeFeedSectionItem + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $SpotifySectionPlaylistCopyWith<$Res>? get playlist { + if (_value.playlist == null) { + return null; + } + + return $SpotifySectionPlaylistCopyWith<$Res>(_value.playlist!, (value) { + return _then(_value.copyWith(playlist: value) as $Val); + }); + } + + /// Create a copy of SpotifyHomeFeedSectionItem + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $SpotifySectionArtistCopyWith<$Res>? get artist { + if (_value.artist == null) { + return null; + } + + return $SpotifySectionArtistCopyWith<$Res>(_value.artist!, (value) { + return _then(_value.copyWith(artist: value) as $Val); + }); + } + + /// Create a copy of SpotifyHomeFeedSectionItem + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $SpotifySectionAlbumCopyWith<$Res>? get album { + if (_value.album == null) { + return null; + } + + return $SpotifySectionAlbumCopyWith<$Res>(_value.album!, (value) { + return _then(_value.copyWith(album: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$SpotifyHomeFeedSectionItemImplCopyWith<$Res> + implements $SpotifyHomeFeedSectionItemCopyWith<$Res> { + factory _$$SpotifyHomeFeedSectionItemImplCopyWith( + _$SpotifyHomeFeedSectionItemImpl value, + $Res Function(_$SpotifyHomeFeedSectionItemImpl) then) = + __$$SpotifyHomeFeedSectionItemImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String typename, + SpotifySectionPlaylist? playlist, + SpotifySectionArtist? artist, + SpotifySectionAlbum? album}); + + @override + $SpotifySectionPlaylistCopyWith<$Res>? get playlist; + @override + $SpotifySectionArtistCopyWith<$Res>? get artist; + @override + $SpotifySectionAlbumCopyWith<$Res>? get album; +} + +/// @nodoc +class __$$SpotifyHomeFeedSectionItemImplCopyWithImpl<$Res> + extends _$SpotifyHomeFeedSectionItemCopyWithImpl<$Res, + _$SpotifyHomeFeedSectionItemImpl> + implements _$$SpotifyHomeFeedSectionItemImplCopyWith<$Res> { + __$$SpotifyHomeFeedSectionItemImplCopyWithImpl( + _$SpotifyHomeFeedSectionItemImpl _value, + $Res Function(_$SpotifyHomeFeedSectionItemImpl) _then) + : super(_value, _then); + + /// Create a copy of SpotifyHomeFeedSectionItem + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? typename = null, + Object? playlist = freezed, + Object? artist = freezed, + Object? album = freezed, + }) { + return _then(_$SpotifyHomeFeedSectionItemImpl( + typename: null == typename + ? _value.typename + : typename // ignore: cast_nullable_to_non_nullable + as String, + playlist: freezed == playlist + ? _value.playlist + : playlist // ignore: cast_nullable_to_non_nullable + as SpotifySectionPlaylist?, + artist: freezed == artist + ? _value.artist + : artist // ignore: cast_nullable_to_non_nullable + as SpotifySectionArtist?, + album: freezed == album + ? _value.album + : album // ignore: cast_nullable_to_non_nullable + as SpotifySectionAlbum?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SpotifyHomeFeedSectionItemImpl implements _SpotifyHomeFeedSectionItem { + _$SpotifyHomeFeedSectionItemImpl( + {required this.typename, this.playlist, this.artist, this.album}); + + factory _$SpotifyHomeFeedSectionItemImpl.fromJson( + Map json) => + _$$SpotifyHomeFeedSectionItemImplFromJson(json); + + @override + final String typename; + @override + final SpotifySectionPlaylist? playlist; + @override + final SpotifySectionArtist? artist; + @override + final SpotifySectionAlbum? album; + + @override + String toString() { + return 'SpotifyHomeFeedSectionItem(typename: $typename, playlist: $playlist, artist: $artist, album: $album)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SpotifyHomeFeedSectionItemImpl && + (identical(other.typename, typename) || + other.typename == typename) && + (identical(other.playlist, playlist) || + other.playlist == playlist) && + (identical(other.artist, artist) || other.artist == artist) && + (identical(other.album, album) || other.album == album)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => + Object.hash(runtimeType, typename, playlist, artist, album); + + /// Create a copy of SpotifyHomeFeedSectionItem + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SpotifyHomeFeedSectionItemImplCopyWith<_$SpotifyHomeFeedSectionItemImpl> + get copyWith => __$$SpotifyHomeFeedSectionItemImplCopyWithImpl< + _$SpotifyHomeFeedSectionItemImpl>(this, _$identity); + + @override + Map toJson() { + return _$$SpotifyHomeFeedSectionItemImplToJson( + this, + ); + } +} + +abstract class _SpotifyHomeFeedSectionItem + implements SpotifyHomeFeedSectionItem { + factory _SpotifyHomeFeedSectionItem( + {required final String typename, + final SpotifySectionPlaylist? playlist, + final SpotifySectionArtist? artist, + final SpotifySectionAlbum? album}) = _$SpotifyHomeFeedSectionItemImpl; + + factory _SpotifyHomeFeedSectionItem.fromJson(Map json) = + _$SpotifyHomeFeedSectionItemImpl.fromJson; + + @override + String get typename; + @override + SpotifySectionPlaylist? get playlist; + @override + SpotifySectionArtist? get artist; + @override + SpotifySectionAlbum? get album; + + /// Create a copy of SpotifyHomeFeedSectionItem + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SpotifyHomeFeedSectionItemImplCopyWith<_$SpotifyHomeFeedSectionItemImpl> + get copyWith => throw _privateConstructorUsedError; +} + +SpotifyHomeFeedSection _$SpotifyHomeFeedSectionFromJson( + Map json) { + return _SpotifyHomeFeedSection.fromJson(json); +} + +/// @nodoc +mixin _$SpotifyHomeFeedSection { + String get typename => throw _privateConstructorUsedError; + String? get title => throw _privateConstructorUsedError; + String get uri => throw _privateConstructorUsedError; + List get items => + throw _privateConstructorUsedError; + + /// Serializes this SpotifyHomeFeedSection to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of SpotifyHomeFeedSection + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $SpotifyHomeFeedSectionCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SpotifyHomeFeedSectionCopyWith<$Res> { + factory $SpotifyHomeFeedSectionCopyWith(SpotifyHomeFeedSection value, + $Res Function(SpotifyHomeFeedSection) then) = + _$SpotifyHomeFeedSectionCopyWithImpl<$Res, SpotifyHomeFeedSection>; + @useResult + $Res call( + {String typename, + String? title, + String uri, + List items}); +} + +/// @nodoc +class _$SpotifyHomeFeedSectionCopyWithImpl<$Res, + $Val extends SpotifyHomeFeedSection> + implements $SpotifyHomeFeedSectionCopyWith<$Res> { + _$SpotifyHomeFeedSectionCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of SpotifyHomeFeedSection + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? typename = null, + Object? title = freezed, + Object? uri = null, + Object? items = null, + }) { + return _then(_value.copyWith( + typename: null == typename + ? _value.typename + : typename // ignore: cast_nullable_to_non_nullable + as String, + title: freezed == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String?, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + items: null == items + ? _value.items + : items // ignore: cast_nullable_to_non_nullable + as List, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SpotifyHomeFeedSectionImplCopyWith<$Res> + implements $SpotifyHomeFeedSectionCopyWith<$Res> { + factory _$$SpotifyHomeFeedSectionImplCopyWith( + _$SpotifyHomeFeedSectionImpl value, + $Res Function(_$SpotifyHomeFeedSectionImpl) then) = + __$$SpotifyHomeFeedSectionImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String typename, + String? title, + String uri, + List items}); +} + +/// @nodoc +class __$$SpotifyHomeFeedSectionImplCopyWithImpl<$Res> + extends _$SpotifyHomeFeedSectionCopyWithImpl<$Res, + _$SpotifyHomeFeedSectionImpl> + implements _$$SpotifyHomeFeedSectionImplCopyWith<$Res> { + __$$SpotifyHomeFeedSectionImplCopyWithImpl( + _$SpotifyHomeFeedSectionImpl _value, + $Res Function(_$SpotifyHomeFeedSectionImpl) _then) + : super(_value, _then); + + /// Create a copy of SpotifyHomeFeedSection + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? typename = null, + Object? title = freezed, + Object? uri = null, + Object? items = null, + }) { + return _then(_$SpotifyHomeFeedSectionImpl( + typename: null == typename + ? _value.typename + : typename // ignore: cast_nullable_to_non_nullable + as String, + title: freezed == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String?, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + items: null == items + ? _value._items + : items // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SpotifyHomeFeedSectionImpl implements _SpotifyHomeFeedSection { + _$SpotifyHomeFeedSectionImpl( + {required this.typename, + this.title, + required this.uri, + required final List items}) + : _items = items; + + factory _$SpotifyHomeFeedSectionImpl.fromJson(Map json) => + _$$SpotifyHomeFeedSectionImplFromJson(json); + + @override + final String typename; + @override + final String? title; + @override + final String uri; + final List _items; + @override + List get items { + if (_items is EqualUnmodifiableListView) return _items; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_items); + } + + @override + String toString() { + return 'SpotifyHomeFeedSection(typename: $typename, title: $title, uri: $uri, items: $items)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SpotifyHomeFeedSectionImpl && + (identical(other.typename, typename) || + other.typename == typename) && + (identical(other.title, title) || other.title == title) && + (identical(other.uri, uri) || other.uri == uri) && + const DeepCollectionEquality().equals(other._items, _items)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, typename, title, uri, + const DeepCollectionEquality().hash(_items)); + + /// Create a copy of SpotifyHomeFeedSection + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SpotifyHomeFeedSectionImplCopyWith<_$SpotifyHomeFeedSectionImpl> + get copyWith => __$$SpotifyHomeFeedSectionImplCopyWithImpl< + _$SpotifyHomeFeedSectionImpl>(this, _$identity); + + @override + Map toJson() { + return _$$SpotifyHomeFeedSectionImplToJson( + this, + ); + } +} + +abstract class _SpotifyHomeFeedSection implements SpotifyHomeFeedSection { + factory _SpotifyHomeFeedSection( + {required final String typename, + final String? title, + required final String uri, + required final List items}) = + _$SpotifyHomeFeedSectionImpl; + + factory _SpotifyHomeFeedSection.fromJson(Map json) = + _$SpotifyHomeFeedSectionImpl.fromJson; + + @override + String get typename; + @override + String? get title; + @override + String get uri; + @override + List get items; + + /// Create a copy of SpotifyHomeFeedSection + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SpotifyHomeFeedSectionImplCopyWith<_$SpotifyHomeFeedSectionImpl> + get copyWith => throw _privateConstructorUsedError; +} + +SpotifyHomeFeed _$SpotifyHomeFeedFromJson(Map json) { + return _SpotifyHomeFeed.fromJson(json); +} + +/// @nodoc +mixin _$SpotifyHomeFeed { + String get greeting => throw _privateConstructorUsedError; + List get sections => + throw _privateConstructorUsedError; + + /// Serializes this SpotifyHomeFeed to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of SpotifyHomeFeed + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $SpotifyHomeFeedCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SpotifyHomeFeedCopyWith<$Res> { + factory $SpotifyHomeFeedCopyWith( + SpotifyHomeFeed value, $Res Function(SpotifyHomeFeed) then) = + _$SpotifyHomeFeedCopyWithImpl<$Res, SpotifyHomeFeed>; + @useResult + $Res call({String greeting, List sections}); +} + +/// @nodoc +class _$SpotifyHomeFeedCopyWithImpl<$Res, $Val extends SpotifyHomeFeed> + implements $SpotifyHomeFeedCopyWith<$Res> { + _$SpotifyHomeFeedCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of SpotifyHomeFeed + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? greeting = null, + Object? sections = null, + }) { + return _then(_value.copyWith( + greeting: null == greeting + ? _value.greeting + : greeting // ignore: cast_nullable_to_non_nullable + as String, + sections: null == sections + ? _value.sections + : sections // ignore: cast_nullable_to_non_nullable + as List, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SpotifyHomeFeedImplCopyWith<$Res> + implements $SpotifyHomeFeedCopyWith<$Res> { + factory _$$SpotifyHomeFeedImplCopyWith(_$SpotifyHomeFeedImpl value, + $Res Function(_$SpotifyHomeFeedImpl) then) = + __$$SpotifyHomeFeedImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String greeting, List sections}); +} + +/// @nodoc +class __$$SpotifyHomeFeedImplCopyWithImpl<$Res> + extends _$SpotifyHomeFeedCopyWithImpl<$Res, _$SpotifyHomeFeedImpl> + implements _$$SpotifyHomeFeedImplCopyWith<$Res> { + __$$SpotifyHomeFeedImplCopyWithImpl( + _$SpotifyHomeFeedImpl _value, $Res Function(_$SpotifyHomeFeedImpl) _then) + : super(_value, _then); + + /// Create a copy of SpotifyHomeFeed + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? greeting = null, + Object? sections = null, + }) { + return _then(_$SpotifyHomeFeedImpl( + greeting: null == greeting + ? _value.greeting + : greeting // ignore: cast_nullable_to_non_nullable + as String, + sections: null == sections + ? _value._sections + : sections // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SpotifyHomeFeedImpl implements _SpotifyHomeFeed { + _$SpotifyHomeFeedImpl( + {required this.greeting, + required final List sections}) + : _sections = sections; + + factory _$SpotifyHomeFeedImpl.fromJson(Map json) => + _$$SpotifyHomeFeedImplFromJson(json); + + @override + final String greeting; + final List _sections; + @override + List get sections { + if (_sections is EqualUnmodifiableListView) return _sections; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_sections); + } + + @override + String toString() { + return 'SpotifyHomeFeed(greeting: $greeting, sections: $sections)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SpotifyHomeFeedImpl && + (identical(other.greeting, greeting) || + other.greeting == greeting) && + const DeepCollectionEquality().equals(other._sections, _sections)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, greeting, const DeepCollectionEquality().hash(_sections)); + + /// Create a copy of SpotifyHomeFeed + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SpotifyHomeFeedImplCopyWith<_$SpotifyHomeFeedImpl> get copyWith => + __$$SpotifyHomeFeedImplCopyWithImpl<_$SpotifyHomeFeedImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$SpotifyHomeFeedImplToJson( + this, + ); + } +} + +abstract class _SpotifyHomeFeed implements SpotifyHomeFeed { + factory _SpotifyHomeFeed( + {required final String greeting, + required final List sections}) = + _$SpotifyHomeFeedImpl; + + factory _SpotifyHomeFeed.fromJson(Map json) = + _$SpotifyHomeFeedImpl.fromJson; + + @override + String get greeting; + @override + List get sections; + + /// Create a copy of SpotifyHomeFeed + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SpotifyHomeFeedImplCopyWith<_$SpotifyHomeFeedImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/models/spotify/home_feed.g.dart b/lib/models/spotify/home_feed.g.dart new file mode 100644 index 00000000..fceb3db4 --- /dev/null +++ b/lib/models/spotify/home_feed.g.dart @@ -0,0 +1,165 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'home_feed.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$SpotifySectionPlaylistImpl _$$SpotifySectionPlaylistImplFromJson(Map json) => + _$SpotifySectionPlaylistImpl( + description: json['description'] as String, + format: json['format'] as String, + images: (json['images'] as List) + .map((e) => SpotifySectionItemImage.fromJson( + Map.from(e as Map))) + .toList(), + name: json['name'] as String, + owner: json['owner'] as String, + uri: json['uri'] as String, + ); + +Map _$$SpotifySectionPlaylistImplToJson( + _$SpotifySectionPlaylistImpl instance) => + { + 'description': instance.description, + 'format': instance.format, + 'images': instance.images.map((e) => e.toJson()).toList(), + 'name': instance.name, + 'owner': instance.owner, + 'uri': instance.uri, + }; + +_$SpotifySectionArtistImpl _$$SpotifySectionArtistImplFromJson(Map json) => + _$SpotifySectionArtistImpl( + name: json['name'] as String, + uri: json['uri'] as String, + images: (json['images'] as List) + .map((e) => SpotifySectionItemImage.fromJson( + Map.from(e as Map))) + .toList(), + ); + +Map _$$SpotifySectionArtistImplToJson( + _$SpotifySectionArtistImpl instance) => + { + 'name': instance.name, + 'uri': instance.uri, + 'images': instance.images.map((e) => e.toJson()).toList(), + }; + +_$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson(Map json) => + _$SpotifySectionAlbumImpl( + artists: (json['artists'] as List) + .map((e) => SpotifySectionAlbumArtist.fromJson( + Map.from(e as Map))) + .toList(), + images: (json['images'] as List) + .map((e) => SpotifySectionItemImage.fromJson( + Map.from(e as Map))) + .toList(), + name: json['name'] as String, + uri: json['uri'] as String, + ); + +Map _$$SpotifySectionAlbumImplToJson( + _$SpotifySectionAlbumImpl instance) => + { + 'artists': instance.artists.map((e) => e.toJson()).toList(), + 'images': instance.images.map((e) => e.toJson()).toList(), + 'name': instance.name, + 'uri': instance.uri, + }; + +_$SpotifySectionAlbumArtistImpl _$$SpotifySectionAlbumArtistImplFromJson( + Map json) => + _$SpotifySectionAlbumArtistImpl( + name: json['name'] as String, + uri: json['uri'] as String, + ); + +Map _$$SpotifySectionAlbumArtistImplToJson( + _$SpotifySectionAlbumArtistImpl instance) => + { + 'name': instance.name, + 'uri': instance.uri, + }; + +_$SpotifySectionItemImageImpl _$$SpotifySectionItemImageImplFromJson( + Map json) => + _$SpotifySectionItemImageImpl( + height: json['height'] as num?, + url: json['url'] as String, + width: json['width'] as num?, + ); + +Map _$$SpotifySectionItemImageImplToJson( + _$SpotifySectionItemImageImpl instance) => + { + 'height': instance.height, + 'url': instance.url, + 'width': instance.width, + }; + +_$SpotifyHomeFeedSectionItemImpl _$$SpotifyHomeFeedSectionItemImplFromJson( + Map json) => + _$SpotifyHomeFeedSectionItemImpl( + typename: json['typename'] as String, + playlist: json['playlist'] == null + ? null + : SpotifySectionPlaylist.fromJson( + Map.from(json['playlist'] as Map)), + artist: json['artist'] == null + ? null + : SpotifySectionArtist.fromJson( + Map.from(json['artist'] as Map)), + album: json['album'] == null + ? null + : SpotifySectionAlbum.fromJson( + Map.from(json['album'] as Map)), + ); + +Map _$$SpotifyHomeFeedSectionItemImplToJson( + _$SpotifyHomeFeedSectionItemImpl instance) => + { + 'typename': instance.typename, + 'playlist': instance.playlist?.toJson(), + 'artist': instance.artist?.toJson(), + 'album': instance.album?.toJson(), + }; + +_$SpotifyHomeFeedSectionImpl _$$SpotifyHomeFeedSectionImplFromJson(Map json) => + _$SpotifyHomeFeedSectionImpl( + typename: json['typename'] as String, + title: json['title'] as String?, + uri: json['uri'] as String, + items: (json['items'] as List) + .map((e) => SpotifyHomeFeedSectionItem.fromJson( + Map.from(e as Map))) + .toList(), + ); + +Map _$$SpotifyHomeFeedSectionImplToJson( + _$SpotifyHomeFeedSectionImpl instance) => + { + 'typename': instance.typename, + 'title': instance.title, + 'uri': instance.uri, + 'items': instance.items.map((e) => e.toJson()).toList(), + }; + +_$SpotifyHomeFeedImpl _$$SpotifyHomeFeedImplFromJson(Map json) => + _$SpotifyHomeFeedImpl( + greeting: json['greeting'] as String, + sections: (json['sections'] as List) + .map((e) => SpotifyHomeFeedSection.fromJson( + Map.from(e as Map))) + .toList(), + ); + +Map _$$SpotifyHomeFeedImplToJson( + _$SpotifyHomeFeedImpl instance) => + { + 'greeting': instance.greeting, + 'sections': instance.sections.map((e) => e.toJson()).toList(), + }; diff --git a/lib/models/spotify/recommendation_seeds.dart b/lib/models/spotify/recommendation_seeds.dart new file mode 100644 index 00000000..0d874ad6 --- /dev/null +++ b/lib/models/spotify/recommendation_seeds.dart @@ -0,0 +1,40 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'recommendation_seeds.freezed.dart'; +part 'recommendation_seeds.g.dart'; + +@freezed +class GeneratePlaylistProviderInput with _$GeneratePlaylistProviderInput { + factory GeneratePlaylistProviderInput({ + Iterable? seedArtists, + Iterable? seedGenres, + Iterable? seedTracks, + required int limit, + RecommendationSeeds? max, + RecommendationSeeds? min, + RecommendationSeeds? target, + }) = _GeneratePlaylistProviderInput; +} + +@freezed +class RecommendationSeeds with _$RecommendationSeeds { + factory RecommendationSeeds({ + num? acousticness, + num? danceability, + @JsonKey(name: "duration_ms") num? durationMs, + num? energy, + num? instrumentalness, + num? key, + num? liveness, + num? loudness, + num? mode, + num? popularity, + num? speechiness, + num? tempo, + @JsonKey(name: "time_signature") num? timeSignature, + num? valence, + }) = _RecommendationSeeds; + + factory RecommendationSeeds.fromJson(Map json) => + _$RecommendationSeedsFromJson(json); +} diff --git a/lib/models/spotify/recommendation_seeds.freezed.dart b/lib/models/spotify/recommendation_seeds.freezed.dart new file mode 100644 index 00000000..c55a4134 --- /dev/null +++ b/lib/models/spotify/recommendation_seeds.freezed.dart @@ -0,0 +1,786 @@ +// 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 'recommendation_seeds.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'); + +/// @nodoc +mixin _$GeneratePlaylistProviderInput { + Iterable? get seedArtists => throw _privateConstructorUsedError; + Iterable? get seedGenres => throw _privateConstructorUsedError; + Iterable? get seedTracks => throw _privateConstructorUsedError; + int get limit => throw _privateConstructorUsedError; + RecommendationSeeds? get max => throw _privateConstructorUsedError; + RecommendationSeeds? get min => throw _privateConstructorUsedError; + RecommendationSeeds? get target => throw _privateConstructorUsedError; + + /// Create a copy of GeneratePlaylistProviderInput + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $GeneratePlaylistProviderInputCopyWith + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $GeneratePlaylistProviderInputCopyWith<$Res> { + factory $GeneratePlaylistProviderInputCopyWith( + GeneratePlaylistProviderInput value, + $Res Function(GeneratePlaylistProviderInput) then) = + _$GeneratePlaylistProviderInputCopyWithImpl<$Res, + GeneratePlaylistProviderInput>; + @useResult + $Res call( + {Iterable? seedArtists, + Iterable? seedGenres, + Iterable? seedTracks, + int limit, + RecommendationSeeds? max, + RecommendationSeeds? min, + RecommendationSeeds? target}); + + $RecommendationSeedsCopyWith<$Res>? get max; + $RecommendationSeedsCopyWith<$Res>? get min; + $RecommendationSeedsCopyWith<$Res>? get target; +} + +/// @nodoc +class _$GeneratePlaylistProviderInputCopyWithImpl<$Res, + $Val extends GeneratePlaylistProviderInput> + implements $GeneratePlaylistProviderInputCopyWith<$Res> { + _$GeneratePlaylistProviderInputCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of GeneratePlaylistProviderInput + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? seedArtists = freezed, + Object? seedGenres = freezed, + Object? seedTracks = freezed, + Object? limit = null, + Object? max = freezed, + Object? min = freezed, + Object? target = freezed, + }) { + return _then(_value.copyWith( + seedArtists: freezed == seedArtists + ? _value.seedArtists + : seedArtists // ignore: cast_nullable_to_non_nullable + as Iterable?, + seedGenres: freezed == seedGenres + ? _value.seedGenres + : seedGenres // ignore: cast_nullable_to_non_nullable + as Iterable?, + seedTracks: freezed == seedTracks + ? _value.seedTracks + : seedTracks // ignore: cast_nullable_to_non_nullable + as Iterable?, + limit: null == limit + ? _value.limit + : limit // ignore: cast_nullable_to_non_nullable + as int, + max: freezed == max + ? _value.max + : max // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + min: freezed == min + ? _value.min + : min // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + target: freezed == target + ? _value.target + : target // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + ) as $Val); + } + + /// Create a copy of GeneratePlaylistProviderInput + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $RecommendationSeedsCopyWith<$Res>? get max { + if (_value.max == null) { + return null; + } + + return $RecommendationSeedsCopyWith<$Res>(_value.max!, (value) { + return _then(_value.copyWith(max: value) as $Val); + }); + } + + /// Create a copy of GeneratePlaylistProviderInput + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $RecommendationSeedsCopyWith<$Res>? get min { + if (_value.min == null) { + return null; + } + + return $RecommendationSeedsCopyWith<$Res>(_value.min!, (value) { + return _then(_value.copyWith(min: value) as $Val); + }); + } + + /// Create a copy of GeneratePlaylistProviderInput + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $RecommendationSeedsCopyWith<$Res>? get target { + if (_value.target == null) { + return null; + } + + return $RecommendationSeedsCopyWith<$Res>(_value.target!, (value) { + return _then(_value.copyWith(target: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$GeneratePlaylistProviderInputImplCopyWith<$Res> + implements $GeneratePlaylistProviderInputCopyWith<$Res> { + factory _$$GeneratePlaylistProviderInputImplCopyWith( + _$GeneratePlaylistProviderInputImpl value, + $Res Function(_$GeneratePlaylistProviderInputImpl) then) = + __$$GeneratePlaylistProviderInputImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {Iterable? seedArtists, + Iterable? seedGenres, + Iterable? seedTracks, + int limit, + RecommendationSeeds? max, + RecommendationSeeds? min, + RecommendationSeeds? target}); + + @override + $RecommendationSeedsCopyWith<$Res>? get max; + @override + $RecommendationSeedsCopyWith<$Res>? get min; + @override + $RecommendationSeedsCopyWith<$Res>? get target; +} + +/// @nodoc +class __$$GeneratePlaylistProviderInputImplCopyWithImpl<$Res> + extends _$GeneratePlaylistProviderInputCopyWithImpl<$Res, + _$GeneratePlaylistProviderInputImpl> + implements _$$GeneratePlaylistProviderInputImplCopyWith<$Res> { + __$$GeneratePlaylistProviderInputImplCopyWithImpl( + _$GeneratePlaylistProviderInputImpl _value, + $Res Function(_$GeneratePlaylistProviderInputImpl) _then) + : super(_value, _then); + + /// Create a copy of GeneratePlaylistProviderInput + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? seedArtists = freezed, + Object? seedGenres = freezed, + Object? seedTracks = freezed, + Object? limit = null, + Object? max = freezed, + Object? min = freezed, + Object? target = freezed, + }) { + return _then(_$GeneratePlaylistProviderInputImpl( + seedArtists: freezed == seedArtists + ? _value.seedArtists + : seedArtists // ignore: cast_nullable_to_non_nullable + as Iterable?, + seedGenres: freezed == seedGenres + ? _value.seedGenres + : seedGenres // ignore: cast_nullable_to_non_nullable + as Iterable?, + seedTracks: freezed == seedTracks + ? _value.seedTracks + : seedTracks // ignore: cast_nullable_to_non_nullable + as Iterable?, + limit: null == limit + ? _value.limit + : limit // ignore: cast_nullable_to_non_nullable + as int, + max: freezed == max + ? _value.max + : max // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + min: freezed == min + ? _value.min + : min // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + target: freezed == target + ? _value.target + : target // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + )); + } +} + +/// @nodoc + +class _$GeneratePlaylistProviderInputImpl + implements _GeneratePlaylistProviderInput { + _$GeneratePlaylistProviderInputImpl( + {this.seedArtists, + this.seedGenres, + this.seedTracks, + required this.limit, + this.max, + this.min, + this.target}); + + @override + final Iterable? seedArtists; + @override + final Iterable? seedGenres; + @override + final Iterable? seedTracks; + @override + final int limit; + @override + final RecommendationSeeds? max; + @override + final RecommendationSeeds? min; + @override + final RecommendationSeeds? target; + + @override + String toString() { + return 'GeneratePlaylistProviderInput(seedArtists: $seedArtists, seedGenres: $seedGenres, seedTracks: $seedTracks, limit: $limit, max: $max, min: $min, target: $target)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$GeneratePlaylistProviderInputImpl && + const DeepCollectionEquality() + .equals(other.seedArtists, seedArtists) && + const DeepCollectionEquality() + .equals(other.seedGenres, seedGenres) && + const DeepCollectionEquality() + .equals(other.seedTracks, seedTracks) && + (identical(other.limit, limit) || other.limit == limit) && + (identical(other.max, max) || other.max == max) && + (identical(other.min, min) || other.min == min) && + (identical(other.target, target) || other.target == target)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(seedArtists), + const DeepCollectionEquality().hash(seedGenres), + const DeepCollectionEquality().hash(seedTracks), + limit, + max, + min, + target); + + /// Create a copy of GeneratePlaylistProviderInput + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$GeneratePlaylistProviderInputImplCopyWith< + _$GeneratePlaylistProviderInputImpl> + get copyWith => __$$GeneratePlaylistProviderInputImplCopyWithImpl< + _$GeneratePlaylistProviderInputImpl>(this, _$identity); +} + +abstract class _GeneratePlaylistProviderInput + implements GeneratePlaylistProviderInput { + factory _GeneratePlaylistProviderInput( + {final Iterable? seedArtists, + final Iterable? seedGenres, + final Iterable? seedTracks, + required final int limit, + final RecommendationSeeds? max, + final RecommendationSeeds? min, + final RecommendationSeeds? target}) = _$GeneratePlaylistProviderInputImpl; + + @override + Iterable? get seedArtists; + @override + Iterable? get seedGenres; + @override + Iterable? get seedTracks; + @override + int get limit; + @override + RecommendationSeeds? get max; + @override + RecommendationSeeds? get min; + @override + RecommendationSeeds? get target; + + /// Create a copy of GeneratePlaylistProviderInput + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$GeneratePlaylistProviderInputImplCopyWith< + _$GeneratePlaylistProviderInputImpl> + get copyWith => throw _privateConstructorUsedError; +} + +RecommendationSeeds _$RecommendationSeedsFromJson(Map json) { + return _RecommendationSeeds.fromJson(json); +} + +/// @nodoc +mixin _$RecommendationSeeds { + num? get acousticness => throw _privateConstructorUsedError; + num? get danceability => throw _privateConstructorUsedError; + @JsonKey(name: "duration_ms") + num? get durationMs => throw _privateConstructorUsedError; + num? get energy => throw _privateConstructorUsedError; + num? get instrumentalness => throw _privateConstructorUsedError; + num? get key => throw _privateConstructorUsedError; + num? get liveness => throw _privateConstructorUsedError; + num? get loudness => throw _privateConstructorUsedError; + num? get mode => throw _privateConstructorUsedError; + num? get popularity => throw _privateConstructorUsedError; + num? get speechiness => throw _privateConstructorUsedError; + num? get tempo => throw _privateConstructorUsedError; + @JsonKey(name: "time_signature") + num? get timeSignature => throw _privateConstructorUsedError; + num? get valence => throw _privateConstructorUsedError; + + /// Serializes this RecommendationSeeds to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of RecommendationSeeds + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $RecommendationSeedsCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $RecommendationSeedsCopyWith<$Res> { + factory $RecommendationSeedsCopyWith( + RecommendationSeeds value, $Res Function(RecommendationSeeds) then) = + _$RecommendationSeedsCopyWithImpl<$Res, RecommendationSeeds>; + @useResult + $Res call( + {num? acousticness, + num? danceability, + @JsonKey(name: "duration_ms") num? durationMs, + num? energy, + num? instrumentalness, + num? key, + num? liveness, + num? loudness, + num? mode, + num? popularity, + num? speechiness, + num? tempo, + @JsonKey(name: "time_signature") num? timeSignature, + num? valence}); +} + +/// @nodoc +class _$RecommendationSeedsCopyWithImpl<$Res, $Val extends RecommendationSeeds> + implements $RecommendationSeedsCopyWith<$Res> { + _$RecommendationSeedsCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of RecommendationSeeds + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? acousticness = freezed, + Object? danceability = freezed, + Object? durationMs = freezed, + Object? energy = freezed, + Object? instrumentalness = freezed, + Object? key = freezed, + Object? liveness = freezed, + Object? loudness = freezed, + Object? mode = freezed, + Object? popularity = freezed, + Object? speechiness = freezed, + Object? tempo = freezed, + Object? timeSignature = freezed, + Object? valence = freezed, + }) { + return _then(_value.copyWith( + acousticness: freezed == acousticness + ? _value.acousticness + : acousticness // ignore: cast_nullable_to_non_nullable + as num?, + danceability: freezed == danceability + ? _value.danceability + : danceability // ignore: cast_nullable_to_non_nullable + as num?, + durationMs: freezed == durationMs + ? _value.durationMs + : durationMs // ignore: cast_nullable_to_non_nullable + as num?, + energy: freezed == energy + ? _value.energy + : energy // ignore: cast_nullable_to_non_nullable + as num?, + instrumentalness: freezed == instrumentalness + ? _value.instrumentalness + : instrumentalness // ignore: cast_nullable_to_non_nullable + as num?, + key: freezed == key + ? _value.key + : key // ignore: cast_nullable_to_non_nullable + as num?, + liveness: freezed == liveness + ? _value.liveness + : liveness // ignore: cast_nullable_to_non_nullable + as num?, + loudness: freezed == loudness + ? _value.loudness + : loudness // ignore: cast_nullable_to_non_nullable + as num?, + mode: freezed == mode + ? _value.mode + : mode // ignore: cast_nullable_to_non_nullable + as num?, + popularity: freezed == popularity + ? _value.popularity + : popularity // ignore: cast_nullable_to_non_nullable + as num?, + speechiness: freezed == speechiness + ? _value.speechiness + : speechiness // ignore: cast_nullable_to_non_nullable + as num?, + tempo: freezed == tempo + ? _value.tempo + : tempo // ignore: cast_nullable_to_non_nullable + as num?, + timeSignature: freezed == timeSignature + ? _value.timeSignature + : timeSignature // ignore: cast_nullable_to_non_nullable + as num?, + valence: freezed == valence + ? _value.valence + : valence // ignore: cast_nullable_to_non_nullable + as num?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$RecommendationSeedsImplCopyWith<$Res> + implements $RecommendationSeedsCopyWith<$Res> { + factory _$$RecommendationSeedsImplCopyWith(_$RecommendationSeedsImpl value, + $Res Function(_$RecommendationSeedsImpl) then) = + __$$RecommendationSeedsImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {num? acousticness, + num? danceability, + @JsonKey(name: "duration_ms") num? durationMs, + num? energy, + num? instrumentalness, + num? key, + num? liveness, + num? loudness, + num? mode, + num? popularity, + num? speechiness, + num? tempo, + @JsonKey(name: "time_signature") num? timeSignature, + num? valence}); +} + +/// @nodoc +class __$$RecommendationSeedsImplCopyWithImpl<$Res> + extends _$RecommendationSeedsCopyWithImpl<$Res, _$RecommendationSeedsImpl> + implements _$$RecommendationSeedsImplCopyWith<$Res> { + __$$RecommendationSeedsImplCopyWithImpl(_$RecommendationSeedsImpl _value, + $Res Function(_$RecommendationSeedsImpl) _then) + : super(_value, _then); + + /// Create a copy of RecommendationSeeds + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? acousticness = freezed, + Object? danceability = freezed, + Object? durationMs = freezed, + Object? energy = freezed, + Object? instrumentalness = freezed, + Object? key = freezed, + Object? liveness = freezed, + Object? loudness = freezed, + Object? mode = freezed, + Object? popularity = freezed, + Object? speechiness = freezed, + Object? tempo = freezed, + Object? timeSignature = freezed, + Object? valence = freezed, + }) { + return _then(_$RecommendationSeedsImpl( + acousticness: freezed == acousticness + ? _value.acousticness + : acousticness // ignore: cast_nullable_to_non_nullable + as num?, + danceability: freezed == danceability + ? _value.danceability + : danceability // ignore: cast_nullable_to_non_nullable + as num?, + durationMs: freezed == durationMs + ? _value.durationMs + : durationMs // ignore: cast_nullable_to_non_nullable + as num?, + energy: freezed == energy + ? _value.energy + : energy // ignore: cast_nullable_to_non_nullable + as num?, + instrumentalness: freezed == instrumentalness + ? _value.instrumentalness + : instrumentalness // ignore: cast_nullable_to_non_nullable + as num?, + key: freezed == key + ? _value.key + : key // ignore: cast_nullable_to_non_nullable + as num?, + liveness: freezed == liveness + ? _value.liveness + : liveness // ignore: cast_nullable_to_non_nullable + as num?, + loudness: freezed == loudness + ? _value.loudness + : loudness // ignore: cast_nullable_to_non_nullable + as num?, + mode: freezed == mode + ? _value.mode + : mode // ignore: cast_nullable_to_non_nullable + as num?, + popularity: freezed == popularity + ? _value.popularity + : popularity // ignore: cast_nullable_to_non_nullable + as num?, + speechiness: freezed == speechiness + ? _value.speechiness + : speechiness // ignore: cast_nullable_to_non_nullable + as num?, + tempo: freezed == tempo + ? _value.tempo + : tempo // ignore: cast_nullable_to_non_nullable + as num?, + timeSignature: freezed == timeSignature + ? _value.timeSignature + : timeSignature // ignore: cast_nullable_to_non_nullable + as num?, + valence: freezed == valence + ? _value.valence + : valence // ignore: cast_nullable_to_non_nullable + as num?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$RecommendationSeedsImpl implements _RecommendationSeeds { + _$RecommendationSeedsImpl( + {this.acousticness, + this.danceability, + @JsonKey(name: "duration_ms") this.durationMs, + this.energy, + this.instrumentalness, + this.key, + this.liveness, + this.loudness, + this.mode, + this.popularity, + this.speechiness, + this.tempo, + @JsonKey(name: "time_signature") this.timeSignature, + this.valence}); + + factory _$RecommendationSeedsImpl.fromJson(Map json) => + _$$RecommendationSeedsImplFromJson(json); + + @override + final num? acousticness; + @override + final num? danceability; + @override + @JsonKey(name: "duration_ms") + final num? durationMs; + @override + final num? energy; + @override + final num? instrumentalness; + @override + final num? key; + @override + final num? liveness; + @override + final num? loudness; + @override + final num? mode; + @override + final num? popularity; + @override + final num? speechiness; + @override + final num? tempo; + @override + @JsonKey(name: "time_signature") + final num? timeSignature; + @override + final num? valence; + + @override + String toString() { + return 'RecommendationSeeds(acousticness: $acousticness, danceability: $danceability, durationMs: $durationMs, energy: $energy, instrumentalness: $instrumentalness, key: $key, liveness: $liveness, loudness: $loudness, mode: $mode, popularity: $popularity, speechiness: $speechiness, tempo: $tempo, timeSignature: $timeSignature, valence: $valence)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$RecommendationSeedsImpl && + (identical(other.acousticness, acousticness) || + other.acousticness == acousticness) && + (identical(other.danceability, danceability) || + other.danceability == danceability) && + (identical(other.durationMs, durationMs) || + other.durationMs == durationMs) && + (identical(other.energy, energy) || other.energy == energy) && + (identical(other.instrumentalness, instrumentalness) || + other.instrumentalness == instrumentalness) && + (identical(other.key, key) || other.key == key) && + (identical(other.liveness, liveness) || + other.liveness == liveness) && + (identical(other.loudness, loudness) || + other.loudness == loudness) && + (identical(other.mode, mode) || other.mode == mode) && + (identical(other.popularity, popularity) || + other.popularity == popularity) && + (identical(other.speechiness, speechiness) || + other.speechiness == speechiness) && + (identical(other.tempo, tempo) || other.tempo == tempo) && + (identical(other.timeSignature, timeSignature) || + other.timeSignature == timeSignature) && + (identical(other.valence, valence) || other.valence == valence)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + acousticness, + danceability, + durationMs, + energy, + instrumentalness, + key, + liveness, + loudness, + mode, + popularity, + speechiness, + tempo, + timeSignature, + valence); + + /// Create a copy of RecommendationSeeds + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$RecommendationSeedsImplCopyWith<_$RecommendationSeedsImpl> get copyWith => + __$$RecommendationSeedsImplCopyWithImpl<_$RecommendationSeedsImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$RecommendationSeedsImplToJson( + this, + ); + } +} + +abstract class _RecommendationSeeds implements RecommendationSeeds { + factory _RecommendationSeeds( + {final num? acousticness, + final num? danceability, + @JsonKey(name: "duration_ms") final num? durationMs, + final num? energy, + final num? instrumentalness, + final num? key, + final num? liveness, + final num? loudness, + final num? mode, + final num? popularity, + final num? speechiness, + final num? tempo, + @JsonKey(name: "time_signature") final num? timeSignature, + final num? valence}) = _$RecommendationSeedsImpl; + + factory _RecommendationSeeds.fromJson(Map json) = + _$RecommendationSeedsImpl.fromJson; + + @override + num? get acousticness; + @override + num? get danceability; + @override + @JsonKey(name: "duration_ms") + num? get durationMs; + @override + num? get energy; + @override + num? get instrumentalness; + @override + num? get key; + @override + num? get liveness; + @override + num? get loudness; + @override + num? get mode; + @override + num? get popularity; + @override + num? get speechiness; + @override + num? get tempo; + @override + @JsonKey(name: "time_signature") + num? get timeSignature; + @override + num? get valence; + + /// Create a copy of RecommendationSeeds + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$RecommendationSeedsImplCopyWith<_$RecommendationSeedsImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/models/spotify/recommendation_seeds.g.dart b/lib/models/spotify/recommendation_seeds.g.dart new file mode 100644 index 00000000..accb2ed1 --- /dev/null +++ b/lib/models/spotify/recommendation_seeds.g.dart @@ -0,0 +1,44 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recommendation_seeds.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$RecommendationSeedsImpl _$$RecommendationSeedsImplFromJson(Map json) => + _$RecommendationSeedsImpl( + acousticness: json['acousticness'] as num?, + danceability: json['danceability'] as num?, + durationMs: json['duration_ms'] as num?, + energy: json['energy'] as num?, + instrumentalness: json['instrumentalness'] as num?, + key: json['key'] as num?, + liveness: json['liveness'] as num?, + loudness: json['loudness'] as num?, + mode: json['mode'] as num?, + popularity: json['popularity'] as num?, + speechiness: json['speechiness'] as num?, + tempo: json['tempo'] as num?, + timeSignature: json['time_signature'] as num?, + valence: json['valence'] as num?, + ); + +Map _$$RecommendationSeedsImplToJson( + _$RecommendationSeedsImpl instance) => + { + 'acousticness': instance.acousticness, + 'danceability': instance.danceability, + 'duration_ms': instance.durationMs, + 'energy': instance.energy, + 'instrumentalness': instance.instrumentalness, + 'key': instance.key, + 'liveness': instance.liveness, + 'loudness': instance.loudness, + 'mode': instance.mode, + 'popularity': instance.popularity, + 'speechiness': instance.speechiness, + 'tempo': instance.tempo, + 'time_signature': instance.timeSignature, + 'valence': instance.valence, + }; diff --git a/lib/models/spotify_friends.g.dart b/lib/models/spotify_friends.g.dart index 4a32dd09..a1248429 100644 --- a/lib/models/spotify_friends.g.dart +++ b/lib/models/spotify_friends.g.dart @@ -6,60 +6,55 @@ part of 'spotify_friends.dart'; // JsonSerializableGenerator // ************************************************************************** -SpotifyFriend _$SpotifyFriendFromJson(Map json) => - SpotifyFriend( +SpotifyFriend _$SpotifyFriendFromJson(Map json) => SpotifyFriend( uri: json['uri'] as String, name: json['name'] as String, imageUrl: json['imageUrl'] as String, ); -SpotifyActivityArtist _$SpotifyActivityArtistFromJson( - Map json) => +SpotifyActivityArtist _$SpotifyActivityArtistFromJson(Map json) => SpotifyActivityArtist( uri: json['uri'] as String, name: json['name'] as String, ); -SpotifyActivityAlbum _$SpotifyActivityAlbumFromJson( - Map json) => +SpotifyActivityAlbum _$SpotifyActivityAlbumFromJson(Map json) => SpotifyActivityAlbum( uri: json['uri'] as String, name: json['name'] as String, ); -SpotifyActivityContext _$SpotifyActivityContextFromJson( - Map json) => +SpotifyActivityContext _$SpotifyActivityContextFromJson(Map json) => SpotifyActivityContext( uri: json['uri'] as String, name: json['name'] as String, index: json['index'] as num, ); -SpotifyActivityTrack _$SpotifyActivityTrackFromJson( - Map json) => +SpotifyActivityTrack _$SpotifyActivityTrackFromJson(Map json) => SpotifyActivityTrack( uri: json['uri'] as String, name: json['name'] as String, imageUrl: json['imageUrl'] as String, artist: SpotifyActivityArtist.fromJson( - json['artist'] as Map), - album: - SpotifyActivityAlbum.fromJson(json['album'] as Map), + Map.from(json['artist'] as Map)), + album: SpotifyActivityAlbum.fromJson( + Map.from(json['album'] as Map)), context: SpotifyActivityContext.fromJson( - json['context'] as Map), + Map.from(json['context'] as Map)), ); -SpotifyFriendActivity _$SpotifyFriendActivityFromJson( - Map json) => +SpotifyFriendActivity _$SpotifyFriendActivityFromJson(Map json) => SpotifyFriendActivity( - user: SpotifyFriend.fromJson(json['user'] as Map), - track: - SpotifyActivityTrack.fromJson(json['track'] as Map), + user: SpotifyFriend.fromJson( + Map.from(json['user'] as Map)), + track: SpotifyActivityTrack.fromJson( + Map.from(json['track'] as Map)), ); -SpotifyFriends _$SpotifyFriendsFromJson(Map json) => - SpotifyFriends( +SpotifyFriends _$SpotifyFriendsFromJson(Map json) => SpotifyFriends( friends: (json['friends'] as List) - .map((e) => SpotifyFriendActivity.fromJson(e as Map)) + .map((e) => SpotifyFriendActivity.fromJson( + Map.from(e as Map))) .toList(), ); diff --git a/lib/models/spotify_spotube_credentials.dart b/lib/models/spotify_spotube_credentials.dart deleted file mode 100644 index 982ca64a..00000000 --- a/lib/models/spotify_spotube_credentials.dart +++ /dev/null @@ -1,30 +0,0 @@ -class SpotifySpotubeCredentials { - String clientId; - String accessToken; - DateTime expiration; - bool isAnonymous; - - SpotifySpotubeCredentials({ - required this.clientId, - required this.accessToken, - required this.expiration, - required this.isAnonymous, - }); - - SpotifySpotubeCredentials.fromJson(Map json) - : clientId = json['clientId'], - accessToken = json['accessToken'], - expiration = DateTime.fromMillisecondsSinceEpoch( - json['accessTokenExpirationTimestampMs'], - ), - isAnonymous = json['isAnonymous']; - - Map toJson() { - return { - 'clientId': clientId, - 'accessToken': accessToken, - 'accessTokenExpirationTimestampMs': expiration.millisecondsSinceEpoch, - 'isAnonymous': isAnonymous, - }; - } -} diff --git a/lib/modules/album/album_card.dart b/lib/modules/album/album_card.dart new file mode 100644 index 00000000..5fee9cc4 --- /dev/null +++ b/lib/modules/album/album_card.dart @@ -0,0 +1,169 @@ +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:spotify/spotify.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/artist_simple.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/connect/connect.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/spotify/spotify.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; + +extension FormattedAlbumType on AlbumType { + String get formatted => name.replaceFirst(name[0], name[0].toUpperCase()); +} + +class AlbumCard extends HookConsumerWidget { + final AlbumSimple 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); + + bool isPlaylistPlaying = useMemoized( + () => playlist.containsCollection(album.id!), + [playlist, album.id], + ); + + final updating = useState(false); + + Future> fetchAllTrack() async { + if (album.tracks != null && album.tracks!.isNotEmpty) { + return album.tracks!.asTracks(album, ref); + } + await ref.read(albumTracksProvider(album).future); + return ref.read(albumTracksProvider(album).notifier).fetchAll(); + } + + var imageUrl = album.images.asUrlString( + placeholder: ImagePlaceholder.collection, + ); + var isLoading = + (isPlaylistPlaying && isFetchingActiveTrack) || updating.value; + var description = + "${album.albumType?.formatted} • ${album.artists?.asString() ?? ""}"; + + void onTap() { + context.navigateTo(AlbumRoute(id: album.id!, album: album)); + } + + void onPlaybuttonPressed() 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; + } + } + + void onAddToQueuePressed() 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; + } + } + + 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 new file mode 100644 index 00000000..7131aa3b --- /dev/null +++ b/lib/modules/artist/artist_album_list.dart @@ -0,0 +1,37 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +class ArtistAlbumList extends HookConsumerWidget { + final String artistId; + + const ArtistAlbumList( + this.artistId, { + super.key, + }); + + @override + Widget build(BuildContext context, ref) { + final albumsQuery = ref.watch(artistAlbumsProvider(artistId)); + final albumsQueryNotifier = + ref.watch(artistAlbumsProvider(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 new file mode 100644 index 00000000..e53070ef --- /dev/null +++ b/lib/modules/artist/artist_card.dart @@ -0,0 +1,76 @@ +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:spotify/spotify.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/extensions/image.dart'; + +import 'package:spotube/provider/blacklist_provider.dart'; + +class ArtistCard extends HookConsumerWidget { + final Artist 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 new file mode 100644 index 00000000..2c8d612b --- /dev/null +++ b/lib/modules/connect/connect_device.dart @@ -0,0 +1,79 @@ +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 new file mode 100644 index 00000000..dc192e44 --- /dev/null +++ b/lib/modules/connect/local_devices.dart @@ -0,0 +1,59 @@ +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/components/getting_started/blur_card.dart b/lib/modules/getting_started/blur_card.dart similarity index 81% rename from lib/components/getting_started/blur_card.dart rename to lib/modules/getting_started/blur_card.dart index db887013..6434c0a3 100644 --- a/lib/components/getting_started/blur_card.dart +++ b/lib/modules/getting_started/blur_card.dart @@ -1,7 +1,5 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; class BlurCard extends HookConsumerWidget { final Widget child; @@ -18,8 +16,7 @@ class BlurCard extends HookConsumerWidget { clipBehavior: Clip.antiAlias, child: SizedBox( width: double.infinity, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + 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 new file mode 100644 index 00000000..a339bd43 --- /dev/null +++ b/lib/modules/home/sections/featured.dart @@ -0,0 +1,46 @@ +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:spotify/spotify.dart'; +import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +class HomeFeaturedSection extends HookConsumerWidget { + const HomeFeaturedSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + 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/feed.dart b/lib/modules/home/sections/feed.dart new file mode 100644 index 00000000..216dc491 --- /dev/null +++ b/lib/modules/home/sections/feed.dart @@ -0,0 +1,50 @@ +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/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/spotify/views/home.dart'; + +class HomePageFeedSection extends HookConsumerWidget { + const HomePageFeedSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final homeFeed = ref.watch(homeViewProvider); + final nonShortSections = homeFeed.asData?.value.sections + .where((s) => s.typename == "HomeGenericSectionData") + .toList() ?? + []; + + return SliverList.builder( + itemCount: nonShortSections.length, + itemBuilder: (context, index) { + final section = nonShortSections[index]; + if (section.items.isEmpty) return const SizedBox.shrink(); + + return HorizontalPlaybuttonCardView( + items: [ + for (final item in section.items) + if (item.album != null) + item.album!.asAlbum + else if (item.artist != null) + item.artist!.asArtist + else if (item.playlist != null) + item.playlist!.asPlaylist + ], + title: Text(section.title ?? context.l10n.no_title), + hasNextPage: false, + isLoadingNextPage: false, + onFetchMore: () {}, + titleTrailing: Button.text( + child: Text(context.l10n.browse_all), + onPressed: () { + context.navigateTo(HomeFeedSectionRoute(sectionUri: section.uri)); + }, + ), + ); + }, + ); + } +} diff --git a/lib/modules/home/sections/friends.dart b/lib/modules/home/sections/friends.dart new file mode 100644 index 00000000..5c9c2178 --- /dev/null +++ b/lib/modules/home/sections/friends.dart @@ -0,0 +1,65 @@ +import 'dart:ui'; + +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/modules/home/sections/friends/friend_item.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +class HomePageFriendsSection extends HookConsumerWidget { + const HomePageFriendsSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final auth = ref.watch(authenticationProvider); + final friendsQuery = ref.watch(friendsProvider); + final friends = + friendsQuery.asData?.value.friends ?? FakeData.friends.friends; + + if (friendsQuery.isLoading || + friendsQuery.asData?.value.friends.isEmpty == true || + auth.asData?.value == null) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + context.l10n.friends, + style: context.theme.typography.h4, + ), + ), + SizedBox( + height: 80 * context.theme.scaling, + width: double.infinity, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: PointerDeviceKind.values.toSet(), + scrollbars: false, + ), + child: Skeletonizer( + enabled: friendsQuery.isLoading, + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 8), + scrollDirection: Axis.horizontal, + itemCount: friends.length, + separatorBuilder: (context, index) => const Gap(8), + itemBuilder: (context, index) { + return FriendItem(friend: friends[index]); + }, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/components/home/sections/friends/friend_item.dart b/lib/modules/home/sections/friends/friend_item.dart similarity index 55% rename from lib/components/home/sections/friends/friend_item.dart rename to lib/modules/home/sections/friends/friend_item.dart index fcdadab7..bf04558f 100644 --- a/lib/components/home/sections/friends/friend_item.dart +++ b/lib/modules/home/sections/friends/friend_item.dart @@ -1,46 +1,33 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; +import 'package:auto_route/auto_route.dart'; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; -import 'package:go_router/go_router.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.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/shared/image/universal_image.dart'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/models/spotify_friends.dart'; -import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class FriendItem extends HookConsumerWidget { final SpotifyFriendActivity friend; const FriendItem({ - Key? key, + super.key, required this.friend, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { - final ThemeData( - textTheme: textTheme, - colorScheme: colorScheme, - ) = Theme.of(context); - - final queryClient = useQueryClient(); final spotify = ref.watch(spotifyProvider); - return Container( + return Card( padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: colorScheme.surfaceVariant.withOpacity(0.3), - borderRadius: BorderRadius.circular(15), - ), - constraints: const BoxConstraints( - minWidth: 300, - ), - height: 80, child: Row( children: [ - CircleAvatar( - backgroundImage: UniversalImage.imageProvider( + Avatar( + initials: Avatar.getInitials(friend.user.name), + provider: UniversalImage.imageProvider( friend.user.imageUrl, ), ), @@ -50,17 +37,20 @@ class FriendItem extends HookConsumerWidget { children: [ Text( friend.user.name, - style: textTheme.bodyLarge, + style: context.theme.typography.bold, ), RichText( text: TextSpan( - style: textTheme.bodySmall, + style: context.theme.typography.normal.copyWith( + color: context.theme.colorScheme.foreground, + ), children: [ TextSpan( text: friend.track.name, recognizer: TapGestureRecognizer() ..onTap = () { - context.push("/track/${friend.track.id}"); + context + .navigateTo(TrackRoute(trackId: friend.track.id)); }, ), const TextSpan(text: " • "), @@ -74,8 +64,8 @@ class FriendItem extends HookConsumerWidget { text: " ${friend.track.artist.name}", recognizer: TapGestureRecognizer() ..onTap = () { - context.push( - "/artist/${friend.track.artist.id}", + context.navigateTo( + ArtistRoute(artistId: friend.track.artist.id), ); }, ), @@ -84,17 +74,13 @@ class FriendItem extends HookConsumerWidget { text: friend.track.context.name, recognizer: TapGestureRecognizer() ..onTap = () async { - context.push( + context.router.navigateNamed( "/${friend.track.context.path}", - extra: !friend.track.context.path - .startsWith("album") - ? null - : await queryClient.fetchQuery( - "album/${friend.track.album.id}", - () => spotify.albums.get( - friend.track.album.id, - ), - ), + // extra: + // !friend.track.context.path.startsWith("album") + // ? null + // : await spotify.albums + // .get(friend.track.context.id), ); }, ), @@ -109,17 +95,12 @@ class FriendItem extends HookConsumerWidget { text: " ${friend.track.album.name}", recognizer: TapGestureRecognizer() ..onTap = () async { - final album = - await queryClient.fetchQuery( - "album/${friend.track.album.id}", - () => spotify.albums.get( - friend.track.album.id, - ), + final album = await spotify.invoke( + (api) => api.albums.get(friend.track.album.id), ); if (context.mounted) { - context.push( - "/album/${friend.track.album.id}", - extra: album, + context.navigateTo( + AlbumRoute(id: album.id!, album: album), ); } }, diff --git a/lib/modules/home/sections/genres/genre_card.dart b/lib/modules/home/sections/genres/genre_card.dart new file mode 100644 index 00000000..8133f0db --- /dev/null +++ b/lib/modules/home/sections/genres/genre_card.dart @@ -0,0 +1,116 @@ +import 'dart:math'; +import 'dart:ui'; + +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:spotify/spotify.dart' hide Offset; +import 'package:spotube/collections/fake.dart'; +import 'package:spotube/collections/gradients.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/modules/home/sections/genres/genre_card_playlist_card.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +final random = Random(); +final gradientState = StateProvider.family( + (ref, String id) => gradients[random.nextInt(gradients.length)], +); + +class GenreSectionCard extends HookConsumerWidget { + final Category category; + const GenreSectionCard({ + super.key, + required this.category, + }); + + @override + Widget build(BuildContext context, ref) { + final theme = Theme.of(context); + final playlists = category == FakeData.category + ? null + : ref.watch(categoryPlaylistsProvider(category.id!)); + final playlistsData = playlists?.asData?.value.items.take(8) ?? + List.generate(5, (index) => FakeData.playlistSimple); + + final randomGradient = ref.watch(gradientState(category.id!)); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + borderRadius: theme.borderRadiusXxl, + boxShadow: [ + BoxShadow( + color: theme.colorScheme.foreground, + offset: const Offset(0, 5), + blurRadius: 7, + spreadRadius: -5, + ), + ], + image: DecorationImage( + image: UniversalImage.imageProvider( + category.icons!.first.url!, + ), + fit: BoxFit.cover, + ), + ), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: theme.borderRadiusXxl, + gradient: randomGradient + .withOpacity(theme.brightness == Brightness.dark ? 0.2 : 0.7), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + category.name!, + style: const TextStyle(color: Colors.white), + ).h3(), + Button.link( + onPressed: () { + context.navigateTo( + GenrePlaylistsRoute( + id: category.id!, + category: category, + ), + ); + }, + child: Text( + context.l10n.view_all, + style: const TextStyle(color: Colors.white), + ).muted(), + ), + ], + ), + if (playlists?.hasError != true) + Expanded( + child: Skeleton.ignore( + child: Skeletonizer( + enabled: playlists?.isLoading ?? false, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: playlistsData.length, + separatorBuilder: (context, index) => const Gap(12), + itemBuilder: (context, index) { + final playlist = playlistsData.elementAt(index); + + return GenreSectionCardPlaylistCard(playlist: playlist); + }, + ), + ), + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/modules/home/sections/genres/genre_card_playlist_card.dart b/lib/modules/home/sections/genres/genre_card_playlist_card.dart new file mode 100644 index 00000000..328507cc --- /dev/null +++ b/lib/modules/home/sections/genres/genre_card_playlist_card.dart @@ -0,0 +1,130 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Consumer; +import 'package:spotify/spotify.dart' hide Image; +import 'package:spotube/collections/env.dart'; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/extensions/string.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:stroke_text/stroke_text.dart'; + +class GenreSectionCardPlaylistCard extends HookConsumerWidget { + final PlaylistSimple playlist; + const GenreSectionCardPlaylistCard({ + super.key, + required this.playlist, + }); + + @override + Widget build(BuildContext context, ref) { + final theme = Theme.of(context); + + return Container( + width: 115 * theme.scaling, + decoration: BoxDecoration( + color: theme.colorScheme.background.withAlpha(75), + borderRadius: theme.borderRadiusMd, + ), + child: SurfaceBlur( + borderRadius: theme.borderRadiusMd, + surfaceBlur: theme.surfaceBlur, + child: Button( + style: ButtonVariance.secondary.copyWith( + padding: (context, states, value) => const EdgeInsets.all(8), + decoration: (context, states, value) { + final decoration = ButtonVariance.secondary + .decoration(context, states) as BoxDecoration; + + if (states.isNotEmpty) { + return decoration; + } + + return decoration.copyWith( + color: decoration.color?.withAlpha(180), + ); + }, + ), + onPressed: () { + context.navigateTo( + PlaylistRoute(id: playlist.id!, playlist: playlist), + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 5, + children: [ + ClipRRect( + borderRadius: theme.borderRadiusSm, + child: playlist.owner?.displayName == "Spotify" && + Env.disableSpotifyImages + ? Consumer( + builder: (context, ref, _) { + final (:src, :color, :colorBlendMode, :placement) = + ref.watch(playlistImageProvider(playlist.id!)); + return SizedBox( + height: 100 * theme.scaling, + width: 100 * theme.scaling, + child: Stack( + children: [ + Positioned.fill( + child: Image.asset( + src, + color: color, + colorBlendMode: colorBlendMode, + fit: BoxFit.cover, + ), + ), + Positioned.fill( + top: placement == Alignment.topLeft + ? 10 + : null, + left: 10, + bottom: placement == Alignment.bottomLeft + ? 10 + : null, + child: StrokeText( + text: playlist.name!, + strokeColor: Colors.white, + strokeWidth: 3, + textColor: Colors.black, + textStyle: const TextStyle( + fontSize: 16, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ); + }, + ) + : UniversalImage( + path: (playlist.images)!.asUrlString( + placeholder: ImagePlaceholder.collection, + index: 1, + ), + fit: BoxFit.cover, + height: 100 * theme.scaling, + width: 100 * theme.scaling, + ), + ), + Text( + playlist.name!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ).semiBold().small(), + if (playlist.description != null) + Text( + playlist.description?.unescapeHtml().cleanHtml() ?? "", + maxLines: 2, + overflow: TextOverflow.ellipsis, + ).xSmall().muted(), + ], + ), + ), + ), + ); + } +} diff --git a/lib/modules/home/sections/genres/genres.dart b/lib/modules/home/sections/genres/genres.dart new file mode 100644 index 00000000..c9f3f9b2 --- /dev/null +++ b/lib/modules/home/sections/genres/genres.dart @@ -0,0 +1,139 @@ +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/collections/fake.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/modules/home/sections/genres/genre_card.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +class HomeGenresSection extends HookConsumerWidget { + const HomeGenresSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final theme = context.theme; + final mediaQuery = MediaQuery.sizeOf(context); + + final categoriesQuery = ref.watch(categoriesProvider); + final categories = useMemoized( + () => + categoriesQuery.asData?.value + .where((c) => (c.icons?.length ?? 0) > 0) + .take(6) + .toList() ?? + [ + FakeData.category, + ], + [categoriesQuery.asData?.value], + ); + final controller = useMemoized(() => CarouselController(), []); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.l10n.genres, + style: context.theme.typography.h4, + ), + Button.link( + onPressed: () { + context.navigateTo(const GenreRoute()); + }, + child: Text( + context.l10n.browse_all, + ).muted(), + ), + ], + ), + ), + const Gap(8), + Stack( + children: [ + SizedBox( + height: 280 * theme.scaling, + child: Carousel( + controller: controller, + transition: const CarouselTransition.sliding(gap: 24), + sizeConstraint: CarouselSizeConstraint.fixed( + mediaQuery.mdAndUp + ? mediaQuery.width * .6 + : mediaQuery.width * .95, + ), + itemCount: categories.length, + pauseOnHover: true, + direction: Axis.horizontal, + itemBuilder: (context, index) { + final category = categories[index]; + + return Skeletonizer( + enabled: categoriesQuery.isLoading, + child: GenreSectionCard(category: category), + ); + }, + ), + ), + Positioned( + left: 0, + child: Container( + height: 280 * theme.scaling, + width: (mediaQuery.mdAndUp ? 60 : 40) * theme.scaling, + alignment: Alignment.center, + child: IconButton.secondary( + shape: ButtonShape.circle, + size: mediaQuery.mdAndUp + ? const ButtonSize(1.3) + : ButtonSize.normal, + icon: const Icon(SpotubeIcons.angleLeft), + onPressed: () { + controller.animatePrevious( + const Duration(seconds: 1), + ); + }, + ), + ), + ), + Positioned( + right: 0, + child: Container( + height: 280 * theme.scaling, + width: (mediaQuery.mdAndUp ? 60 : 40) * theme.scaling, + alignment: Alignment.center, + child: IconButton.secondary( + shape: ButtonShape.circle, + size: mediaQuery.mdAndUp + ? const ButtonSize(1.3) + : ButtonSize.normal, + icon: const Icon(SpotubeIcons.angleRight), + onPressed: () { + controller.animateNext( + const Duration(seconds: 1), + ); + }, + ), + ), + ), + ], + ), + const Gap(8), + Center( + child: CarouselDotIndicator( + itemCount: categories.length, + controller: controller, + ), + ), + ], + ); + } +} diff --git a/lib/components/home/sections/made_for_user.dart b/lib/modules/home/sections/made_for_user.dart similarity index 64% rename from lib/components/home/sections/made_for_user.dart rename to lib/modules/home/sections/made_for_user.dart index a3f96899..4fd025d5 100644 --- a/lib/components/home/sections/made_for_user.dart +++ b/lib/modules/home/sections/made_for_user.dart @@ -1,20 +1,20 @@ -import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HomeMadeForUserSection extends HookConsumerWidget { - const HomeMadeForUserSection({Key? key}) : super(key: key); + const HomeMadeForUserSection({super.key}); @override Widget build(BuildContext context, ref) { - final madeForUser = useQueries.views.get(ref, "made-for-x-hub"); + final madeForUser = ref.watch(viewProvider("made-for-x-hub")); return SliverList.builder( - itemCount: madeForUser.data?["content"]?["items"]?.length ?? 0, + itemCount: madeForUser.asData?.value["content"]?["items"]?.length ?? 0, itemBuilder: (context, index) { - final item = madeForUser.data?["content"]?["items"]?[index]; + final item = madeForUser.asData?.value["content"]?["items"]?[index]; final playlists = item["content"]?["items"] ?.where((itemL2) => itemL2["type"] == "playlist") .map((itemL2) => PlaylistSimple.fromJson(itemL2)) diff --git a/lib/modules/home/sections/new_releases.dart b/lib/modules/home/sections/new_releases.dart new file mode 100644 index 00000000..2ebbbee0 --- /dev/null +++ b/lib/modules/home/sections/new_releases.dart @@ -0,0 +1,35 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +class HomeNewReleasesSection extends HookConsumerWidget { + const HomeNewReleasesSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final auth = ref.watch(authenticationProvider); + + final newReleases = ref.watch(albumReleasesProvider); + final newReleasesNotifier = ref.read(albumReleasesProvider.notifier); + + final albums = ref.watch(userArtistAlbumReleasesProvider); + + if (auth.asData?.value == null || + newReleases.isLoading || + newReleases.asData?.value.items.isEmpty == true) { + return const SizedBox.shrink(); + } + + return HorizontalPlaybuttonCardView( + items: albums, + title: Text(context.l10n.new_releases), + isLoadingNextPage: newReleases.isLoadingNextPage, + hasNextPage: newReleases.asData?.value.hasMore ?? false, + onFetchMore: newReleasesNotifier.fetchMore, + ); + } +} diff --git a/lib/modules/home/sections/recent.dart b/lib/modules/home/sections/recent.dart new file mode 100644 index 00000000..5420ad55 --- /dev/null +++ b/lib/modules/home/sections/recent.dart @@ -0,0 +1,40 @@ +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/library/local_folder/cache_export_dialog.dart b/lib/modules/library/local_folder/cache_export_dialog.dart new file mode 100644 index 00000000..0f10defc --- /dev/null +++ b/lib/modules/library/local_folder/cache_export_dialog.dart @@ -0,0 +1,138 @@ +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'; +import 'package:spotube/services/sourced_track/enums.dart'; + +final codecs = SourceCodecs.values.map((s) => s.name); + +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 && + codecs.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 new file mode 100644 index 00000000..78f1aa14 --- /dev/null +++ b/lib/modules/library/local_folder/local_folder_item.dart @@ -0,0 +1,193 @@ +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/image.dart'; +import 'package:spotube/extensions/string.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 Uri(:pathSegments) = Uri.parse( + folder + .replaceFirst(RegExp(r'^/Volumes/[^/]+/Users/'), "") + .replaceFirst(r'C:\Users\', "") + .replaceFirst(r'/home/', ""), + ); + + // if length > 5, we ... all the middle segments after 2 and the last 2 + final segments = pathSegments.length > 5 + ? [ + ...pathSegments.take(2), + "...", + ...pathSegments.skip(pathSegments.length - 3).toList() + ..removeLast(), + ] + : pathSegments.take(max(pathSegments.length - 1, 0)).toList(); + + 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: [ + Column( + mainAxisSize: MainAxisSize.min, + 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, + ), + ), + Wrap( + spacing: 2, + runSpacing: 2, + children: [ + for (final MapEntry(key: index, value: segment) + in segments.asMap().entries) + Text.rich( + TextSpan( + children: [ + if (index != 0) const TextSpan(text: "/ "), + TextSpan(text: segment), + ], + ), + maxLines: 2, + ).xSmall().muted(), + ], + ), + ], + ), + 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/components/library/playlist_generate/multi_select_field.dart b/lib/modules/library/playlist_generate/multi_select_field.dart similarity index 97% rename from lib/components/library/playlist_generate/multi_select_field.dart rename to lib/modules/library/playlist_generate/multi_select_field.dart index ed5eb38f..00a09c95 100644 --- a/lib/components/library/playlist_generate/multi_select_field.dart +++ b/lib/modules/library/playlist_generate/multi_select_field.dart @@ -25,7 +25,7 @@ class MultiSelectField extends HookWidget { final bool enabled; const MultiSelectField({ - Key? key, + super.key, required this.options, required this.selectedOptions, required this.getValueForOption, @@ -36,7 +36,7 @@ class MultiSelectField extends HookWidget { this.dialogTitle, this.helperText, this.enabled = true, - }) : super(key: key); + }); Widget defaultSelectedOptionBuilder(T option) { return Chip( @@ -68,10 +68,10 @@ class MultiSelectField extends HookWidget { side: BorderSide( color: enabled ? theme.colorScheme.onSurface - : theme.colorScheme.onSurface.withOpacity(0.1), + : theme.colorScheme.onSurface.withValues(alpha: 0.1), ), ), - mouseCursor: MaterialStateMouseCursor.textable, + mouseCursor: WidgetStateMouseCursor.textable, onPressed: !enabled ? null : () async { @@ -134,14 +134,14 @@ class _MultiSelectDialog extends HookWidget { final String? helperText; const _MultiSelectDialog({ - Key? key, + super.key, required this.dialogTitle, required this.options, required this.getValueForOption, this.optionBuilder, this.initialSelection = const [], this.helperText, - }) : super(key: key); + }); @override Widget build(BuildContext context) { @@ -187,7 +187,7 @@ class _MultiSelectDialog extends HookWidget { return AlertDialog( scrollable: true, - title: dialogTitle ?? const Text('Select'), + title: dialogTitle ?? Text(context.l10n.select), contentPadding: mediaQuery.mdAndUp ? null : const EdgeInsets.all(16), insetPadding: const EdgeInsets.all(16), actions: [ diff --git a/lib/modules/library/playlist_generate/recommendation_attribute_dials.dart b/lib/modules/library/playlist_generate/recommendation_attribute_dials.dart new file mode 100644 index 00000000..564bfb55 --- /dev/null +++ b/lib/modules/library/playlist_generate/recommendation_attribute_dials.dart @@ -0,0 +1,183 @@ +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; + +typedef RecommendationAttribute = ({double min, double target, double max}); + +RecommendationAttribute lowValues(double base) => + (min: 1 * base, target: 0.3 * base, max: 0.3 * base); +RecommendationAttribute moderateValues(double base) => + (min: 0.5 * base, target: 1 * base, max: 0.5 * base); +RecommendationAttribute highValues(double base) => + (min: 0.3 * base, target: 0.3 * base, max: 1 * base); + +class RecommendationAttributeDials extends HookWidget { + final Widget title; + final RecommendationAttribute values; + final ValueChanged onChanged; + final double base; + + const RecommendationAttributeDials({ + super.key, + required this.values, + required this.onChanged, + required this.title, + this.base = 1, + }); + + @override + Widget build(BuildContext context) { + final labelStyle = Theme.of(context).typography.small.copyWith( + fontWeight: FontWeight.w500, + ); + + final minSlider = Row( + spacing: 5, + children: [ + Text(context.l10n.min, style: labelStyle), + Expanded( + child: Slider( + value: SliderValue.single(values.min / base), + min: 0, + max: 1, + onChanged: (value) => onChanged(( + min: value.value * base, + target: values.target, + max: values.max, + )), + ), + ), + ], + ); + + final targetSlider = Row( + spacing: 5, + children: [ + Text(context.l10n.target, style: labelStyle), + Expanded( + child: Slider( + value: SliderValue.single(values.target / base), + min: 0, + max: 1, + onChanged: (value) => onChanged(( + min: values.min, + target: value.value * base, + max: values.max, + )), + ), + ), + ], + ); + + final maxSlider = Row( + spacing: 5, + children: [ + Text(context.l10n.max, style: labelStyle), + Expanded( + child: Slider( + value: SliderValue.single(values.max / base), + min: 0, + max: 1, + onChanged: (value) => onChanged(( + min: values.min, + target: values.target, + max: value.value * base, + )), + ), + ), + ], + ); + + void onSelected(int index) { + RecommendationAttribute newValues = zeroValues; + switch (index) { + case 0: + newValues = lowValues(base); + break; + case 1: + newValues = moderateValues(base); + break; + case 2: + newValues = highValues(base); + break; + } + + if (newValues == values) { + onChanged(zeroValues); + } else { + onChanged(newValues); + } + } + + return LayoutBuilder(builder: (context, constrain) { + return Accordion( + items: [ + AccordionItem( + trigger: AccordionTrigger( + child: SizedBox( + width: double.infinity, + child: Basic( + title: title.semiBold(), + trailing: Row( + spacing: 5, + children: [ + Toggle( + value: values == lowValues(base), + onChanged: (value) => onSelected(0), + style: + const ButtonStyle.outline(size: ButtonSize.small), + child: Text(context.l10n.low), + ), + Toggle( + value: values == moderateValues(base), + onChanged: (value) => onSelected(1), + style: + const ButtonStyle.outline(size: ButtonSize.small), + child: Text(context.l10n.moderate), + ), + Toggle( + value: values == highValues(base), + onChanged: (value) => onSelected(2), + style: + const ButtonStyle.outline(size: ButtonSize.small), + child: Text(context.l10n.high), + ), + ], + ), + ), + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (constrain.mdAndUp) + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const SizedBox(width: 16), + Expanded(child: minSlider), + Expanded(child: targetSlider), + Expanded(child: maxSlider), + ], + ) + else + Padding( + padding: const EdgeInsets.only(left: 16), + child: Column( + children: [ + minSlider, + targetSlider, + maxSlider, + ], + ), + ), + ], + ), + ), + ], + ); + }); + } +} diff --git a/lib/modules/library/playlist_generate/recommendation_attribute_fields.dart b/lib/modules/library/playlist_generate/recommendation_attribute_fields.dart new file mode 100644 index 00000000..b616b080 --- /dev/null +++ b/lib/modules/library/playlist_generate/recommendation_attribute_fields.dart @@ -0,0 +1,182 @@ +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; +import 'package:spotube/modules/library/playlist_generate/recommendation_attribute_dials.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; + +class RecommendationAttributeFields extends HookWidget { + final Widget title; + final RecommendationAttribute values; + final ValueChanged onChanged; + final Map? presets; + + const RecommendationAttributeFields({ + super.key, + required this.values, + required this.onChanged, + required this.title, + this.presets, + }); + + @override + Widget build(BuildContext context) { + final minController = + useShadcnTextEditingController(text: values.min.toString()); + final targetController = + useShadcnTextEditingController(text: values.target.toString()); + final maxController = + useShadcnTextEditingController(text: values.max.toString()); + + useEffect(() { + listener() { + onChanged(( + min: double.tryParse(minController.text) ?? 0, + target: double.tryParse(targetController.text) ?? 0, + max: double.tryParse(maxController.text) ?? 0, + )); + } + + minController.addListener(listener); + targetController.addListener(listener); + maxController.addListener(listener); + + return () { + minController.removeListener(listener); + targetController.removeListener(listener); + maxController.removeListener(listener); + }; + }, [values]); + + final minField = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 5, + children: [ + Text(context.l10n.min).semiBold(), + NumberInput( + controller: minController, + allowDecimals: false, + ), + ], + ); + + final targetField = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 5, + children: [ + Text(context.l10n.target).semiBold(), + NumberInput( + controller: targetController, + allowDecimals: false, + ), + ], + ); + + final maxField = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 5, + children: [ + Text(context.l10n.max).semiBold(), + NumberInput( + controller: maxController, + allowDecimals: false, + ), + ], + ); + + void onSelected(int index) { + RecommendationAttribute newValues = presets!.values.elementAt(index); + if (newValues == values) { + onChanged(zeroValues); + minController.text = zeroValues.min.toString(); + targetController.text = zeroValues.target.toString(); + maxController.text = zeroValues.max.toString(); + } else { + onChanged(newValues); + minController.text = newValues.min.toString(); + targetController.text = newValues.target.toString(); + maxController.text = newValues.max.toString(); + } + } + + return LayoutBuilder(builder: (context, constraints) { + return Accordion( + items: [ + AccordionItem( + trigger: AccordionTrigger( + child: SizedBox( + width: double.infinity, + child: Basic( + title: title.semiBold(), + trailing: presets == null + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + spacing: 5, + children: [ + for (final presetEntry in presets?.entries + .toList() ?? + >[]) + Toggle( + value: presetEntry.value == values, + style: const ButtonStyle.outline( + size: ButtonSize.small, + ), + onChanged: (value) { + onSelected( + presets!.entries.toList().indexWhere( + (s) => s.key == presetEntry.key), + ); + }, + child: Text(presetEntry.key), + ), + ], + ), + ), + ), + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + if (constraints.mdAndUp) + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const SizedBox(width: 16), + Expanded(child: minField), + const SizedBox(width: 16), + Expanded(child: targetField), + const SizedBox(width: 16), + Expanded(child: maxField), + const SizedBox(width: 16), + ], + ) + else + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + minField, + const SizedBox(height: 16), + targetField, + const SizedBox(height: 16), + maxField, + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ], + ); + }); + } +} diff --git a/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart b/lib/modules/library/playlist_generate/seeds_multi_autocomplete.dart similarity index 69% rename from lib/components/library/playlist_generate/seeds_multi_autocomplete.dart rename to lib/modules/library/playlist_generate/seeds_multi_autocomplete.dart index b1665d32..812d9367 100644 --- a/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart +++ b/lib/modules/library/playlist_generate/seeds_multi_autocomplete.dart @@ -1,9 +1,11 @@ import 'dart:async'; import 'dart:math'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show Autocomplete; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; enum SelectedItemDisplayType { wrap, @@ -20,29 +22,35 @@ class SeedsMultiAutocomplete extends HookWidget { final Widget Function(T option) selectedSeedBuilder; final String Function(T option) displayStringForOption; - final InputDecoration? inputDecoration; final bool enabled; final SelectedItemDisplayType selectedItemDisplayType; + final Widget? placeholder; + final Widget? leading; + final Widget? trailing; + final Widget? label; const SeedsMultiAutocomplete({ - Key? key, + super.key, required this.seeds, required this.fetchSeeds, required this.autocompleteOptionBuilder, required this.displayStringForOption, required this.selectedSeedBuilder, - this.inputDecoration, this.enabled = true, this.selectedItemDisplayType = SelectedItemDisplayType.wrap, - }) : super(key: key); + this.placeholder, + this.leading, + this.trailing, + this.label, + }); @override Widget build(BuildContext context) { useValueListenable(seeds); final theme = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final seedController = useTextEditingController(); + final seedController = useShadcnTextEditingController(); final containerKey = useRef(GlobalKey()); @@ -61,6 +69,10 @@ class SeedsMultiAutocomplete extends HookWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (label != null) ...[ + label!.semiBold(), + const Gap(8), + ], LayoutBuilder(builder: (context, constrains) { return Container( key: containerKey.value, @@ -101,13 +113,17 @@ class SeedsMultiAutocomplete extends HookWidget { focusNode, onFieldSubmitted, ) { - return TextFormField( + return TextField( controller: seedController, onChanged: (value) => textEditingController.text = value, focusNode: focusNode, - onFieldSubmitted: (_) => onFieldSubmitted(), + onSubmitted: (_) => onFieldSubmitted(), enabled: enabled, - decoration: inputDecoration, + features: [ + if (leading != null) InputFeature.leading(leading!), + if (trailing != null) InputFeature.trailing(trailing!), + ], + placeholder: placeholder, ); }, ), @@ -120,22 +136,27 @@ class SeedsMultiAutocomplete extends HookWidget { runSpacing: 4, children: seeds.value.map(selectedSeedBuilder).toList(), ), - SelectedItemDisplayType.list => Card( - margin: EdgeInsets.zero, - child: Column( - children: [ - for (final seed in seeds.value) ...[ - selectedSeedBuilder(seed), - if (seeds.value.length > 1 && seed != seeds.value.last) - Divider( - color: theme.colorScheme.primaryContainer, - height: 1, - indent: 12, - endIndent: 12, + SelectedItemDisplayType.list => AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: seeds.value.isEmpty + ? const SizedBox.shrink() + : Card( + child: Column( + children: [ + for (final seed in seeds.value) ...[ + selectedSeedBuilder(seed), + if (seeds.value.length > 1 && + seed != seeds.value.last) + Divider( + color: theme.colorScheme.secondary, + height: 1, + indent: 12, + endIndent: 12, + ), + ], + ], ), - ], - ], - ), + ), ), }, ], diff --git a/lib/components/library/playlist_generate/simple_track_tile.dart b/lib/modules/library/playlist_generate/simple_track_tile.dart similarity index 67% rename from lib/components/library/playlist_generate/simple_track_tile.dart rename to lib/modules/library/playlist_generate/simple_track_tile.dart index 86800d06..afa723f3 100644 --- a/lib/components/library/playlist_generate/simple_track_tile.dart +++ b/lib/modules/library/playlist_generate/simple_track_tile.dart @@ -1,46 +1,45 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/ui/button_tile.dart'; +import 'package:spotube/extensions/image.dart'; class SimpleTrackTile extends HookWidget { final Track track; final VoidCallback? onDelete; const SimpleTrackTile({ - Key? key, + super.key, required this.track, this.onDelete, - }) : super(key: key); + }); @override Widget build(BuildContext context) { - return ListTile( + return ButtonTile( leading: ClipRRect( borderRadius: BorderRadius.circular(8), child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString( - track.album?.images, + path: (track.album?.images).asUrlString( placeholder: ImagePlaceholder.artist, ), height: 40, width: 40, ), ), - horizontalTitleGap: 10, - contentPadding: const EdgeInsets.symmetric(horizontal: 8), title: Text(track.name!), trailing: onDelete == null ? null - : IconButton( + : IconButton.ghost( icon: const Icon(SpotubeIcons.close), onPressed: onDelete, ), subtitle: Text( track.artists?.map((e) => e.name).join(", ") ?? track.album?.name ?? "", ), + style: ButtonVariance.ghost, ); } } diff --git a/lib/components/library/user_downloads/download_item.dart b/lib/modules/library/user_downloads/download_item.dart similarity index 68% rename from lib/components/library/user_downloads/download_item.dart rename to lib/modules/library/user_downloads/download_item.dart index 10dec410..2c0a96a5 100644 --- a/lib/components/library/user_downloads/download_item.dart +++ b/lib/modules/library/user_downloads/download_item.dart @@ -1,21 +1,25 @@ -import 'package:flutter/material.dart'; +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:spotify/spotify.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/image/universal_image.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/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/services/download_manager/download_status.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class DownloadItem extends HookConsumerWidget { final Track track; const DownloadItem({ - Key? key, + super.key, required this.track, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { @@ -43,7 +47,8 @@ class DownloadItem extends HookConsumerWidget { final isQueryingSourceInfo = taskStatus.value == null || track is! SourcedTrack; - return ListTile( + return ButtonTile( + style: ButtonVariance.ghost, leading: Padding( padding: const EdgeInsets.symmetric(horizontal: 5), child: ClipRRect( @@ -51,23 +56,22 @@ class DownloadItem extends HookConsumerWidget { child: UniversalImage( height: 40, width: 40, - path: TypeConversionUtils.image_X_UrlString( - track.album?.images, + path: (track.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, ), ), ), ), title: Text(track.name ?? ''), - subtitle: TypeConversionUtils.artists_X_ClickableArtists( - track.artists ?? [], + subtitle: ArtistLink( + artists: track.artists ?? [], mainAxisAlignment: WrapAlignment.start, + onOverflowArtistClick: () { + context.navigateTo(TrackRoute(trackId: track.id!)); + }, ), trailing: isQueryingSourceInfo - ? Text( - context.l10n.querying_info, - style: Theme.of(context).textTheme.labelMedium, - ) + ? Text(context.l10n.querying_info).small() : switch (taskStatus.value!) { DownloadStatus.downloading => HookBuilder(builder: (context) { final taskProgress = useListenable(useMemoized( @@ -75,39 +79,36 @@ class DownloadItem extends HookConsumerWidget { .getProgressNotifier(track as SourcedTrack), [track], )); - return SizedBox( - width: 140, - child: Row( - children: [ - CircularProgressIndicator( - value: taskProgress?.value ?? 0, - ), - const SizedBox(width: 10), - IconButton( - icon: const Icon(SpotubeIcons.pause), - onPressed: () { - downloadManager.pause(track as SourcedTrack); - }), - const SizedBox(width: 10), - IconButton( - icon: const Icon(SpotubeIcons.close), - onPressed: () { - downloadManager.cancel(track as SourcedTrack); - }), - ], - ), + return Row( + children: [ + CircularProgressIndicator( + value: taskProgress?.value ?? 0, + ), + const SizedBox(width: 10), + IconButton.ghost( + icon: const Icon(SpotubeIcons.pause), + onPressed: () { + downloadManager.pause(track as SourcedTrack); + }), + const SizedBox(width: 10), + IconButton.ghost( + icon: const Icon(SpotubeIcons.close), + onPressed: () { + downloadManager.cancel(track as SourcedTrack); + }), + ], ); }), DownloadStatus.paused => Row( mainAxisSize: MainAxisSize.min, children: [ - IconButton( + IconButton.ghost( icon: const Icon(SpotubeIcons.play), onPressed: () { downloadManager.resume(track as SourcedTrack); }), const SizedBox(width: 10), - IconButton( + IconButton.ghost( icon: const Icon(SpotubeIcons.close), onPressed: () { downloadManager.cancel(track as SourcedTrack); @@ -123,7 +124,7 @@ class DownloadItem extends HookConsumerWidget { color: Colors.red[400], ), const SizedBox(width: 10), - IconButton( + IconButton.ghost( icon: const Icon(SpotubeIcons.refresh), onPressed: () { downloadManager.retry(track as SourcedTrack); @@ -134,7 +135,7 @@ class DownloadItem extends HookConsumerWidget { ), DownloadStatus.completed => Icon(SpotubeIcons.done, color: Colors.green[400]), - DownloadStatus.queued => IconButton( + DownloadStatus.queued => IconButton.ghost( icon: const Icon(SpotubeIcons.close), onPressed: () { downloadManager.removeFromQueue(track as SourcedTrack); diff --git a/lib/components/lyrics/use_synced_lyrics.dart b/lib/modules/lyrics/use_synced_lyrics.dart similarity index 66% rename from lib/components/lyrics/use_synced_lyrics.dart rename to lib/modules/lyrics/use_synced_lyrics.dart index 7a171473..cf929226 100644 --- a/lib/components/lyrics/use_synced_lyrics.dart +++ b/lib/modules/lyrics/use_synced_lyrics.dart @@ -1,6 +1,7 @@ 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, @@ -13,8 +14,12 @@ int useSyncedLyrics( useEffect(() { return stream.listen((pos) { - if (lyricsMap.containsKey(pos.inSeconds + delay)) { - currentTime.value = pos.inSeconds + delay; + try { + if (lyricsMap.containsKey(pos.inSeconds + delay)) { + currentTime.value = pos.inSeconds + delay; + } + } catch (e, stack) { + AppLogger.reportError(e, stack); } }).cancel; }, [lyricsMap, delay]); diff --git a/lib/components/lyrics/zoom_controls.dart b/lib/modules/lyrics/zoom_controls.dart similarity index 62% rename from lib/components/lyrics/zoom_controls.dart rename to lib/modules/lyrics/zoom_controls.dart index f50ea71d..b4eeb9d6 100644 --- a/lib/components/lyrics/zoom_controls.dart +++ b/lib/modules/lyrics/zoom_controls.dart @@ -1,5 +1,6 @@ -import 'package:flutter/material.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'; @@ -17,7 +18,7 @@ class ZoomControls extends HookWidget { final String unit; const ZoomControls({ - Key? key, + super.key, required this.value, required this.onChanged, this.min, @@ -27,12 +28,12 @@ class ZoomControls extends HookWidget { this.decreaseIcon = const Icon(SpotubeIcons.zoomOut), this.direction = Axis.horizontal, this.unit = "%", - }) : super(key: key); + }); @override Widget build(BuildContext context) { final actions = [ - IconButton( + IconButton.ghost( icon: decreaseIcon, onPressed: () { if (value == min) return; @@ -40,7 +41,7 @@ class ZoomControls extends HookWidget { }, ), Text("$value$unit"), - IconButton( + IconButton.ghost( icon: increaseIcon, onPressed: () { if (value == max) return; @@ -50,27 +51,28 @@ class ZoomControls extends HookWidget { ]; return Container( - decoration: BoxDecoration( - color: Theme.of(context).cardColor.withOpacity(0.7), - borderRadius: BorderRadius.circular(10), - ), constraints: BoxConstraints( maxHeight: direction == Axis.horizontal ? 50 : 200, maxWidth: direction == Axis.vertical ? 50 : double.infinity, ), margin: const EdgeInsets.all(8), - 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, - ), + 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/player/player.dart b/lib/modules/player/player.dart new file mode 100644 index 00000000..b02910e9 --- /dev/null +++ b/lib/modules/player/player.dart @@ -0,0 +1,278 @@ +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' hide Consumer; +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/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/artist_simple.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/modules/root/spotube_navigation_bar.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/server/active_sourced_track.dart'; +import 'package:spotube/provider/volume_provider.dart'; +import 'package:spotube/services/sourced_track/sources/youtube.dart'; +import 'package:spotube/utils/platform.dart'; + +import 'package:url_launcher/url_launcher_string.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 auth = ref.watch(authenticationProvider); + final sourcedCurrentTrack = ref.watch(activeSourcedTrackProvider); + final currentActiveTrack = + ref.watch(audioPlayerProvider.select((s) => s.activeTrack)); + final currentTrack = sourcedCurrentTrack ?? currentActiveTrack; + final isLocalTrack = currentTrack is LocalTrack; + final mediaQuery = MediaQuery.sizeOf(context); + + 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( + () => (currentTrack?.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + [currentTrack?.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( + minimum: + kIsMobile ? const EdgeInsets.only(top: 80) : EdgeInsets.zero, + bottom: false, + child: TitleBar( + surfaceOpacity: 0, + surfaceBlur: 0, + leading: [ + IconButton.ghost( + icon: const Icon(SpotubeIcons.angleDown, size: 18), + onPressed: panelController.close, + ) + ], + trailing: [ + if (currentTrack is YoutubeSourcedTrack) + TextButton( + leading: Assets.logos.songlinkTransparent.image( + width: 20, + height: 20, + color: theme.colorScheme.foreground, + ), + onPressed: () { + final url = "https://song.link/s/${currentTrack.id}"; + + launchUrlString(url); + }, + child: Text(context.l10n.song_link), + ), + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.details), + ).call, + child: IconButton.ghost( + icon: const Icon(SpotubeIcons.info, size: 18), + onPressed: currentTrack == null + ? null + : () { + showDialog( + context: context, + builder: (context) { + return TrackDetailsDialog( + track: currentTrack, + ); + }); + }, + ), + ) + ], + ), + ), + ], + 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.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( + currentTrack?.name ?? context.l10n.not_playing, + style: const TextStyle(fontSize: 22), + maxFontSize: 22, + maxLines: 1, + textAlign: TextAlign.start, + ), + if (isLocalTrack) + Text( + currentTrack.artists?.asString() ?? "", + style: theme.typography.normal + .copyWith(fontWeight: FontWeight.bold), + ) + else + ArtistLink( + artists: currentTrack?.artists ?? [], + textStyle: theme.typography.normal + .copyWith(fontWeight: FontWeight.bold), + onRouteChange: (route) { + panelController.close(); + context.router.navigateNamed(route); + }, + onOverflowArtistClick: () => context.navigateTo( + TrackRoute(trackId: currentTrack!.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()); + }, + ), + ), + if (auth.asData?.value != null) const SizedBox(width: 10), + if (auth.asData?.value != null) + 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); + }, + ); + }), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/modules/player/player_actions.dart b/lib/modules/player/player_actions.dart new file mode 100644 index 00000000..53023a10 --- /dev/null +++ b/lib/modules/player/player_actions.dart @@ -0,0 +1,270 @@ +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' 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/extensions/constrains.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/artist_simple.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/duration.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/audio_player/audio_player.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 LocalTrack; + ref.watch(downloadManagerProvider); + final downloader = ref.watch(downloadManagerProvider.notifier); + final isInQueue = useMemoized(() { + if (playlist.activeTrack == null) return false; + return downloader.isActive(playlist.activeTrack!); + }, [ + playlist.activeTrack, + downloader, + ]); + + final localTracks = [] /* ref.watch(localTracksProvider).value */; + final auth = ref.watch(authenticationProvider); + final sleepTimer = ref.watch(sleepTimerProvider); + final sleepTimerNotifier = ref.watch(sleepTimerProvider.notifier); + + final isDownloaded = useMemoized(() { + return localTracks.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!) + : null, + ), + ), + if (playlist.activeTrack != null && + !isLocalTrack && + auth.asData?.value != null) + 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 new file mode 100644 index 00000000..14b9cf43 --- /dev/null +++ b/lib/modules/player/player_controls.dart @@ -0,0 +1,266 @@ +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' hide Consumer; + +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'; + +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); + + 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( + icon: Icon( + SpotubeIcons.shuffle, + color: shuffled ? theme.colorScheme.primary : null, + ), + 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( + 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( + 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( + 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( + 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 new file mode 100644 index 00000000..3c3ff373 --- /dev/null +++ b/lib/modules/player/player_overlay.dart @@ -0,0 +1,54 @@ +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 new file mode 100644 index 00000000..aa5a3b38 --- /dev/null +++ b/lib/modules/player/player_overlay_collapsed.dart @@ -0,0 +1,117 @@ +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: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 new file mode 100644 index 00000000..06d7e3c7 --- /dev/null +++ b/lib/modules/player/player_queue.dart @@ -0,0 +1,256 @@ +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:spotify/spotify.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/button/back_button.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/extensions/artist_simple.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/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(Track 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 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 + 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); + return AutoScrollTag( + key: ValueKey(i), + controller: controller, + index: i, + child: TrackTile( + playlist: playlist, + index: i, + track: track, + onTap: () async { + if (playlist.activeTrack?.id == track.id) { + return; + } + await onJump(track); + }, + leadingActions: [ + if (!isSearching.value && + searchText.value.isEmpty) + 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.playlist.index, + preferPosition: AutoScrollPosition.middle, + ); + }, + ), + ) + ], + ); + } +} diff --git a/lib/components/player/player_track_details.dart b/lib/modules/player/player_track_details.dart similarity index 59% rename from lib/components/player/player_track_details.dart rename to lib/modules/player/player_track_details.dart index 66cb9ef5..2e38bf37 100644 --- a/lib/components/player/player_track_details.dart +++ b/lib/modules/player/player_track_details.dart @@ -1,26 +1,29 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; + +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/links/link_text.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/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; class PlayerTrackDetails extends HookConsumerWidget { - final String? albumArt; final Color? color; - const PlayerTrackDetails({Key? key, this.albumArt, this.color}) - : super(key: key); + final Track? 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(ProxyPlaylistNotifier.provider); + final playback = ref.watch(audioPlayerProvider); return Row( children: [ @@ -34,7 +37,8 @@ class PlayerTrackDetails extends HookConsumerWidget { child: ClipRRect( borderRadius: BorderRadius.circular(4), child: UniversalImage( - path: albumArt ?? "", + path: (track?.album?.images) + .asUrlString(placeholder: ImagePlaceholder.albumArt), placeholder: Assets.albumPlaceholder.path, ), ), @@ -47,19 +51,17 @@ class PlayerTrackDetails extends HookConsumerWidget { const SizedBox(height: 4), LinkText( playback.activeTrack?.name ?? "", - "/track/${playback.activeTrack?.id}", + TrackRoute(trackId: playback.activeTrack?.id ?? ""), push: true, overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium!.copyWith( + style: theme.typography.normal.copyWith( color: color, ), ), Text( - TypeConversionUtils.artists_X_String( - playback.activeTrack?.artists ?? [], - ), + playback.activeTrack?.artists?.asString() ?? "", overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall!.copyWith(color: color), + style: theme.typography.small.copyWith(color: color), ) ], ), @@ -71,16 +73,18 @@ class PlayerTrackDetails extends HookConsumerWidget { children: [ LinkText( playback.activeTrack?.name ?? "", - "/track/${playback.activeTrack?.id}", + TrackRoute(trackId: playback.activeTrack?.id ?? ""), push: true, overflow: TextOverflow.ellipsis, style: TextStyle(fontWeight: FontWeight.bold, color: color), ), - TypeConversionUtils.artists_X_ClickableArtists( - playback.activeTrack?.artists ?? [], + ArtistLink( + artists: playback.activeTrack?.artists ?? [], onRouteChange: (route) { - ServiceUtils.push(context, 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 new file mode 100644 index 00000000..d026cea9 --- /dev/null +++ b/lib/modules/player/sibling_tracks_sheet.dart @@ -0,0 +1,338 @@ +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/collections/assets.gen.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/button/back_button.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/artist_simple.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/duration.dart'; +import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; +import 'package:spotube/hooks/utils/use_debounce.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/audio_player/querying_track_info.dart'; +import 'package:spotube/provider/server/active_sourced_track.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/youtube_engine/youtube_engine.dart'; +import 'package:spotube/services/sourced_track/models/source_info.dart'; +import 'package:spotube/services/sourced_track/models/video_info.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:spotube/services/sourced_track/sources/invidious.dart'; +import 'package:spotube/services/sourced_track/sources/jiosaavn.dart'; +import 'package:spotube/services/sourced_track/sources/piped.dart'; +import 'package:spotube/services/sourced_track/sources/youtube.dart'; +import 'package:spotube/utils/service_utils.dart'; + +final sourceInfoToIconMap = { + YoutubeSourceInfo: const Icon(SpotubeIcons.youtube, color: Color(0xFFFF0000)), + JioSaavnSourceInfo: Container( + height: 30, + width: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(90), + image: DecorationImage( + image: Assets.jiosaavn.provider(), + fit: BoxFit.cover, + ), + ), + ), + PipedSourceInfo: const Icon(SpotubeIcons.piped), + InvidiousSourceInfo: Container( + height: 18, + width: 18, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(90), + image: DecorationImage( + image: Assets.invidious.provider(), + fit: BoxFit.cover, + ), + ), + ), +}; + +class SiblingTracksSheet extends HookConsumerWidget { + final bool floating; + const SiblingTracksSheet({ + super.key, + this.floating = true, + }); + + @override + Widget build(BuildContext context, ref) { + final theme = Theme.of(context); + final playlist = ref.watch(audioPlayerProvider); + final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); + final preferences = ref.watch(userPreferencesProvider); + final youtubeEngine = ref.watch(youtubeEngineProvider); + + final isSearching = useState(false); + final searchMode = useState(preferences.searchMode); + final activeTrackNotifier = ref.watch(activeSourcedTrackProvider.notifier); + final activeTrack = + ref.watch(activeSourcedTrackProvider) ?? playlist.activeTrack; + + final title = ServiceUtils.getTitle( + activeTrack?.name ?? "", + artists: activeTrack?.artists?.map((e) => e.name!).toList() ?? [], + onlyCleanArtist: true, + ).trim(); + + final defaultSearchTerm = + "$title - ${activeTrack?.artists?.asString() ?? ""}"; + final searchController = useShadcnTextEditingController( + text: defaultSearchTerm, + ); + + final searchTerm = useDebounce( + useValueListenable(searchController).text, + ); + + final controller = useScrollController(); + + final searchRequest = useMemoized(() async { + if (searchTerm.trim().isEmpty) { + return []; + } + if (preferences.audioSource == AudioSource.jiosaavn) { + final resultsJioSaavn = + await jiosaavnClient.search.songs(searchTerm.trim()); + final results = await Future.wait( + resultsJioSaavn.results.mapIndexed((i, song) async { + final siblingType = JioSaavnSourcedTrack.toSiblingType(song); + return siblingType.info; + })); + + final activeSourceInfo = (activeTrack! as SourcedTrack).sourceInfo; + + return results + ..removeWhere((element) => element.id == activeSourceInfo.id) + ..insert( + 0, + activeSourceInfo, + ); + } else { + final resultsYt = await youtubeEngine.searchVideos(searchTerm.trim()); + + final searchResults = await Future.wait( + resultsYt + .map(YoutubeVideoInfo.fromVideo) + .mapIndexed((i, video) async { + final siblingType = + await YoutubeSourcedTrack.toSiblingType(i, video, ref); + return siblingType.info; + }), + ); + final activeSourceInfo = (activeTrack! as SourcedTrack).sourceInfo; + return searchResults + ..removeWhere((element) => element.id == activeSourceInfo.id) + ..insert( + 0, + activeSourceInfo, + ); + } + }, [ + searchTerm, + searchMode.value, + activeTrack, + preferences.audioSource, + youtubeEngine, + ]); + + final siblings = useMemoized( + () => !isFetchingActiveTrack + ? [ + (activeTrack as SourcedTrack).sourceInfo, + ...activeTrack.siblings, + ] + : [], + [activeTrack, isFetchingActiveTrack], + ); + + final previousActiveTrack = usePrevious(activeTrack); + useEffect(() { + /// Populate sibling when active track changes + if (previousActiveTrack?.id == activeTrack?.id) return; + if (activeTrack is SourcedTrack && activeTrack.siblings.isEmpty) { + activeTrackNotifier.populateSibling(); + } + return null; + }, [activeTrack, previousActiveTrack]); + + final itemBuilder = useCallback( + (SourceInfo sourceInfo) { + final icon = sourceInfoToIconMap[sourceInfo.runtimeType]; + return ButtonTile( + style: ButtonVariance.ghost, + padding: const EdgeInsets.symmetric(horizontal: 8), + title: Text( + sourceInfo.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + leading: UniversalImage( + path: sourceInfo.thumbnail, + height: 60, + width: 60, + ), + trailing: Text(sourceInfo.duration.toHumanReadableString()), + subtitle: Row( + children: [ + if (icon != null) icon, + Flexible( + child: Text( + " • ${sourceInfo.artist}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + enabled: !isFetchingActiveTrack, + selected: !isFetchingActiveTrack && + sourceInfo.id == (activeTrack as SourcedTrack).sourceInfo.id, + onPressed: () { + if (!isFetchingActiveTrack && + sourceInfo.id != (activeTrack as SourcedTrack).sourceInfo.id) { + activeTrackNotifier.swapSibling(sourceInfo); + if (MediaQuery.sizeOf(context).mdAndUp) { + closeOverlay(context); + } else { + closeDrawer(context); + } + } + }, + ); + }, + [activeTrack, siblings], + ); + + final scale = context.theme.scaling; + + 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: !isSearching.value + ? Text( + context.l10n.alternative_track_sources, + ).bold() + : ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 320 * scale, + maxHeight: 38 * scale, + ), + child: TextField( + autofocus: true, + controller: searchController, + placeholder: Text(context.l10n.search), + style: theme.typography.bold, + ), + ), + ), + const Spacer(), + if (!isSearching.value) ...[ + IconButton.outline( + icon: const Icon(SpotubeIcons.search, size: 18), + onPressed: () { + isSearching.value = true; + }, + ), + if (!floating) const BackButton(icon: SpotubeIcons.angleDown) + ] else ...[ + if (preferences.audioSource == AudioSource.piped) + IconButton.outline( + icon: const Icon(SpotubeIcons.filter, size: 18), + onPressed: () { + showPopover( + context: context, + alignment: Alignment.bottomRight, + builder: (context) { + return DropdownMenu( + children: SearchMode.values + .map( + (e) => MenuButton( + onPressed: (context) { + searchMode.value = e; + }, + enabled: searchMode.value != e, + child: Text(e.label), + ), + ) + .toList(), + ); + }, + ); + }, + ), + IconButton.outline( + icon: const Icon(SpotubeIcons.close, size: 18), + onPressed: () { + isSearching.value = false; + }, + ), + ] + ], + ), + ), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: InterScrollbar( + controller: controller, + child: switch (isSearching.value) { + false => ListView.separated( + padding: const EdgeInsets.all(8.0), + controller: controller, + itemCount: siblings.length, + separatorBuilder: (context, index) => const Gap(8), + itemBuilder: (context, index) => + itemBuilder(siblings[index]), + ), + true => FutureBuilder( + future: searchRequest, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text(snapshot.error.toString()), + ); + } else if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator()); + } + + return ListView.separated( + padding: const EdgeInsets.all(8.0), + controller: controller, + itemCount: snapshot.data!.length, + separatorBuilder: (context, index) => const Gap(8), + itemBuilder: (context, index) => + itemBuilder(snapshot.data![index]), + ); + }, + ), + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/components/player/use_progress.dart b/lib/modules/player/use_progress.dart similarity index 76% rename from lib/components/player/use_progress.dart rename to lib/modules/player/use_progress.dart index 15a979af..eaea638e 100644 --- a/lib/components/player/use_progress.dart +++ b/lib/modules/player/use_progress.dart @@ -1,4 +1,3 @@ -import 'package:async/async.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -19,26 +18,13 @@ import 'package:spotube/services/audio_player/audio_player.dart'; final sliderValue = position.value.inSeconds; useEffect(() { - final durationOperation = - CancelableOperation.fromFuture(audioPlayer.duration); - durationOperation.then((value) { - if (value != null) { - duration.value = value; - } - }); + duration.value = audioPlayer.duration; final durationSubscription = audioPlayer.durationStream.listen((event) { duration.value = event; }); - final positionOperation = - CancelableOperation.fromFuture(audioPlayer.position); - - positionOperation.then((value) { - if (value != null) { - position.value = value; - } - }); + position.value = audioPlayer.position; var lastPosition = position.value; @@ -54,9 +40,7 @@ import 'package:spotube/services/audio_player/audio_player.dart'; }); return () { - positionOperation.cancel(); positionSubscription.cancel(); - durationOperation.cancel(); durationSubscription.cancel(); }; }, []); diff --git a/lib/components/player/volume_slider.dart b/lib/modules/player/volume_slider.dart similarity index 57% rename from lib/components/player/volume_slider.dart rename to lib/modules/player/volume_slider.dart index 75445125..ee4ac9c5 100644 --- a/lib/components/player/volume_slider.dart +++ b/lib/modules/player/volume_slider.dart @@ -1,61 +1,69 @@ import 'package:flutter/gestures.dart'; -import 'package:flutter/material.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/provider/volume_provider.dart'; class VolumeSlider extends HookConsumerWidget { final bool fullWidth; + + final double value; + final ValueChanged onChanged; + const VolumeSlider({ - Key? key, + super.key, this.fullWidth = false, - }) : super(key: key); + required this.value, + required this.onChanged, + }); @override Widget build(BuildContext context, ref) { - final volume = ref.watch(volumeProvider); - final volumeNotifier = ref.watch(volumeProvider.notifier); - var slider = Listener( onPointerSignal: (event) async { if (event is PointerScrollEvent) { if (event.scrollDelta.dy > 0) { - final value = volume - .2; - volumeNotifier.setVolume(value < 0 ? 0 : value); + final newValue = value - .2; + onChanged(newValue < 0 ? 0 : newValue); } else { - final value = volume + .2; - volumeNotifier.setVolume(value > 1 ? 1 : value); + final newValue = value + .2; + onChanged(newValue > 1 ? 1 : newValue); } } }, - child: Slider( - min: 0, - max: 1, - value: volume, - onChanged: volumeNotifier.setVolume, + 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( - volume == 0 + value == 0 ? SpotubeIcons.volumeMute - : volume <= 0.2 + : value <= 0.2 ? SpotubeIcons.volumeLow - : volume <= 0.6 + : value <= 0.6 ? SpotubeIcons.volumeMedium : SpotubeIcons.volumeHigh, size: 16, ), onPressed: () { - if (volume == 0) { - volumeNotifier.setVolume(1); + if (value == 0) { + onChanged(1); } else { - volumeNotifier.setVolume(0); + onChanged(0); } }, ), diff --git a/lib/modules/playlist/playlist_card.dart b/lib/modules/playlist/playlist_card.dart new file mode 100644 index 00000000..c4ffffa7 --- /dev/null +++ b/lib/modules/playlist/playlist_card.dart @@ -0,0 +1,232 @@ +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' hide Consumer; +import 'package:spotify/spotify.dart' hide Offset, Image; +import 'package:spotube/collections/env.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/extensions/image.dart'; +import 'package:spotube/models/connect/connect.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/spotify/spotify.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:stroke_text/stroke_text.dart'; + +class PlaylistCard extends HookConsumerWidget { + final PlaylistSimple 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; + bool isPlaylistPlaying = useMemoized( + () => playlistQueue.containsCollection(playlist.id!), + [playlistQueue, playlist.id], + ); + + final updating = useState(false); + final me = ref.watch(meProvider); + + Future> fetchInitialTracks() async { + if (playlist.id == 'user-liked-tracks') { + return await ref.read(likedTracksProvider.future); + } + + final result = + await ref.read(playlistTracksProvider(playlist.id!).future); + + return result.items; + } + + Future> fetchAllTracks() async { + final initialTracks = await fetchInitialTracks(); + + if (playlist.id == 'user-liked-tracks') { + return initialTracks; + } + + return ref.read(playlistTracksProvider(playlist.id!).notifier).fetchAll(); + } + + void onTap() { + context.navigateTo(PlaylistRoute(id: playlist.id!, playlist: playlist)); + } + + void onPlaybuttonPressed() 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; + } + } + } + + void onAddToQueuePressed() 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; + } + } + + final imageUrl = playlist.images.asUrlString( + placeholder: ImagePlaceholder.collection, + ); + final isLoading = + (isPlaylistPlaying && isFetchingActiveTrack) || updating.value; + final isOwner = playlist.owner?.id == me.asData?.value.id && + me.asData?.value.id != null; + + final image = + playlist.owner?.displayName == "Spotify" && Env.disableSpotifyImages + ? Consumer( + builder: (context, ref, child) { + final (:color, :colorBlendMode, :src, :placement) = + ref.watch(playlistImageProvider(playlist.id!)); + + return Stack( + children: [ + Positioned.fill( + child: Image.asset( + src, + color: color, + colorBlendMode: colorBlendMode, + fit: BoxFit.cover, + ), + ), + Positioned.fill( + top: placement == Alignment.topLeft ? 10 : null, + left: 10, + bottom: placement == Alignment.bottomLeft ? 10 : null, + child: StrokeText( + text: playlist.name!, + strokeColor: Colors.white, + strokeWidth: 3, + textColor: Colors.black, + textStyle: const TextStyle( + fontSize: 16, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ); + }, + ) + : null; + + if (_isTile) { + return PlaybuttonTile( + title: playlist.name!, + description: playlist.description, + image: image, + imageUrl: image == null ? imageUrl : null, + isPlaying: isPlaylistPlaying, + isLoading: isLoading, + isOwner: isOwner, + onTap: onTap, + onPlaybuttonPressed: onPlaybuttonPressed, + onAddToQueuePressed: onAddToQueuePressed, + ); + } + + return PlaybuttonCard( + title: playlist.name!, + description: playlist.description, + image: image, + imageUrl: image == null ? imageUrl : null, + 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 new file mode 100644 index 00000000..3ee39583 --- /dev/null +++ b/lib/modules/playlist/playlist_create_dialog.dart @@ -0,0 +1,282 @@ +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:spotify/spotify.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/extensions/image.dart'; +import 'package:spotube/provider/spotify/spotify.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(favoritePlaylistsProvider); + final playlist = ref.watch(playlistProvider(playlistId ?? "")); + final playlistNotifier = + ref.watch(playlistProvider(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); + + final onError = useCallback((error) { + if (error is SpotifyError || error is SpotifyException) { + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + child: Basic( + title: Text( + l10n.error(error.message ?? 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 PlaylistInput 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(payload, onError); + } else { + await playlistNotifier.create(payload, onError); + } + + if (trackIds.isNotEmpty) { + await playlistNotifier.addTracks(trackIds, onError); + } + } finally { + isSubmitting.value = false; + if (context.mounted && + !ref.read(playlistProvider(playlistId ?? "")).hasError) { + context.router.maybePop( + await ref.read(playlistProvider(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': updatingPlaylist?.public ?? false, + 'collaborative': updatingPlaylist?.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, SpotifyApiWrapper spotify) { + showDialog( + context: context, + alignment: Alignment.center, + builder: (context) => const ToastLayer( + child: PlaylistCreateDialog(), + ), + ); + } + + @override + Widget build(BuildContext context, ref) { + final spotify = ref.watch(spotifyProvider); + + return Button.secondary( + leading: const Icon(SpotubeIcons.addFilled), + child: Text(context.l10n.playlist), + onPressed: () => showPlaylistDialog(context, spotify), + ); + } +} diff --git a/lib/modules/root/bottom_player.dart b/lib/modules/root/bottom_player.dart new file mode 100644 index 00000000..c2e6369d --- /dev/null +++ b/lib/modules/root/bottom_player.dart @@ -0,0 +1,132 @@ +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' hide Consumer; +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/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/extensions/image.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.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 new file mode 100644 index 00000000..6b61c70b --- /dev/null +++ b/lib/modules/root/sidebar/sidebar.dart @@ -0,0 +1,140 @@ +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/assets.gen.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, + }); + + static Widget brandLogo(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(50), + ), + child: Assets.spotubeLogoPng.image( + height: 50, + cacheHeight: (100 * MediaQuery.devicePixelRatioOf(context)).toInt(), + ), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mediaQuery = MediaQuery.of(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 + ? const DefaultTextStyle( + style: TextStyle( + fontFamily: "Cookie", + fontSize: 30, + letterSpacing: 1.8, + ), + child: Text("Spotube"), + ) + : const Text(""), + ), + for (final tile in sidebarTileList) + NavigationButton( + 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( + 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 new file mode 100644 index 00000000..fb3edddd --- /dev/null +++ b/lib/modules/root/sidebar/sidebar_footer.dart @@ -0,0 +1,140 @@ +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/image.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/modules/connect/connect_device.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/provider/spotify/spotify.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).$downloadCount; + final userSnapshot = ref.watch(meProvider); + final data = userSnapshot.asData?.value; + + final avatarImg = (data?.images).asUrlString( + index: (data?.images?.length ?? 1) - 1, + placeholder: ImagePlaceholder.artist, + ); + + final auth = ref.watch(authenticationProvider); + + 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 (auth.asData?.value != null && data == null) + const CircularProgressIndicator() + else if (data != null) + Flexible( + child: GestureDetector( + onTap: () { + context.navigateTo(const ProfileRoute()); + }, + child: Row( + children: [ + Avatar( + initials: + Avatar.getInitials(data.displayName ?? "User"), + provider: UniversalImage.imageProvider(avatarImg), + ), + const SizedBox(width: 10), + Flexible( + child: Text( + data.displayName ?? context.l10n.guest, + 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 new file mode 100644 index 00000000..15417fa6 --- /dev/null +++ b/lib/modules/root/spotube_navigation_bar.dart @@ -0,0 +1,86 @@ +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).$downloadCount; + 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 new file mode 100644 index 00000000..4aa2fd13 --- /dev/null +++ b/lib/modules/root/update_dialog.dart @@ -0,0 +1,57 @@ +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_downloader_dialogs.dart b/lib/modules/root/use_downloader_dialogs.dart new file mode 100644 index 00000000..e2f91043 --- /dev/null +++ b/lib/modules/root/use_downloader_dialogs.dart @@ -0,0 +1,46 @@ +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/components/dialogs/replace_downloaded_dialog.dart'; +import 'package:spotube/provider/download_manager_provider.dart'; + +void useDownloaderDialogs(WidgetRef ref) { + final context = useContext(); + final showingDialogCompleter = useRef(Completer()..complete()); + final downloader = ref.watch(downloadManagerProvider); + + useEffect(() { + downloader.onFileExists = (track) async { + if (!context.mounted) return false; + + if (!showingDialogCompleter.value.isCompleted) { + await showingDialogCompleter.value.future; + } + + final replaceAll = ref.read(replaceDownloadedFileState); + + if (replaceAll != null) return replaceAll; + + showingDialogCompleter.value = Completer(); + + if (context.mounted) { + final result = await showDialog( + context: context, + builder: (context) => ReplaceDownloadedDialog( + track: track, + ), + ) ?? + false; + + showingDialogCompleter.value.complete(); + return result; + } + + // it'll never reach here as root_app is always mounted + return false; + }; + return null; + }, [downloader]); +} diff --git a/lib/modules/root/use_global_subscriptions.dart b/lib/modules/root/use_global_subscriptions.dart new file mode 100644 index 00000000..e0e4dae7 --- /dev/null +++ b/lib/modules/root/use_global_subscriptions.dart @@ -0,0 +1,127 @@ +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/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); + }); + + 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/settings/color_scheme_picker_dialog.dart b/lib/modules/settings/color_scheme_picker_dialog.dart new file mode 100644 index 00000000..3fb05e72 --- /dev/null +++ b/lib/modules/settings/color_scheme_picker_dialog.dart @@ -0,0 +1,154 @@ +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": ColorSchemes.slate, + "gray": ColorSchemes.gray, + "zinc": ColorSchemes.zinc, + "neutral": ColorSchemes.neutral, + "stone": ColorSchemes.stone, + "red": ColorSchemes.red, + "orange": ColorSchemes.orange, + "yellow": ColorSchemes.yellow, + "green": ColorSchemes.green, + "blue": ColorSchemes.blue, + "violet": ColorSchemes.violet, + "rose": ColorSchemes.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 new file mode 100644 index 00000000..587f4388 --- /dev/null +++ b/lib/modules/settings/playback/edit_connect_port_dialog.dart @@ -0,0 +1,97 @@ +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 new file mode 100644 index 00000000..b2dda411 --- /dev/null +++ b/lib/modules/settings/playback/edit_instance_url_dialog.dart @@ -0,0 +1,75 @@ +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 new file mode 100644 index 00000000..c7bc1f26 --- /dev/null +++ b/lib/modules/settings/section_card_with_heading.dart @@ -0,0 +1,55 @@ +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 new file mode 100644 index 00000000..b993dd1b --- /dev/null +++ b/lib/modules/settings/youtube_engine_not_installed_dialog.dart @@ -0,0 +1,122 @@ +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 new file mode 100644 index 00000000..cd0a6caf --- /dev/null +++ b/lib/modules/stats/common/album_item.dart @@ -0,0 +1,51 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/components/ui/button_tile.dart'; +import 'package:spotube/modules/album/album_card.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/links/artist_link.dart'; +import 'package:spotube/extensions/image.dart'; + +class StatsAlbumItem extends StatelessWidget { + final AlbumSimple 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 new file mode 100644 index 00000000..5eff9a9d --- /dev/null +++ b/lib/modules/stats/common/artist_item.dart @@ -0,0 +1,37 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotify/spotify.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/image.dart'; + +class StatsArtistItem extends StatelessWidget { + final Artist 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 new file mode 100644 index 00000000..58610af1 --- /dev/null +++ b/lib/modules/stats/common/playlist_item.dart @@ -0,0 +1,42 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotify/spotify.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/image.dart'; +import 'package:spotube/extensions/string.dart'; + +class StatsPlaylistItem extends StatelessWidget { + final PlaylistSimple 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 new file mode 100644 index 00000000..ae2e22c6 --- /dev/null +++ b/lib/modules/stats/common/track_item.dart @@ -0,0 +1,47 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotify/spotify.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/extensions/image.dart'; + +class StatsTrackItem extends StatelessWidget { + final Track 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 new file mode 100644 index 00000000..30e68b1f --- /dev/null +++ b/lib/modules/stats/summary/summary.dart @@ -0,0 +1,102 @@ +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 new file mode 100644 index 00000000..e78dd080 --- /dev/null +++ b/lib/modules/stats/summary/summary_card.dart @@ -0,0 +1,90 @@ +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 new file mode 100644 index 00000000..09bf755c --- /dev/null +++ b/lib/modules/stats/top/albums.dart @@ -0,0 +1,67 @@ +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/spotify/spotify.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 new file mode 100644 index 00000000..cb2a152f --- /dev/null +++ b/lib/modules/stats/top/artists.dart @@ -0,0 +1,77 @@ +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/spotify/spotify.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( + () => topTracks.asData?.value.artists ?? [], + [topTracks.asData?.value], + ); + + for (final artist in artistsData) { + print("${artist.artist.name} has ${artist.artist.images?.length} images"); + } + + 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 new file mode 100644 index 00000000..38f04ccb --- /dev/null +++ b/lib/modules/stats/top/top.dart @@ -0,0 +1,111 @@ +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 new file mode 100644 index 00000000..c4015431 --- /dev/null +++ b/lib/modules/stats/top/tracks.dart @@ -0,0 +1,69 @@ +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/spotify/spotify.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 index 4578aea2..5773f9fa 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -1,80 +1,79 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; +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:spotify/spotify.dart'; -import 'package:spotube/components/shared/tracks_view/track_view.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.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/extensions/infinite_query.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +@RoutePage() class AlbumPage extends HookConsumerWidget { + static const name = "album"; + final AlbumSimple album; + final String id; const AlbumPage({ super.key, + @PathParam("id") required this.id, required this.album, }); @override Widget build(BuildContext context, ref) { - final spotify = ref.watch(spotifyProvider); - final tracksQuery = useQueries.album.tracksOf(ref, album); + final tracks = ref.watch(albumTracksProvider(album)); + final tracksNotifier = ref.watch(albumTracksProvider(album).notifier); + final favoriteAlbumsNotifier = ref.watch(favoriteAlbumsProvider.notifier); + final isSavedAlbum = ref.watch(albumsIsSavedProvider(album.id!)); - final tracks = useMemoized(() { - return tracksQuery.pages.expand((element) => element).toList(); - }, [tracksQuery.pages]); - - final client = useQueryClient(); - - final albumIsSaved = useQueries.album.isSavedForMe(ref, album.id!); - final isLiked = albumIsSaved.data ?? false; - - final toggleAlbumLike = useMutations.album.toggleFavorite( - ref, - album.id!, - refreshQueries: [albumIsSaved.key], - onData: (_, __) async { - await client.refreshInfiniteQueryAllPages("current-user-albums"); + return material.RefreshIndicator.adaptive( + onRefresh: () async { + ref.invalidate(albumTracksProvider(album)); + ref.invalidate(favoriteAlbumsProvider); + ref.invalidate(albumsIsSavedProvider(album.id!)); }, - ); - - return InheritedTrackView( - collectionId: album.id!, - image: TypeConversionUtils.image_X_UrlString( - album.images, - placeholder: ImagePlaceholder.albumArt, + 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 ?? [], + 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(albumTracksProvider(album)); + }, + ), + routePath: "/album/${album.id}", + shareUrl: album.externalUrls?.spotify ?? + "https://open.spotify.com/album/${album.id}", + isLiked: isSavedAlbum.asData?.value ?? false, + owner: album.artists!.first.name, + onHeart: isSavedAlbum.asData?.value == null + ? null + : () async { + if (isSavedAlbum.asData!.value) { + await favoriteAlbumsNotifier.removeFavorites([album.id!]); + } else { + await favoriteAlbumsNotifier.addFavorites([album.id!]); + } + return null; + }, + ), ), - title: album.name!, - description: - "${context.l10n.released} • ${album.releaseDate} • ${album.artists!.first.name}", - tracks: tracks, - pagination: PaginationProps.fromQuery( - tracksQuery, - onFetchAll: () { - return tracksQuery.fetchAllTracks(getAllTracks: () async { - final res = await spotify.albums.tracks(album.id!).all(); - - return res - .map((track) => - TypeConversionUtils.simpleTrack_X_Track(track, album)) - .toList(); - }); - }, - ), - routePath: "/album/${album.id}", - shareUrl: album.externalUrls!.spotify!, - isLiked: isLiked, - onHeart: albumIsSaved.hasData - ? () async { - await toggleAlbumLike.mutate(isLiked); - return null; - } - : null, - child: const TrackView(), ); } } diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index d511cb97..2037174a 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -1,82 +1,101 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' as material; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotube/components/button/back_button.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/artist/artist_album_list.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; +import 'package:spotube/modules/artist/artist_album_list.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/logger.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/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class ArtistPage extends HookConsumerWidget { + static const name = "artist"; + final String artistId; - final logger = getLogger(ArtistPage); - ArtistPage(this.artistId, {Key? key}) : super(key: key); + const ArtistPage( + @PathParam("id") this.artistId, { + super.key, + }); @override Widget build(BuildContext context, ref) { final scrollController = useScrollController(); final theme = Theme.of(context); - final artistQuery = useQueries.artist.get(ref, artistId); + final artistQuery = ref.watch(artistProvider(artistId)); return SafeArea( bottom: false, child: Scaffold( - appBar: const PageWindowTitleBar( - leading: BackButton(), - backgroundColor: Colors.transparent, - ), - extendBodyBehindAppBar: true, - body: Builder(builder: (context) { - if (artistQuery.hasError && artistQuery.data == null) { - return Center(child: Text(artistQuery.error.toString())); - } - return Skeletonizer( - enabled: artistQuery.isLoading, - child: CustomScrollView( - controller: scrollController, - slivers: [ - SliverToBoxAdapter( - child: SafeArea( - bottom: false, - child: ArtistPageHeader(artistId: artistId), - ), - ), - const SliverGap(50), - ArtistPageTopTracks(artistId: artistId), - const SliverGap(50), - SliverToBoxAdapter(child: ArtistAlbumList(artistId)), - const SliverGap(20), - SliverPadding( - padding: const EdgeInsets.all(8.0), - sliver: SliverToBoxAdapter( - child: Text( - context.l10n.fans_also_like, - style: theme.textTheme.headlineSmall, + headers: const [ + TitleBar( + leading: [BackButton()], + backgroundColor: Colors.transparent, + ) + ], + floatingHeader: true, + child: material.RefreshIndicator.adaptive( + onRefresh: () async { + ref.invalidate(artistProvider(artistId)); + ref.invalidate(relatedArtistsProvider(artistId)); + ref.invalidate(artistAlbumsProvider(artistId)); + ref.invalidate(artistIsFollowingProvider(artistId)); + ref.invalidate(artistTopTracksProvider(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: [ + SliverToBoxAdapter( + child: SafeArea( + bottom: false, + child: ArtistPageHeader(artistId: artistId), ), ), - ), - SliverSafeArea( - sliver: ArtistPageRelatedArtists(artistId: artistId), - ), - if (artistQuery.data != null) - SliverSafeArea( - top: false, + const SliverGap(20), + ArtistPageTopTracks(artistId: artistId), + const SliverGap(20), + SliverToBoxAdapter(child: ArtistAlbumList(artistId)), + SliverPadding( + padding: const EdgeInsets.all(8.0), sliver: SliverToBoxAdapter( - child: ArtistPageFooter(artist: artistQuery.data!), + child: Text( + context.l10n.fans_also_like, + style: 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 index b01ef705..9a1423b5 100644 --- a/lib/pages/artist/section/footer.dart +++ b/lib/pages/artist/section/footer.dart @@ -1,31 +1,32 @@ import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + import 'package:url_launcher/url_launcher_string.dart'; -class ArtistPageFooter extends HookConsumerWidget { +class ArtistPageFooter extends ConsumerWidget { final Artist artist; - const ArtistPageFooter({Key? key, required this.artist}) : super(key: key); + const ArtistPageFooter({super.key, required this.artist}); @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme) = Theme.of(context); + final ThemeData(:typography) = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final artistImage = TypeConversionUtils.image_X_UrlString( - artist.images, + final artistImage = artist.images.asUrlString( placeholder: ImagePlaceholder.artist, ); - final summary = useQueries.artist.wikipediaSummary(artist); - if (summary.hasError || !summary.hasData) return const SizedBox.shrink(); + final summary = ref.watch(artistWikipediaSummaryProvider(artist)); + if (summary.asData?.value == null) return const SizedBox.shrink(); + return Container( - margin: const EdgeInsets.all(16), + margin: const EdgeInsets.all(8), padding: mediaQuery.smAndDown ? const EdgeInsets.all(20) : const EdgeInsets.all(30), @@ -34,13 +35,13 @@ class ArtistPageFooter extends HookConsumerWidget { borderRadius: BorderRadius.circular(10), image: DecorationImage( colorFilter: ColorFilter.mode( - Colors.black.withOpacity(0.5), + Colors.black.withValues(alpha: 0.5), BlendMode.darken, ), image: UniversalImage.imageProvider( - summary.data!.thumbnail?.source_ ?? artistImage, - height: summary.data!.thumbnail?.height.toDouble(), - width: summary.data!.thumbnail?.width.toDouble(), + 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, @@ -49,7 +50,7 @@ class ArtistPageFooter extends HookConsumerWidget { alignment: Alignment.center, child: RichText( text: TextSpan( - style: textTheme.bodyLarge?.copyWith( + style: typography.semiBold.copyWith( color: Colors.white, ), children: [ @@ -63,25 +64,25 @@ class ArtistPageFooter extends HookConsumerWidget { ), TextSpan( text: " Wikipedia", - style: textTheme.titleLarge?.copyWith( + style: typography.large.copyWith( color: Colors.white, ), ), const TextSpan(text: '\n\n'), TextSpan( - text: summary.data!.extract, + text: summary.asData?.value!.extract, ), TextSpan( text: '\n...read more at wikipedia', - style: textTheme.bodyLarge?.copyWith( - color: Colors.lightBlue[300], + style: typography.semiBold.copyWith( + color: Colors.sky[300], decoration: TextDecoration.underline, - decorationColor: Colors.lightBlue[300], + decorationColor: Colors.sky[300], ), recognizer: TapGestureRecognizer() ..onTap = () async { await launchUrlString( - "http://en.wikipedia.org/wiki?curid=${summary.data?.pageid}", + "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 index 7cee7a01..62e66edc 100644 --- a/lib/pages/artist/section/header.dart +++ b/lib/pages/artist/section/header.dart @@ -1,256 +1,227 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter/material.dart'; +import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Consumer; import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/primitive_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class ArtistPageHeader extends HookConsumerWidget { final String artistId; - const ArtistPageHeader({Key? key, required this.artistId}) : super(key: key); + const ArtistPageHeader({super.key, required this.artistId}); @override Widget build(BuildContext context, ref) { - final queryClient = useQueryClient(); - final artistQuery = useQueries.artist.get(ref, artistId); - final artist = artistQuery.data ?? FakeData.artist; + final artistQuery = ref.watch(artistProvider(artistId)); + final artist = artistQuery.asData?.value ?? FakeData.artist; - final scaffoldMessenger = ScaffoldMessenger.of(context); - final mediaQuery = MediaQuery.of(context); final theme = Theme.of(context); - final ThemeData(:textTheme) = theme; + final ThemeData(:typography) = theme; - final chipTextVariant = useBreakpointValue( - xs: textTheme.bodySmall, - sm: textTheme.bodySmall, - md: textTheme.bodyMedium, - lg: textTheme.bodyLarge, - xl: textTheme.titleSmall, - xxl: textTheme.titleMedium, - ); + final auth = ref.watch(authenticationProvider); + ref.watch(blacklistProvider); + final blacklistNotifier = ref.watch(blacklistProvider.notifier); + final isBlackListed = blacklistNotifier.containsArtist(artist); - final spotify = ref.read(spotifyProvider); - final auth = ref.watch(AuthenticationNotifier.provider); - final blacklist = ref.watch(BlackListNotifier.provider); - final isBlackListed = blacklist.contains( - BlacklistedElement.artist(artistId, artist.name!), - ); - - final image = TypeConversionUtils.image_X_UrlString( - artist.images, + final image = artist.images.asUrlString( placeholder: ImagePlaceholder.artist, ); + final actions = Skeleton.keep( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (auth.asData?.value != null) + Consumer( + builder: (context, ref, _) { + final isFollowingQuery = ref.watch( + artistIsFollowingProvider(artist.id!), + ); + final followingArtistNotifier = ref.watch( + followedArtistsProvider.notifier, + ); + + return switch (isFollowingQuery) { + AsyncData(value: final following) => Builder( + builder: (context) { + if (following) { + return Button.outline( + onPressed: () async { + await followingArtistNotifier + .removeArtists([artist.id!]); + }, + child: Text(context.l10n.following), + ); + } + + return Button.primary( + onPressed: () async { + await followingArtistNotifier + .saveArtists([artist.id!]); + }, + 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 { + if (artist.externalUrls?.spotify != null) { + await Clipboard.setData( + ClipboardData( + text: artist.externalUrls!.spotify!, + ), + ); + } + + 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 Center( - child: Flex( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: constrains.smAndDown - ? CrossAxisAlignment.start - : CrossAxisAlignment.center, - direction: constrains.smAndDown ? Axis.vertical : Axis.horizontal, - children: [ - DecoratedBox( - decoration: BoxDecoration( - boxShadow: kElevationToShadow[2], - borderRadius: BorderRadius.circular(35), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(35), - child: UniversalImage( - path: image, - width: 250, - height: 250, - fit: BoxFit.cover, - ), - ), - ), - const Gap(20), - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.blue, - borderRadius: BorderRadius.circular(50)), - child: Skeleton.keep( - child: Text( - artist.type!.toUpperCase(), - style: chipTextVariant.copyWith( - color: Colors.white, - ), - ), - ), - ), - if (isBlackListed) ...[ - const SizedBox(width: 5), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.red[400], - borderRadius: BorderRadius.circular(50)), - child: Text( - context.l10n.blacklisted, - style: chipTextVariant.copyWith( - color: Colors.white, - ), - ), - ), - ] - ], - ), - Text( - artist.name!, - style: mediaQuery.smAndDown - ? textTheme.headlineSmall - : textTheme.headlineMedium, - ), - Text( - context.l10n.followers( - PrimitiveUtils.toReadableNumber( - artist.followers!.total!.toDouble(), + 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, ), ), - style: textTheme.bodyMedium?.copyWith( - fontWeight: mediaQuery.mdAndUp ? FontWeight.bold : null, - ), - ), - const Gap(20), - Skeleton.keep( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (auth != null) - HookBuilder( - builder: (context) { - final isFollowingQuery = - useQueries.artist.doIFollow(ref, artistId); - - final followUnfollow = useCallback(() async { - try { - isFollowingQuery.data! - ? await spotify.me.unfollow( - FollowingType.artist, - [artistId], - ) - : await spotify.me.follow( - FollowingType.artist, - [artistId], - ); - await isFollowingQuery.refresh(); - - queryClient.refreshInfiniteQueryAllPages( - "user-following-artists"); - } finally { - queryClient.refreshQuery( - "user-follows-artists-query/$artistId", - ); - } - }, [isFollowingQuery]); - - if (isFollowingQuery.isLoading || - !isFollowingQuery.hasData) { - return const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(), - ); - } - - if (isFollowingQuery.data!) { - return OutlinedButton( - onPressed: followUnfollow, - child: Text(context.l10n.following), - ); - } - - return FilledButton( - onPressed: followUnfollow, - child: Text(context.l10n.follow), - ); - }, - ), - const SizedBox(width: 5), - IconButton( - tooltip: context.l10n.add_artist_to_blacklist, - icon: Icon( - SpotubeIcons.userRemove, - color: - !isBlackListed ? Colors.red[400] : Colors.white, - ), - style: IconButton.styleFrom( - backgroundColor: - isBlackListed ? Colors.red[400] : null, - ), - onPressed: () async { - if (isBlackListed) { - ref - .read(BlackListNotifier.provider.notifier) - .remove( - BlacklistedElement.artist( - artist.id!, artist.name!), - ); - } else { - ref.read(BlackListNotifier.provider.notifier).add( - BlacklistedElement.artist( - artist.id!, artist.name!), - ); - } - }, - ), - IconButton( - icon: const Icon(SpotubeIcons.share), - onPressed: () async { - if (artist.externalUrls?.spotify != null) { - await Clipboard.setData( - ClipboardData( - text: artist.externalUrls!.spotify!, + 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(), ), - ); - } - - if (!context.mounted) return; - - scaffoldMessenger.showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - context.l10n.artist_url_copied, - textAlign: TextAlign.center, + ] + ], + ), + 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( + PrimitiveUtils.toReadableNumber( + artist.followers!.total!.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 index 2938c084..2db9ca94 100644 --- a/lib/pages/artist/section/related_artists.dart +++ b/lib/pages/artist/section/related_artists.dart @@ -1,49 +1,45 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/artist/artist_card.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/modules/artist/artist_card.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; -class ArtistPageRelatedArtists extends HookConsumerWidget { +class ArtistPageRelatedArtists extends ConsumerWidget { final String artistId; const ArtistPageRelatedArtists({ - Key? key, + super.key, required this.artistId, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { - final relatedArtists = useQueries.artist.relatedArtistsOf( - ref, - artistId, - ); + final relatedArtists = ref.watch(relatedArtistsProvider(artistId)); - if (relatedArtists.isLoading || !relatedArtists.hasData) { - return const SliverToBoxAdapter( - child: Center(child: CircularProgressIndicator())); - } else if (relatedArtists.hasError) { - return SliverToBoxAdapter( - child: Center( - child: Text(relatedArtists.error.toString()), + return switch (relatedArtists) { + AsyncData(value: final artists) => SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + sliver: SliverGrid.builder( + itemCount: artists.length, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: 250, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 0.8, + ), + itemBuilder: (context, index) { + final artist = artists.elementAt(index); + return ArtistCard(artist); + }, + ), ), - ); - } - - return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - sliver: SliverGrid.builder( - itemCount: relatedArtists.data!.length, - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - mainAxisExtent: 250, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - childAspectRatio: 0.8, + AsyncError(:final error) => SliverToBoxAdapter( + child: Center( + child: Text(error.toString()), + ), ), - itemBuilder: (context, index) { - final artist = relatedArtists.data!.elementAt(index); - return ArtistCard(artist); - }, - ), - ); + _ => const SliverToBoxAdapter( + child: Center(child: CircularProgressIndicator()), + ), + }; } } diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart index 771757b9..72709751 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -1,33 +1,31 @@ -import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/track_tile/track_tile.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/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class ArtistPageTopTracks extends HookConsumerWidget { final String artistId; - const ArtistPageTopTracks({Key? key, required this.artistId}) - : super(key: key); + const ArtistPageTopTracks({super.key, required this.artistId}); @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); - final scaffoldMessenger = ScaffoldMessenger.of(context); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final topTracksQuery = useQueries.artist.topTracksOf( - ref, - artistId, - ); + final playlist = ref.watch(audioPlayerProvider); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + final topTracksQuery = ref.watch(artistTopTracksProvider(artistId)); final isPlaylistPlaying = playlist.containsTracks( - topTracksQuery.data ?? [], + topTracksQuery.asData?.value ?? [], ); if (topTracksQuery.hasError) { @@ -38,21 +36,50 @@ class ArtistPageTopTracks extends HookConsumerWidget { ); } - final topTracks = - topTracksQuery.data ?? List.generate(10, (index) => FakeData.track); + final topTracks = topTracksQuery.asData?.value ?? + List.generate(10, (index) => FakeData.track); void playPlaylist(List tracks, {Track? currentTrack}) async { currentTrack ??= tracks.first; - if (!isPlaylistPlaying) { - playlistNotifier.load( - tracks, - initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), - autoPlay: true, - ); - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != playlist.activeTrack?.id) { - await playlistNotifier.jumpToTrack(currentTrack); + + 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 != null && + 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 != null && + currentTrack.id != playlist.activeTrack?.id) { + await playlistNotifier.jumpToTrack(currentTrack); + } } } @@ -65,52 +92,53 @@ class ArtistPageTopTracks extends HookConsumerWidget { padding: const EdgeInsets.all(8.0), child: Text( context.l10n.top_tracks, - style: theme.textTheme.headlineSmall, + style: theme.typography.h4, ), ), if (!isPlaylistPlaying) - IconButton( + IconButton.outline( icon: const Icon( SpotubeIcons.queueAdd, ), onPressed: () { playlistNotifier.addTracks(topTracks.toList()); - scaffoldMessenger.showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - context.l10n.added_to_queue( - topTracks.length, + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + child: Text( + context.l10n.added_to_queue( + topTracks.length, + ), ), - textAlign: TextAlign.center, - ), - ), + ); + }, ); }, ), const SizedBox(width: 5), - IconButton( + IconButton.primary( + shape: ButtonShape.circle, + enabled: !isPlaylistPlaying, icon: Skeleton.keep( child: Icon( - isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play, - color: Colors.white, + isPlaylistPlaying ? SpotubeIcons.pause : SpotubeIcons.play, ), ), - style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.primary, - ), 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( diff --git a/lib/pages/connect/connect.dart b/lib/pages/connect/connect.dart new file mode 100644 index 00000000..bb8bbfae --- /dev/null +++ b/lib/pages/connect/connect.dart @@ -0,0 +1,89 @@ +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 new file mode 100644 index 00000000..e28566fd --- /dev/null +++ b/lib/pages/connect/control/control.dart @@ -0,0 +1,391 @@ +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' 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/models/connect/connect.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/extensions/image.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/desktop_login/desktop_login.dart b/lib/pages/desktop_login/desktop_login.dart deleted file mode 100644 index c2cc3695..00000000 --- a/lib/pages/desktop_login/desktop_login.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/components/desktop_login/login_form.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; - -class DesktopLoginPage extends HookConsumerWidget { - const DesktopLoginPage({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final mediaQuery = MediaQuery.of(context); - final theme = Theme.of(context); - final color = theme.colorScheme.surfaceVariant.withOpacity(.3); - - return SafeArea( - child: Scaffold( - appBar: const PageWindowTitleBar( - leading: BackButton(), - ), - body: SingleChildScrollView( - child: Center( - child: Container( - margin: const EdgeInsets.all(10), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(10), - ), - child: Column( - children: [ - Assets.spotubeLogoPng.image( - width: MediaQuery.of(context).size.width * - (mediaQuery.mdAndDown ? .5 : .3), - ), - Text( - context.l10n.add_spotify_credentials, - style: theme.textTheme.titleMedium, - ), - Text( - context.l10n.credentials_will_not_be_shared_disclaimer, - style: theme.textTheme.labelMedium, - ), - const SizedBox(height: 10), - TokenLoginForm( - onDone: () => GoRouter.of(context).go("/"), - ), - const SizedBox(height: 10), - Wrap( - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text(context.l10n.know_how_to_login), - TextButton( - child: Text( - context.l10n.follow_step_by_step_guide, - ), - onPressed: () => GoRouter.of(context).push( - "/login-tutorial", - ), - ), - ], - ), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/pages/desktop_login/login_tutorial.dart b/lib/pages/desktop_login/login_tutorial.dart deleted file mode 100644 index 24373e75..00000000 --- a/lib/pages/desktop_login/login_tutorial.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:introduction_screen/introduction_screen.dart'; - -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/components/desktop_login/login_form.dart'; -import 'package:spotube/components/shared/links/hyper_link.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/utils/service_utils.dart'; - -class LoginTutorial extends ConsumerWidget { - const LoginTutorial({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - ref.watch(AuthenticationNotifier.provider); - final authenticationNotifier = - ref.watch(AuthenticationNotifier.provider.notifier); - final key = GlobalKey>(); - final theme = Theme.of(context); - - final pageDecoration = PageDecoration( - bodyTextStyle: theme.textTheme.bodyMedium!, - titleTextStyle: theme.textTheme.headlineMedium!, - ); - return Scaffold( - appBar: PageWindowTitleBar( - leading: TextButton( - child: Text(context.l10n.exit), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ), - body: IntroductionScreen( - key: key, - globalBackgroundColor: theme.scaffoldBackgroundColor, - overrideBack: OutlinedButton( - child: Center(child: Text(context.l10n.previous)), - onPressed: () { - (key.currentState as IntroductionScreenState).previous(); - }, - ), - overrideNext: FilledButton( - child: Center(child: Text(context.l10n.next)), - onPressed: () { - (key.currentState as IntroductionScreenState).next(); - }, - ), - showBackButton: true, - overrideDone: FilledButton( - onPressed: authenticationNotifier.isLoggedIn - ? () { - ServiceUtils.push(context, "/"); - } - : null, - child: Center(child: Text(context.l10n.done)), - ), - pages: [ - PageViewModel( - decoration: pageDecoration, - title: context.l10n.step_1, - image: Assets.tutorial.step1.image(), - bodyWidget: Wrap( - children: [ - Text(context.l10n.first_go_to), - const SizedBox(width: 5), - const Hyperlink( - "accounts.spotify.com ", - "https://accounts.spotify.com", - ), - Text(context.l10n.login_if_not_logged_in), - ], - ), - ), - PageViewModel( - decoration: pageDecoration, - title: context.l10n.step_2, - image: Assets.tutorial.step2.image(), - bodyWidget: - Text(context.l10n.step_2_steps, textAlign: TextAlign.left), - ), - PageViewModel( - decoration: pageDecoration, - title: context.l10n.step_3, - image: Assets.tutorial.step3.image(), - bodyWidget: - Text(context.l10n.step_3_steps, textAlign: TextAlign.left), - ), - if (authenticationNotifier.isLoggedIn) - PageViewModel( - decoration: pageDecoration.copyWith( - bodyAlignment: Alignment.center, - ), - title: context.l10n.success_emoji, - image: Assets.success.image(), - body: context.l10n.success_message, - ) - else - PageViewModel( - decoration: pageDecoration, - title: context.l10n.step_4, - bodyWidget: Column( - children: [ - Text( - context.l10n.step_4_steps, - style: theme.textTheme.labelMedium, - ), - const SizedBox(height: 10), - TokenLoginForm( - onDone: () { - GoRouter.of(context).go("/"); - }, - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/pages/getting_started/getting_started.dart b/lib/pages/getting_started/getting_started.dart index cbab03b9..a576ed09 100644 --- a/lib/pages/getting_started/getting_started.dart +++ b/lib/pages/getting_started/getting_started.dart @@ -1,27 +1,23 @@ -import 'package:flutter/material.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/assets.gen.dart'; -import 'package:spotube/components/shared/page_window_title_bar.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:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/themes/theme.dart'; +import 'package:auto_route/auto_route.dart'; -class GettingStarting extends HookConsumerWidget { - const GettingStarting({super.key}); +@RoutePage() +class GettingStartedPage extends HookConsumerWidget { + static const name = "getting_started"; + + const GettingStartedPage({super.key}); @override Widget build(BuildContext context, ref) { - final preferences = ref.watch(userPreferencesProvider); - final themeData = theme( - preferences.accentColorScheme, - Brightness.dark, - preferences.amoledDarkTheme, - ); final pageController = usePageController(); final onNext = useCallback(() { @@ -38,66 +34,59 @@ class GettingStarting extends HookConsumerWidget { ); }, [pageController]); - return Theme( - data: themeData, - child: Scaffold( - appBar: PageWindowTitleBar( - backgroundColor: Colors.transparent, - actions: [ - ListenableBuilder( - listenable: pageController, - builder: (context, _) { - return AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: pageController.hasClients && - (pageController.page == 0 || pageController.page == 3) - ? const SizedBox() - : TextButton( - onPressed: () { - pageController.animateToPage( - 3, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - child: Text( - context.l10n.skip_this_nonsense, - style: TextStyle( - decoration: TextDecoration.underline, - decorationColor: themeData.colorScheme.primary, - ), + 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), ), - ), - ); - }, - ), - ], - ), - extendBodyBehindAppBar: true, - body: DecoratedBox( - decoration: BoxDecoration( - image: DecorationImage( - image: Assets.bengaliPatternsBg.provider(), - fit: BoxFit.cover, - colorFilter: const ColorFilter.mode( - Colors.black38, - BlendMode.srcOver, + ); + }, ), - ), - ), - child: PageView( - controller: pageController, - children: [ - GettingStartedPageGreetingSection(onNext: onNext), - GettingStartedPageLanguageRegionSection(onNext: onNext), - GettingStartedPagePlaybackSection( - onNext: onNext, - onPrevious: onPrevious, - ), - const GettingStartedScreenSupportSection(), ], ), ), + ], + floatingHeader: true, + child: DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: Assets.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 index 563e43de..4b9c0a89 100644 --- a/lib/pages/getting_started/sections/greeting.dart +++ b/lib/pages/getting_started/sections/greeting.dart @@ -1,10 +1,9 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.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/collections/spotube_icons.dart'; -import 'package:spotube/components/getting_started/blur_card.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 { @@ -13,8 +12,6 @@ class GettingStartedPageGreetingSection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme) = Theme.of(context); - return Center( child: BlurCard( child: Column( @@ -22,30 +19,19 @@ class GettingStartedPageGreetingSection extends HookConsumerWidget { children: [ Assets.spotubeLogoPng.image(height: 200), const Gap(24), - Text( - "Spotube", - style: - textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), + const Text("Spotube").semiBold().h4(), const Gap(4), Text( kIsMobile ? context.l10n.freedom_of_music_palm : context.l10n.freedom_of_music, textAlign: TextAlign.center, - style: textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w300, - fontStyle: FontStyle.italic, - ), - ), + ).light().large().italic(), const Gap(84), - Directionality( - textDirection: TextDirection.rtl, - child: FilledButton.icon( - onPressed: onNext, - icon: const Icon(SpotubeIcons.angleRight), - label: Text(context.l10n.get_started), - ), + 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 index 298cf839..f122dbcd 100644 --- a/lib/pages/getting_started/sections/playback.dart +++ b/lib/pages/getting_started/sections/playback.dart @@ -1,23 +1,27 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.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/collections/spotube_icons.dart'; -import 'package:spotube/components/getting_started/blur_card.dart'; +import 'package:spotube/components/ui/button_tile.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/modules/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/string.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; final audioSourceToIconMap = { AudioSource.youtube: const Icon( SpotubeIcons.youtube, color: Colors.red, - size: 30, + size: 20, ), - AudioSource.piped: const Icon(SpotubeIcons.piped, size: 30), - AudioSource.jiosaavn: Assets.jiosaavn.image(width: 48, height: 48), + AudioSource.piped: const Icon(SpotubeIcons.piped, size: 20), + AudioSource.invidious: ClipRRect( + borderRadius: BorderRadius.circular(26), + child: Assets.invidious.image(width: 26, height: 26), + ), + AudioSource.jiosaavn: Assets.jiosaavn.image(width: 20, height: 20), }; class GettingStartedPagePlaybackSection extends HookConsumerWidget { @@ -32,8 +36,6 @@ class GettingStartedPagePlaybackSection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme, :colorScheme, :dividerColor) = - Theme.of(context); final preferences = ref.watch(userPreferencesProvider); final preferencesNotifier = ref.read(userPreferencesProvider.notifier); @@ -45,6 +47,7 @@ class GettingStartedPagePlaybackSection extends HookConsumerWidget { AudioSource.jiosaavn: "${context.l10n.jiosaavn_source_description}\n" "${context.l10n.highest_quality("320kbps mp")}", + AudioSource.invidious: context.l10n.invidious_source_description, }, []); @@ -57,74 +60,64 @@ class GettingStartedPagePlaybackSection extends HookConsumerWidget { children: [ const Icon(SpotubeIcons.album, size: 16), const Gap(8), - Text(context.l10n.playback, style: textTheme.titleMedium), + Text(context.l10n.playback).semiBold().large(), ], ), const Gap(16), - ListTile( - title: Text( - context.l10n.select_audio_source, - style: textTheme.titleMedium, - ), + Align( + alignment: Alignment.centerLeft, + child: Text(context.l10n.select_audio_source).semiBold().large(), ), const Gap(16), - ToggleButtons( - isSelected: [ - for (final source in AudioSource.values) - preferences.audioSource == source, - ], - onPressed: (index) { - preferencesNotifier.setAudioSource(AudioSource.values[index]); + Select( + value: preferences.audioSource, + onChanged: (value) { + if (value == null) return; + preferencesNotifier.setAudioSource(value); }, - borderRadius: BorderRadius.circular(8), - children: [ - for (final source in AudioSource.values) - SizedBox.square( - dimension: 84, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - audioSourceToIconMap[source]!, - const Gap(8), - Text( - source.name.capitalize(), - style: textTheme.bodySmall!.copyWith( - color: preferences.audioSource == source - ? colorScheme.primary - : null, - ), - ), - ], - ), - ), - ], - ), - ListTile( - title: Align( - alignment: switch (preferences.audioSource) { - AudioSource.youtube => Alignment.centerLeft, - AudioSource.piped => Alignment.center, - AudioSource.jiosaavn => Alignment.centerRight, - }, - child: Text( - audioSourceToDescription[preferences.audioSource]!, - style: textTheme.bodySmall?.copyWith( - color: dividerColor, - ), - ), + placeholder: Text(preferences.audioSource.name.capitalize()), + itemBuilder: (context, value) => Row( + mainAxisSize: MainAxisSize.min, + spacing: 6, + children: [ + audioSourceToIconMap[value]!, + Text(value.name.capitalize()), + ], ), + popup: (context) { + return SelectPopup( + items: SelectItemBuilder( + childCount: AudioSource.values.length, + builder: (context, index) { + final source = AudioSource.values[index]; + + return SelectItemButton( + value: source, + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 6, + children: [ + audioSourceToIconMap[source]!, + Text(source.name.capitalize()), + ], + ), + ); + }, + ), + ); + }, ), const Gap(16), - ListTile( + Text( + audioSourceToDescription[preferences.audioSource]!, + ).small().muted(), + const Gap(16), + ButtonTile( title: Text(context.l10n.endless_playback), subtitle: Text( context.l10n.endless_playback_description, - style: textTheme.bodySmall?.copyWith( - color: dividerColor, - ), - ), - onTap: () { + ).small().muted(), + onPressed: () { preferencesNotifier .setEndlessPlayback(!preferences.endlessPlayback); }, @@ -139,17 +132,17 @@ class GettingStartedPagePlaybackSection extends HookConsumerWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - FilledButton.icon( - icon: const Icon(SpotubeIcons.angleLeft), - label: Text(context.l10n.previous), + Button.secondary( + leading: const Icon(SpotubeIcons.angleLeft), onPressed: onPrevious, + child: Text(context.l10n.previous), ), Directionality( textDirection: TextDirection.rtl, - child: FilledButton.icon( - icon: const Icon(SpotubeIcons.angleRight), - label: Text(context.l10n.next), + 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 index 9303392c..f657f9d9 100644 --- a/lib/pages/getting_started/sections/region.dart +++ b/lib/pages/getting_started/sections/region.dart @@ -1,10 +1,10 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/collections/language_codes.dart'; import 'package:spotube/collections/spotify_markets.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/getting_started/blur_card.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'; @@ -14,9 +14,24 @@ class GettingStartedPageLanguageRegionSection extends HookConsumerWidget { const GettingStartedPageLanguageRegionSection( {super.key, required this.onNext}); + bool filterMarkets(Market item, String query) { + final market = spotifyMarkets + .firstWhere((element) => element.$1 == item) + .$2 + .toLowerCase(); + + return market.contains(query.toLowerCase()); + } + + bool filterLocale(Locale locale, String query) { + final language = + LanguageLocals.getDisplayLanguage(locale.languageCode).toString(); + + return language.toLowerCase().contains(query.toLowerCase()); + } + @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme, :dividerColor) = Theme.of(context); final preferences = ref.watch(userPreferencesProvider); return SafeArea( @@ -32,92 +47,141 @@ class GettingStartedPageLanguageRegionSection extends HookConsumerWidget { size: 16, ), const SizedBox(width: 8), - Text( - context.l10n.language_region, - style: textTheme.titleMedium, - ), + Text(context.l10n.language_region).semiBold(), ], ), - const Gap(48), + const Gap(30), Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( - context.l10n.choose_your_region, - style: textTheme.titleSmall, - ), + Text(context.l10n.choose_your_region).semiBold(), Text( context.l10n.choose_your_region_description, - style: textTheme.bodySmall?.copyWith( - color: dividerColor, - ), - ), + ).small().muted(), const Gap(16), - DropdownMenu( - initialSelection: preferences.recommendationMarket, - onSelected: (value) { - if (value == null) return; - ref - .read(userPreferencesProvider.notifier) - .setRecommendationMarket(value); - }, - hintText: preferences.recommendationMarket.name, - label: Text(context.l10n.market_place_region), - inputDecorationTheme: - const InputDecorationTheme(isDense: true), - dropdownMenuEntries: [ - for (final market in spotifyMarkets) - DropdownMenuEntry( - value: market.$1, - label: market.$2, - ), - ], + 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( + spotifyMarkets + .firstWhere((element) => element.$1 == value) + .$2, + ), + popup: SelectPopup.builder( + searchPlaceholder: Text(context.l10n.search), + builder: (context, searchQuery) { + final filteredMarkets = searchQuery == null || + searchQuery.isEmpty + ? spotifyMarkets + : spotifyMarkets + .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, - style: textTheme.titleSmall, - ), + ).semiBold(), const Gap(16), - DropdownMenu( - initialSelection: preferences.locale, - onSelected: (locale) { - if (locale == null) return; - ref - .read(userPreferencesProvider.notifier) - .setLocale(locale); - }, - hintText: context.l10n.system_default, - label: Text(context.l10n.language), - inputDecorationTheme: - const InputDecorationTheme(isDense: true), - dropdownMenuEntries: [ - DropdownMenuEntry( - value: const Locale("system", "system"), - label: context.l10n.system_default, - ), - for (final locale in L10n.all) - DropdownMenuEntry( - value: locale, - label: LanguageLocals.getDisplayLanguage( - locale.languageCode) - .toString(), - ), - ], + 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) + .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, + ).toString(), + ), + ); + }, + ); + }, + ).call, + ), ), ], ), const Gap(48), Align( alignment: Alignment.centerRight, - child: Directionality( - textDirection: TextDirection.rtl, - child: FilledButton.icon( - icon: const Icon(SpotubeIcons.angleRight), - label: Text(context.l10n.next), - onPressed: onNext, - ), + 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 index 46823425..9559d28d 100644 --- a/lib/pages/getting_started/sections/support.dart +++ b/lib/pages/getting_started/sections/support.dart @@ -1,10 +1,12 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; -import 'package:go_router/go_router.dart'; +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/components/getting_started/blur_card.dart'; +import 'package:spotube/modules/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/mobile_login/hooks/login_callback.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -13,7 +15,7 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme, :colorScheme) = Theme.of(context); + final onLogin = useLoginCallback(ref); return Center( child: Column( @@ -30,9 +32,8 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { const SizedBox(width: 8), Text( context.l10n.help_project_grow, - style: - textTheme.titleMedium?.copyWith(color: Colors.pink), - ), + style: const TextStyle(color: Colors.pink), + ).semiBold(), ], ), const Gap(16), @@ -42,38 +43,59 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - FilledButton.icon( - icon: const Icon(SpotubeIcons.github), - label: Text(context.l10n.contribute_on_github), - style: FilledButton.styleFrom( - backgroundColor: Colors.black, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( + 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, ); }, - ), - const Gap(16), - FilledButton.icon( - icon: const Icon(SpotubeIcons.openCollective), - label: Text(context.l10n.donate_on_open_collective), - style: FilledButton.styleFrom( - backgroundColor: const Color(0xff4cb7f6), - foregroundColor: Colors.white, + child: Text( + context.l10n.contribute_on_github, + style: const TextStyle(color: Colors.white), ), - onPressed: () async { - await launchUrlString( - "https://opencollective.com/spotube", - mode: LaunchMode.externalApplication, - ); - }, ), + 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), + ), + ), + ] ], ), ], @@ -85,44 +107,40 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - gradient: LinearGradient( - colors: [ - colorScheme.primary, - colorScheme.secondary, - ], - ), - ), - child: TextButton.icon( - icon: const Icon(SpotubeIcons.anonymous), - label: Text(context.l10n.browse_anonymously), - style: TextButton.styleFrom( - foregroundColor: Colors.white, - ), - onPressed: () async { - await KVStoreService.setDoneGettingStarted(true); - if (context.mounted) { - context.go("/"); - } - }, - ), - ), - const Gap(16), - FilledButton.icon( - icon: const Icon(SpotubeIcons.spotify), - label: Text(context.l10n.connect_with_spotify), - style: FilledButton.styleFrom( - backgroundColor: const Color(0xff1db954), - foregroundColor: Colors.white, - ), + Button.secondary( + leading: const Icon(SpotubeIcons.anonymous), onPressed: () async { await KVStoreService.setDoneGettingStarted(true); if (context.mounted) { - context.push("/login"); + context.navigateTo(const HomeRoute()); } }, + child: Text(context.l10n.browse_anonymously), + ), + const Gap(16), + Button.primary( + leading: const Icon(SpotubeIcons.spotify), + style: ButtonVariance.primary.copyWith( + decoration: (context, states, value) { + if (states.isNotEmpty) { + return ButtonVariance.primary + .decoration(context, states); + } + + return BoxDecoration( + color: const Color(0xff1db954), + borderRadius: BorderRadius.circular(8), + ); + }, + ), + onPressed: () async { + await KVStoreService.setDoneGettingStarted(true); + await onLogin(); + }, + child: Text( + context.l10n.connect_with_spotify, + style: const TextStyle(color: Colors.white), + ), ), ], ), diff --git a/lib/pages/home/feed/feed_section.dart b/lib/pages/home/feed/feed_section.dart new file mode 100644 index 00000000..2b38d0ed --- /dev/null +++ b/lib/pages/home/feed/feed_section.dart @@ -0,0 +1,101 @@ +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:skeletonizer/skeletonizer.dart'; +import 'package:spotube/collections/fake.dart'; +import 'package:spotube/components/playbutton_view/playbutton_view.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/spotify/views/home_section.dart'; + +@RoutePage() +class HomeFeedSectionPage extends HookConsumerWidget { + static const name = "home_feed_section"; + + final String sectionUri; + const HomeFeedSectionPage({ + super.key, + @PathParam("feedId") required this.sectionUri, + }); + + @override + Widget build(BuildContext context, ref) { + final homeFeedSection = ref.watch(homeSectionViewProvider(sectionUri)); + final section = homeFeedSection.asData?.value ?? FakeData.feedSection; + final controller = useScrollController(); + final isArtist = section.items.every((item) => item.artist != null); + + return SafeArea( + bottom: false, + child: Skeletonizer( + enabled: homeFeedSection.isLoading, + child: Scaffold( + headers: [ + TitleBar( + title: Text(section.title ?? ""), + ) + ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: CustomScrollView( + controller: controller, + slivers: [ + if (isArtist) + SliverGrid.builder( + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: 250, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: section.items.length, + itemBuilder: (context, index) { + final item = section.items[index]; + return ArtistCard(item.artist!.asArtist); + }, + ) + else + PlaybuttonView( + controller: controller, + itemCount: section.items.length, + hasMore: false, + isLoading: false, + onRequestMore: () => {}, + listItemBuilder: (context, index) { + final item = section.items[index]; + if (item.album != null) { + return AlbumCard.tile(item.album!.asAlbum); + } + if (item.playlist != null) { + return PlaylistCard.tile(item.playlist!.asPlaylist); + } + return const SizedBox.shrink(); + }, + gridItemBuilder: (context, index) { + final item = section.items[index]; + if (item.album != null) { + return AlbumCard(item.album!.asAlbum); + } + if (item.playlist != null) { + return PlaylistCard(item.playlist!.asPlaylist); + } + return const SizedBox.shrink(); + }, + ), + const SliverToBoxAdapter( + child: SafeArea( + child: SizedBox(), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/home/genres/genre_playlists.dart b/lib/pages/home/genres/genre_playlists.dart index bfb0843c..ea421cb4 100644 --- a/lib/pages/home/genres/genre_playlists.dart +++ b/lib/pages/home/genres/genre_playlists.dart @@ -1,166 +1,143 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show CollapseMode, FlexibleSpaceBar; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotify/spotify.dart' hide Offset; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/components/playlist/playlist_card.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/shared/waypoint.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.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:spotify/spotify.dart' hide Offset; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/components/button/back_button.dart'; +import 'package:spotube/components/playbutton_view/playbutton_view.dart'; +import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart'; +import 'package:spotube/modules/playlist/playlist_card.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:auto_route/auto_route.dart'; + +@RoutePage() class GenrePlaylistsPage extends HookConsumerWidget { + static const name = "genre_playlists"; + final Category category; - const GenrePlaylistsPage({super.key, required this.category}); + final String id; + const GenrePlaylistsPage({ + super.key, + @PathParam("categoryId") required this.id, + required this.category, + }); @override Widget build(BuildContext context, ref) { - final playlistsQuery = useQueries.category.playlistsOf( - ref, - category.id!, - ); - - final playlists = useMemoized( - () => playlistsQuery.pages.expand( - (page) { - return page.items?.whereNotNull() ?? - const Iterable.empty(); - }, - ).toList(), - [playlistsQuery.pages], - ); - final mediaQuery = MediaQuery.of(context); - + final playlists = ref.watch(categoryPlaylistsProvider(category.id!)); + final playlistsNotifier = + ref.read(categoryPlaylistsProvider(category.id!).notifier); final scrollController = useScrollController(); - return Scaffold( - appBar: DesktopTools.platform.isDesktop - ? const PageWindowTitleBar( - leading: BackButton(color: Colors.white), + useCustomStatusBarColor( + Colors.black, + context.watchRouter.topRoute.name == GenrePlaylistsRoute.name, + noSetBGColor: true, + automaticSystemUiAdjustment: false, + ); + + return SafeArea( + child: Scaffold( + headers: [ + if (kIsDesktop) + const TitleBar( + leading: [ + BackButton(), + ], backgroundColor: Colors.transparent, - foregroundColor: Colors.white, + surfaceOpacity: 0, + surfaceBlur: 0, ) - : null, - extendBodyBehindAppBar: true, - body: DecoratedBox( - decoration: BoxDecoration( - image: DecorationImage( - image: UniversalImage.imageProvider(category.icons!.first.url!), - alignment: Alignment.topCenter, - fit: BoxFit.cover, - colorFilter: ColorFilter.mode( - Colors.black.withOpacity(0.5), - BlendMode.darken, + ], + floatingHeader: true, + child: DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: UniversalImage.imageProvider(category.icons!.first.url!), + alignment: Alignment.topCenter, + fit: BoxFit.cover, + repeat: ImageRepeat.noRepeat, + matchTextDirection: true, ), - repeat: ImageRepeat.noRepeat, - matchTextDirection: true, ), - ), - child: CustomScrollView( - controller: scrollController, - slivers: [ - SliverAppBar( - automaticallyImplyLeading: DesktopTools.platform.isMobile, - expandedHeight: mediaQuery.mdAndDown ? 200 : 150, - title: const Text(""), - backgroundColor: Colors.transparent, - flexibleSpace: FlexibleSpaceBar( - centerTitle: DesktopTools.platform.isDesktop, - title: Text( - category.name!, - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: Colors.white, - letterSpacing: 3, - shadows: [ - const Shadow( - offset: Offset(-1.5, -1.5), - color: Colors.black54, + child: SurfaceCard( + borderRadius: BorderRadius.zero, + padding: EdgeInsets.zero, + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverSafeArea( + bottom: false, + sliver: SliverAppBar( + automaticallyImplyLeading: false, + leading: kIsMobile ? const BackButton() : null, + expandedHeight: mediaQuery.mdAndDown ? 200 : 150, + title: const Text(""), + backgroundColor: Colors.transparent, + flexibleSpace: FlexibleSpaceBar( + centerTitle: kIsDesktop, + title: Text( + category.name!, + style: context.theme.typography.h3.copyWith( + color: Colors.white, + letterSpacing: 3, + shadows: [ + Shadow( + offset: const Offset(-1.5, -1.5), + color: Colors.black.withAlpha(138), + ), + Shadow( + offset: const Offset(1.5, -1.5), + color: Colors.black.withAlpha(138), + ), + Shadow( + offset: const Offset(1.5, 1.5), + color: Colors.black.withAlpha(138), + ), + Shadow( + offset: const Offset(-1.5, 1.5), + color: Colors.black.withAlpha(138), + ), + ], + ), ), - const Shadow( - offset: Offset(1.5, -1.5), - color: Colors.black54, - ), - const Shadow( - offset: Offset(1.5, 1.5), - color: Colors.black54, - ), - const Shadow( - offset: Offset(-1.5, 1.5), - color: Colors.black54, - ), - ], + collapseMode: CollapseMode.parallax, + ), ), ), - collapseMode: CollapseMode.parallax, - ), - ), - const SliverGap(20), - SliverSafeArea( - top: false, - sliver: SliverPadding( - padding: EdgeInsets.symmetric( - horizontal: mediaQuery.mdAndDown ? 12 : 24, + const SliverGap(20), + SliverSafeArea( + top: false, + sliver: SliverPadding( + padding: EdgeInsets.symmetric( + horizontal: mediaQuery.mdAndDown ? 12 : 24, + ), + sliver: PlaybuttonView( + controller: scrollController, + itemCount: playlists.asData?.value.items.length ?? 0, + isLoading: playlists.isLoading, + hasMore: playlists.asData?.value.hasMore == true, + onRequestMore: playlistsNotifier.fetchMore, + listItemBuilder: (context, index) => PlaylistCard.tile( + playlists.asData!.value.items[index]), + gridItemBuilder: (context, index) => + PlaylistCard(playlists.asData!.value.items[index]), + ), + ), ), - sliver: playlists.isEmpty - ? Skeletonizer.sliver( - child: SliverToBoxAdapter( - child: Wrap( - spacing: 12, - runSpacing: 12, - children: List.generate( - 6, - (index) => PlaylistCard(FakeData.playlist), - ), - ), - ), - ) - : SliverGrid.builder( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 190, - mainAxisExtent: mediaQuery.mdAndDown ? 225 : 250, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - ), - itemCount: playlists.length + 1, - itemBuilder: (context, index) { - final playlist = playlists.elementAtOrNull(index); - - if (playlist == null) { - if (!playlistsQuery.hasNextPage) { - return const SizedBox.shrink(); - } - return Skeletonizer( - enabled: true, - child: Waypoint( - controller: scrollController, - isGrid: true, - onTouchEdge: () async { - if (playlistsQuery.hasNextPage) { - await playlistsQuery.fetchNext(); - } - }, - child: PlaylistCard(FakeData.playlist), - ), - ); - } - - return Skeleton.keep( - child: PlaylistCard(playlist), - ); - }, - ), - ), + const SliverGap(20), + ], ), - const SliverGap(20), - ], + ), ), ), ); diff --git a/lib/pages/home/genres/genres.dart b/lib/pages/home/genres/genres.dart index 17a67beb..38d110db 100644 --- a/lib/pages/home/genres/genres.dart +++ b/lib/pages/home/genres/genres.dart @@ -1,43 +1,39 @@ import 'dart:math'; import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart'; + import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart' hide Offset; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/collections/gradients.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:auto_route/auto_route.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; - +@RoutePage() class GenrePage extends HookConsumerWidget { + static const name = "genre"; const GenrePage({super.key}); @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme) = Theme.of(context); final scrollController = useScrollController(); - final recommendationMarket = ref.watch( - userPreferencesProvider.select((s) => s.recommendationMarket), - ); - final categoriesQuery = - useQueries.category.listAll(ref, recommendationMarket); - - final categories = categoriesQuery.data ?? []; + final categories = ref.watch(categoriesProvider); final mediaQuery = MediaQuery.of(context); - return Scaffold( - appBar: PageWindowTitleBar( - title: Text(context.l10n.explore_genres), - automaticallyImplyLeading: true, - ), - body: SafeArea( - top: false, + return SafeArea( + child: Scaffold( + headers: [ + TitleBar( + title: Text(context.l10n.explore_genres), + ) + ], child: GridView.builder( padding: const EdgeInsets.all(12), controller: scrollController, @@ -48,46 +44,58 @@ class GenrePage extends HookConsumerWidget { crossAxisSpacing: 12, mainAxisSpacing: 12, ), - itemCount: categories.length, + itemCount: categories.asData!.value.length, itemBuilder: (context, index) { - final category = categories[index]; + final category = categories.asData!.value[index]; final gradient = gradients[Random().nextInt(gradients.length)]; - return InkWell( - borderRadius: BorderRadius.circular(8), - onTap: () { - context.push("/genre/${category.id}", extra: category); + return CardImage( + onPressed: () { + context.navigateTo( + GenrePlaylistsRoute( + id: category.id!, + category: category, + ), + ); }, - child: Ink( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - image: DecorationImage( - image: NetworkImage(category.icons!.first.url!), - fit: BoxFit.cover, - ), - gradient: gradient, - ), - child: Align( - alignment: Alignment.bottomCenter, - child: AutoSizeText( - category.name!, - style: textTheme.titleLarge?.copyWith( - color: Colors.white, - shadows: [ - // stroke shadow - const Shadow( - color: Colors.black, - offset: Offset(1, 1), - blurRadius: 2, - ), - ], + image: Stack( + children: [ + Container( + height: 300, + width: 250, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: DecorationImage( + image: NetworkImage(category.icons!.first.url!), + fit: BoxFit.cover, + ), + gradient: gradient, ), - maxLines: 1, - textAlign: TextAlign.center, - maxFontSize: textTheme.titleLarge!.fontSize!, - minFontSize: textTheme.titleMedium!.fontSize!, ), - ), + Positioned.fill( + bottom: 10, + child: Align( + alignment: Alignment.bottomCenter, + child: AutoSizeText( + category.name!, + style: context.theme.typography.h3.copyWith( + color: Colors.white, + shadows: [ + // stroke shadow + const Shadow( + color: Colors.black, + offset: Offset(1, 1), + blurRadius: 2, + ), + ], + ), + maxLines: 1, + textAlign: TextAlign.center, + maxFontSize: context.theme.typography.h3.fontSize!, + minFontSize: context.theme.typography.large.fontSize!, + ), + ), + ), + ], ), ); }, diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 312ca7f9..1ef81027 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -1,38 +1,94 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/home/sections/featured.dart'; -import 'package:spotube/components/home/sections/friends.dart'; -import 'package:spotube/components/home/sections/genres.dart'; -import 'package:spotube/components/home/sections/made_for_user.dart'; -import 'package:spotube/components/home/sections/new_releases.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'dart:ui'; +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/modules/connect/connect_device.dart'; +import 'package:spotube/modules/home/sections/featured.dart'; +import 'package:spotube/modules/home/sections/feed.dart'; +import 'package:spotube/modules/home/sections/friends.dart'; +import 'package:spotube/modules/home/sections/genres/genres.dart'; +import 'package:spotube/modules/home/sections/made_for_user.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 { - const HomePage({Key? key}) : super(key: key); + 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( - appBar: - DesktopTools.platform.isLinux || DesktopTools.platform.isWindows - ? const PageWindowTitleBar() - : null, - body: CustomScrollView( + headers: [ + if (kTitlebarVisible) const TitleBar(height: 30), + ], + child: CustomScrollView( controller: controller, slivers: [ - if (DesktopTools.platform.isMacOS || DesktopTools.platform.isWeb) - const SliverGap(20), - const HomeGenresSection(), - const SliverToBoxAdapter(child: HomeFeaturedSection()), - const HomePageFriendsSection(), - const SliverToBoxAdapter(child: HomeNewReleasesSection()), + if (mediaQuery.smAndDown || layoutMode == LayoutMode.compact) + SliverAppBar( + floating: true, + title: Image.asset( + theme.brightness == Brightness.dark + ? Assets.spotubeLogoPng.path + : Assets.spotubeLogoLight.path, + height: 45, + width: 45, + color: theme.colorScheme.background, + colorBlendMode: BlendMode.saturation, + cacheHeight: + (100 * MediaQuery.devicePixelRatioOf(context)).toInt(), + ), + backgroundColor: context.theme.colorScheme.background, + foregroundColor: context.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: 5, + itemBuilder: (context, index) { + return switch (index) { + 0 => const HomeGenresSection(), + 1 => const HomeRecentlyPlayedSection(), + 2 => const HomeFeaturedSection(), + 3 => const HomePageFriendsSection(), + _ => const HomeNewReleasesSection() + }; + }, + ), + const HomePageFeedSection(), const SliverSafeArea(sliver: HomeMadeForUserSection()), ], ), diff --git a/lib/pages/lastfm_login/lastfm_login.dart b/lib/pages/lastfm_login/lastfm_login.dart index 4280328f..164b9b0d 100644 --- a/lib/pages/lastfm_login/lastfm_login.dart +++ b/lib/pages/lastfm_login/lastfm_login.dart @@ -1,139 +1,156 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:form_validator/form_validator.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/scrobbler_provider.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 { - const LastFMLoginPage({Key? key}) : super(key: key); + static const name = "lastfm_login"; + const LastFMLoginPage({super.key}); @override Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final router = GoRouter.of(context); final scrobblerNotifier = ref.read(scrobblerProvider.notifier); - final formKey = useMemoized(() => GlobalKey(), []); - final username = useTextEditingController(); - final password = useTextEditingController(); + final usernameKey = + useMemoized(() => const FormKey("username"), []); + final passwordKey = + useMemoized(() => const FormKey("password"), []); + final passwordVisible = useState(false); final isLoading = useState(false); return Scaffold( - appBar: const PageWindowTitleBar(leading: BackButton()), - body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Card( - margin: const EdgeInsets.all(8.0), - child: Padding( - padding: const EdgeInsets.all(16.0).copyWith(top: 8), - child: Form( - key: formKey, - autovalidateMode: AutovalidateMode.onUserInteraction, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(30), - color: const Color.fromARGB(255, 186, 0, 0), + 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, + ), ), - padding: const EdgeInsets.all(12), - child: const Icon( - SpotubeIcons.lastFm, - color: Colors.white, - size: 60, - ), - ), - Text( - "last.fm", - style: theme.textTheme.titleLarge, - ), - const SizedBox(height: 10), - Text(context.l10n.login_with_your_lastfm), - const SizedBox(height: 10), - AutofillGroup( - child: Column( - children: [ - TextFormField( - autofillHints: const [ - AutofillHints.username, - AutofillHints.email, - ], - controller: username, - validator: ValidationBuilder().required().build(), - decoration: InputDecoration( - labelText: context.l10n.username, - ), - ), - const SizedBox(height: 10), - TextFormField( - autofillHints: const [ - AutofillHints.password, - ], - controller: password, - validator: ValidationBuilder().required().build(), - obscureText: !passwordVisible.value, - decoration: InputDecoration( - labelText: context.l10n.password, - suffixIcon: IconButton( - icon: Icon( - passwordVisible.value - ? SpotubeIcons.eye - : SpotubeIcons.noEye, - ), - onPressed: () => passwordVisible.value = - !passwordVisible.value, + 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, + ), + ), + ], + ), + ), + ], + ), ), - ), - const SizedBox(height: 10), - FilledButton( - onPressed: isLoading.value - ? null - : () async { - try { - isLoading.value = true; - if (formKey.currentState?.validate() != true) { - return; - } - await scrobblerNotifier.login( - username.text.trim(), - password.text, - ); - router.pop(); - } catch (e) { - if (context.mounted) { - showPromptDialog( - context: context, - title: context.l10n - .error("Authentication failed"), - message: e.toString(), - cancelText: null, - ); - } - } finally { - isLoading.value = false; - } - }, - child: Text(context.l10n.login), - ), - ], + 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 index b6b88656..172d9af3 100644 --- a/lib/pages/library/library.dart +++ b/lib/pages/library/library.dart @@ -1,56 +1,87 @@ -import 'package:flutter/material.dart' hide Image; +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:spotube/components/library/user_local_tracks.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/library/user_albums.dart'; -import 'package:spotube/components/library/user_artists.dart'; -import 'package:spotube/components/library/user_downloads.dart'; -import 'package:spotube/components/library/user_playlists.dart'; -import 'package:spotube/components/shared/themed_button_tab_bar.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({Key? key}) : super(key: key); + const LibraryPage({super.key}); + @override Widget build(BuildContext context, ref) { final downloadingCount = ref.watch(downloadManagerProvider).$downloadCount; + 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 DefaultTabController( - length: 5, + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + context.navigateTo(const HomeRoute()); + }, child: SafeArea( bottom: false, - child: Scaffold( - appBar: PageWindowTitleBar( - centerTitle: true, - leading: ThemedButtonsTabBar( - tabs: [ - Tab(text: " ${context.l10n.playlists} "), - Tab(text: " ${context.l10n.local_tracks} "), - Tab( - child: Badge( - isLabelVisible: downloadingCount > 0, - label: Text(downloadingCount.toString()), - child: Text(" ${context.l10n.downloads} "), + 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, ), - Tab(text: " ${context.l10n.artists} "), - Tab(text: " ${context.l10n.albums} "), - ], - ), - leadingWidth: double.infinity, - ), - body: const TabBarView( - children: [ - UserPlaylists(), - UserLocalTracks(), - UserDownloads(), - UserArtists(), - UserAlbums(), + const Gap(10), ], - ), - ), + child: const AutoRouter(), + ); + }), ), ); } diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 802b28d3..f1eca306 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -1,43 +1,50 @@ import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; + import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotify_markets.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/library/playlist_generate/multi_select_field.dart'; -import 'package:spotube/components/library/playlist_generate/recommendation_attribute_dials.dart'; -import 'package:spotube/components/library/playlist_generate/recommendation_attribute_fields.dart'; -import 'package:spotube/components/library/playlist_generate/seeds_multi_autocomplete.dart'; -import 'package:spotube/components/library/playlist_generate/simple_track_tile.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/button/back_button.dart'; +import 'package:spotube/components/ui/button_tile.dart'; + +import 'package:spotube/modules/library/playlist_generate/recommendation_attribute_dials.dart'; +import 'package:spotube/modules/library/playlist_generate/recommendation_attribute_fields.dart'; +import 'package:spotube/modules/library/playlist_generate/seeds_multi_autocomplete.dart'; +import 'package:spotube/modules/library/playlist_generate/simple_track_tile.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart'; -import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/models/spotify/recommendation_seeds.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:auto_route/auto_route.dart'; const RecommendationAttribute zeroValues = (min: 0, target: 0, max: 0); +@RoutePage() class PlaylistGeneratorPage extends HookConsumerWidget { - const PlaylistGeneratorPage({Key? key}) : super(key: key); + static const name = "playlist_generator"; + + const PlaylistGeneratorPage({super.key}); @override Widget build(BuildContext context, ref) { final spotify = ref.watch(spotifyProvider); final theme = Theme.of(context); - final textTheme = theme.textTheme; + final typography = theme.typography; final preferences = ref.watch(userPreferencesProvider); - final genresCollection = useQueries.category.genreSeeds(ref); + final genresCollection = ref.watch(categoryGenresProvider); final limit = useValueNotifier(10); - final market = useValueNotifier(preferences.recommendationMarket); + final market = useValueNotifier(preferences.market); final genres = useState>([]); final artists = useState>([]); @@ -50,60 +57,45 @@ class PlaylistGeneratorPage extends HookConsumerWidget { 5 - genres.value.length - artists.value.length - tracks.value.length; // Dial (int 0-1) attributes - final acousticness = useState(zeroValues); - final danceability = useState(zeroValues); - final energy = useState(zeroValues); - final instrumentalness = useState(zeroValues); - final key = useState(zeroValues); - final liveness = useState(zeroValues); - final loudness = useState(zeroValues); - final popularity = useState(zeroValues); - final speechiness = useState(zeroValues); - final valence = useState(zeroValues); - - // Field editable attributes - final tempo = useState(zeroValues); - final durationMs = useState(zeroValues); - final mode = useState(zeroValues); - final timeSignature = useState(zeroValues); + final min = useState(RecommendationSeeds()); + final max = useState(RecommendationSeeds()); + final target = useState(RecommendationSeeds()); final artistAutoComplete = SeedsMultiAutocomplete( seeds: artists, enabled: enabled, - inputDecoration: InputDecoration( - labelText: context.l10n.artists, - labelStyle: textTheme.titleMedium, - helperText: context.l10n.select_up_to_count_type( - leftSeedCount, - context.l10n.artists, - ), - ), - fetchSeeds: (textEditingValue) => spotify.search - .get( - textEditingValue.text, - types: [SearchType.artist], - ) - .first(6) - .then( - (v) => List.castFrom( - v.expand((e) => e.items ?? []).toList(), + label: Text(context.l10n.artists), + placeholder: Text(context.l10n.select_up_to_count_type( + leftSeedCount, + context.l10n.artists, + )), + fetchSeeds: (textEditingValue) => spotify.invoke( + (api) => api.search + .get( + textEditingValue.text, + types: [SearchType.artist], ) - .where( - (element) => - artists.value.none((artist) => element.id == artist.id), - ) - .toList(), - ), - autocompleteOptionBuilder: (option, onSelected) => ListTile( - leading: CircleAvatar( - backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - option.images, + .first(6) + .then( + (v) => List.castFrom( + v.expand((e) => e.items ?? []).toList(), + ) + .where( + (element) => + artists.value.none((artist) => element.id == artist.id), + ) + .toList(), + ), + ), + autocompleteOptionBuilder: (option, onSelected) => ButtonTile( + leading: Avatar( + initials: "O", + provider: UniversalImage.imageProvider( + option.images.asUrlString( placeholder: ImagePlaceholder.artist, ), ), ), - horizontalTitleGap: 20, title: Text(option.name!), subtitle: option.genres?.isNotEmpty != true ? null @@ -113,35 +105,36 @@ class PlaylistGeneratorPage extends HookConsumerWidget { children: option.genres!.mapIndexed( (index, genre) { return Chip( - label: Text(genre), - labelStyle: textTheme.bodySmall?.copyWith( - color: theme.colorScheme.secondary, - fontWeight: FontWeight.w600, - ), - side: BorderSide.none, - backgroundColor: theme.colorScheme.secondaryContainer, + style: ButtonVariance.secondary, + child: Text(genre), ); }, ).toList(), ), - onTap: () => onSelected(option), + onPressed: () => onSelected(option), + style: ButtonVariance.ghost, ), displayStringForOption: (option) => option.name!, - selectedSeedBuilder: (artist) => Chip( - avatar: CircleAvatar( - backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - artist.images, + selectedSeedBuilder: (artist) => OutlineBadge( + leading: Avatar( + initials: artist.name!.substring(0, 1), + size: 30, + provider: UniversalImage.imageProvider( + artist.images.asUrlString( placeholder: ImagePlaceholder.artist, ), ), ), - label: Text(artist.name!), - onDeleted: () { - artists.value = [ - ...artists.value..removeWhere((element) => element.id == artist.id) - ]; - }, + trailing: IconButton.ghost( + icon: const Icon(SpotubeIcons.close), + onPressed: () { + artists.value = [ + ...artists.value + ..removeWhere((element) => element.id == artist.id) + ]; + }, + ), + child: Text(artist.name!), ), ); @@ -149,47 +142,46 @@ class PlaylistGeneratorPage extends HookConsumerWidget { seeds: tracks, enabled: enabled, selectedItemDisplayType: SelectedItemDisplayType.list, - inputDecoration: InputDecoration( - labelText: context.l10n.tracks, - labelStyle: textTheme.titleMedium, - helperText: context.l10n.select_up_to_count_type( - leftSeedCount, - context.l10n.tracks, - ), - ), - fetchSeeds: (textEditingValue) => spotify.search - .get( - textEditingValue.text, - types: [SearchType.track], - ) - .first(6) - .then( - (v) => List.castFrom( - v.expand((e) => e.items ?? []).toList(), + label: Text(context.l10n.tracks), + placeholder: Text(context.l10n.select_up_to_count_type( + leftSeedCount, + context.l10n.tracks, + )), + fetchSeeds: (textEditingValue) => spotify.invoke( + (api) => api.search + .get( + textEditingValue.text, + types: [SearchType.track], ) - .where( - (element) => - tracks.value.none((track) => element.id == track.id), - ) - .toList(), - ), - autocompleteOptionBuilder: (option, onSelected) => ListTile( - leading: CircleAvatar( - backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - option.album?.images, + .first(6) + .then( + (v) => List.castFrom( + v.expand((e) => e.items ?? []).toList(), + ) + .where( + (element) => + tracks.value.none((track) => element.id == track.id), + ) + .toList(), + ), + ), + autocompleteOptionBuilder: (option, onSelected) => ButtonTile( + leading: Avatar( + initials: option.name!.substring(0, 1), + provider: UniversalImage.imageProvider( + (option.album?.images).asUrlString( placeholder: ImagePlaceholder.artist, ), ), ), - horizontalTitleGap: 20, title: Text(option.name!), subtitle: Text( option.artists?.map((e) => e.name).join(", ") ?? option.album?.name ?? "", ), - onTap: () => onSelected(option), + onPressed: () => onSelected(option), + style: ButtonVariance.ghost, ), displayStringForOption: (option) => option.name!, selectedSeedBuilder: (option) => SimpleTrackTile( @@ -202,63 +194,110 @@ class PlaylistGeneratorPage extends HookConsumerWidget { ), ); - final genreSelector = MultiSelectField( - options: genresCollection.data ?? [], - selectedOptions: genres.value, - getValueForOption: (option) => option, - onSelected: (value) { - genres.value = value; + final genreSelector = MultiSelect( + value: genres.value, + onChanged: (value) { + if (!enabled) return; + genres.value = value?.toList() ?? []; }, - dialogTitle: Text(context.l10n.select_genres), - label: Text(context.l10n.add_genres), - helperText: context.l10n.select_up_to_count_type( - leftSeedCount, - context.l10n.genre, + itemBuilder: (context, item) => Text(item), + popoverAlignment: Alignment.bottomCenter, + popupConstraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * .8, ), - enabled: enabled, + placeholder: Text( + context.l10n.select_up_to_count_type( + leftSeedCount, + context.l10n.genre, + ), + ), + popup: SelectPopup.builder( + searchPlaceholder: Text(context.l10n.select_genres), + builder: (context, searchQuery) { + final filteredGenres = searchQuery?.isNotEmpty != true + ? genresCollection.asData?.value ?? [] + : genresCollection.asData?.value + .where( + (item) => item + .toLowerCase() + .contains(searchQuery!.toLowerCase()), + ) + .toList() ?? + []; + + return SelectItemBuilder( + childCount: filteredGenres.length, + builder: (context, index) { + final option = filteredGenres[index]; + + return SelectItemButton( + value: option, + child: Text(option), + ); + }, + ); + }, + ).call, ); + final countrySelector = ValueListenableBuilder( valueListenable: market, builder: (context, value, _) { - return DropdownButtonFormField( - decoration: InputDecoration( - labelText: context.l10n.country, - labelStyle: textTheme.titleMedium, - ), - isExpanded: true, - items: spotifyMarkets - .map( - (country) => DropdownMenuItem( - value: country.$1, - child: Text(country.$2), - ), - ) - .toList(), + return Select( + placeholder: Text(context.l10n.country), value: market.value, onChanged: (value) { market.value = value!; }, + popupConstraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * .8, + ), + popoverAlignment: Alignment.bottomCenter, + itemBuilder: (context, value) => Text(value.name), + popup: SelectPopup.builder( + searchPlaceholder: Text(context.l10n.search), + builder: (context, searchQuery) { + final filteredMarkets = searchQuery == null || searchQuery.isEmpty + ? spotifyMarkets + : spotifyMarkets + .where( + (item) => item.$1.name + .toLowerCase() + .contains(searchQuery.toLowerCase()), + ) + .toList(); + + return SelectItemBuilder( + childCount: filteredMarkets.length, + builder: (context, index) { + return SelectItemButton( + value: filteredMarkets[index].$1, + child: Text(filteredMarkets[index].$2), + ); + }, + ); + }, + ).call, ); }, ); final controller = useScrollController(); - return Scaffold( - appBar: PageWindowTitleBar( - leading: const BackButton(), - title: Text(context.l10n.generate_playlist), - centerTitle: true, - ), - body: Scrollbar( - controller: controller, - child: Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: Breakpoints.lg), - child: SliderTheme( - data: const SliderThemeData( - overlayShape: RoundSliderOverlayShape(), - ), + return SafeArea( + bottom: false, + child: Scaffold( + headers: [ + TitleBar( + leading: const [BackButton()], + title: Text(context.l10n.generate), + ) + ], + child: Scrollbar( + controller: controller, + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: Breakpoints.lg), child: SafeArea( child: LayoutBuilder(builder: (context, constrains) { return ScrollConfiguration( @@ -276,35 +315,36 @@ class PlaylistGeneratorPage extends HookConsumerWidget { children: [ Text( context.l10n.number_of_tracks_generate, - style: textTheme.titleMedium, + style: typography.semiBold, ), Row( + spacing: 5, children: [ Container( width: 40, height: 40, alignment: Alignment.center, decoration: BoxDecoration( - color: theme.colorScheme.primary, + color: theme.colorScheme.primary + .withAlpha(25), shape: BoxShape.circle, ), child: Text( value.round().toString(), - style: textTheme.bodyLarge?.copyWith( - color: theme - .colorScheme.primaryContainer, + style: typography.large.copyWith( + color: theme.colorScheme.primary, ), ), ), Expanded( child: Slider( - value: value.toDouble(), + value: SliderValue.single( + value.toDouble()), min: 10, max: 100, divisions: 9, - label: value.round().toString(), onChanged: (value) { - limit.value = value.round(); + limit.value = value.value.round(); }, ), ) @@ -355,88 +395,213 @@ class PlaylistGeneratorPage extends HookConsumerWidget { const SizedBox(height: 16), RecommendationAttributeDials( title: Text(context.l10n.acousticness), - values: acousticness.value, + values: ( + target: target.value.acousticness?.toDouble() ?? 0, + min: min.value.acousticness?.toDouble() ?? 0, + max: max.value.acousticness?.toDouble() ?? 0, + ), onChanged: (value) { - acousticness.value = value; + target.value = target.value.copyWith( + acousticness: value.target, + ); + min.value = min.value.copyWith( + acousticness: value.min, + ); + max.value = max.value.copyWith( + acousticness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.danceability), - values: danceability.value, + values: ( + target: target.value.danceability?.toDouble() ?? 0, + min: min.value.danceability?.toDouble() ?? 0, + max: max.value.danceability?.toDouble() ?? 0, + ), onChanged: (value) { - danceability.value = value; + target.value = target.value.copyWith( + danceability: value.target, + ); + min.value = min.value.copyWith( + danceability: value.min, + ); + max.value = max.value.copyWith( + danceability: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.energy), - values: energy.value, + values: ( + target: target.value.energy?.toDouble() ?? 0, + min: min.value.energy?.toDouble() ?? 0, + max: max.value.energy?.toDouble() ?? 0, + ), onChanged: (value) { - energy.value = value; + target.value = target.value.copyWith( + energy: value.target, + ); + min.value = min.value.copyWith( + energy: value.min, + ); + max.value = max.value.copyWith( + energy: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.instrumentalness), - values: instrumentalness.value, + values: ( + target: + target.value.instrumentalness?.toDouble() ?? 0, + min: min.value.instrumentalness?.toDouble() ?? 0, + max: max.value.instrumentalness?.toDouble() ?? 0, + ), onChanged: (value) { - instrumentalness.value = value; + target.value = target.value.copyWith( + instrumentalness: value.target, + ); + min.value = min.value.copyWith( + instrumentalness: value.min, + ); + max.value = max.value.copyWith( + instrumentalness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.liveness), - values: liveness.value, + values: ( + target: target.value.liveness?.toDouble() ?? 0, + min: min.value.liveness?.toDouble() ?? 0, + max: max.value.liveness?.toDouble() ?? 0, + ), onChanged: (value) { - liveness.value = value; + target.value = target.value.copyWith( + liveness: value.target, + ); + min.value = min.value.copyWith( + liveness: value.min, + ); + max.value = max.value.copyWith( + liveness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.loudness), - values: loudness.value, + values: ( + target: target.value.loudness?.toDouble() ?? 0, + min: min.value.loudness?.toDouble() ?? 0, + max: max.value.loudness?.toDouble() ?? 0, + ), onChanged: (value) { - loudness.value = value; + target.value = target.value.copyWith( + loudness: value.target, + ); + min.value = min.value.copyWith( + loudness: value.min, + ); + max.value = max.value.copyWith( + loudness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.speechiness), - values: speechiness.value, + values: ( + target: target.value.speechiness?.toDouble() ?? 0, + min: min.value.speechiness?.toDouble() ?? 0, + max: max.value.speechiness?.toDouble() ?? 0, + ), onChanged: (value) { - speechiness.value = value; + target.value = target.value.copyWith( + speechiness: value.target, + ); + min.value = min.value.copyWith( + speechiness: value.min, + ); + max.value = max.value.copyWith( + speechiness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.valence), - values: valence.value, + values: ( + target: target.value.valence?.toDouble() ?? 0, + min: min.value.valence?.toDouble() ?? 0, + max: max.value.valence?.toDouble() ?? 0, + ), onChanged: (value) { - valence.value = value; + target.value = target.value.copyWith( + valence: value.target, + ); + min.value = min.value.copyWith( + valence: value.min, + ); + max.value = max.value.copyWith( + valence: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.popularity), - values: popularity.value, base: 100, + values: ( + target: target.value.popularity?.toDouble() ?? 0, + min: min.value.popularity?.toDouble() ?? 0, + max: max.value.popularity?.toDouble() ?? 0, + ), onChanged: (value) { - popularity.value = value; + target.value = target.value.copyWith( + popularity: value.target, + ); + min.value = min.value.copyWith( + popularity: value.min, + ); + max.value = max.value.copyWith( + popularity: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.key), - values: key.value, base: 11, + values: ( + target: target.value.key?.toDouble() ?? 0, + min: min.value.key?.toDouble() ?? 0, + max: max.value.key?.toDouble() ?? 0, + ), onChanged: (value) { - key.value = value; + target.value = target.value.copyWith( + key: value.target, + ); + min.value = min.value.copyWith( + key: value.min, + ); + max.value = max.value.copyWith( + key: value.max, + ); }, ), RecommendationAttributeFields( title: Text(context.l10n.duration), values: ( - max: durationMs.value.max / 1000, - target: durationMs.value.target / 1000, - min: durationMs.value.min / 1000, + max: (max.value.durationMs ?? 0) / 1000, + target: (target.value.durationMs ?? 0) / 1000, + min: (min.value.durationMs ?? 0) / 1000, ), onChanged: (value) { - durationMs.value = ( - max: value.max * 1000, - target: value.target * 1000, - min: value.min * 1000, + target.value = target.value.copyWith( + durationMs: (value.target * 1000).toInt(), + ); + min.value = min.value.copyWith( + durationMs: (value.min * 1000).toInt(), + ); + max.value = max.value.copyWith( + durationMs: (value.max * 1000).toInt(), ); }, presets: { @@ -451,69 +616,92 @@ class PlaylistGeneratorPage extends HookConsumerWidget { ), RecommendationAttributeFields( title: Text(context.l10n.tempo), - values: tempo.value, + values: ( + max: max.value.tempo?.toDouble() ?? 0, + target: target.value.tempo?.toDouble() ?? 0, + min: min.value.tempo?.toDouble() ?? 0, + ), onChanged: (value) { - tempo.value = value; + target.value = target.value.copyWith( + tempo: value.target, + ); + min.value = min.value.copyWith( + tempo: value.min, + ); + max.value = max.value.copyWith( + tempo: value.max, + ); }, ), RecommendationAttributeFields( title: Text(context.l10n.mode), - values: mode.value, + values: ( + max: max.value.mode?.toDouble() ?? 0, + target: target.value.mode?.toDouble() ?? 0, + min: min.value.mode?.toDouble() ?? 0, + ), onChanged: (value) { - mode.value = value; + target.value = target.value.copyWith( + mode: value.target, + ); + min.value = min.value.copyWith( + mode: value.min, + ); + max.value = max.value.copyWith( + mode: value.max, + ); }, ), RecommendationAttributeFields( title: Text(context.l10n.time_signature), - values: timeSignature.value, + values: ( + max: max.value.timeSignature?.toDouble() ?? 0, + target: target.value.timeSignature?.toDouble() ?? 0, + min: min.value.timeSignature?.toDouble() ?? 0, + ), onChanged: (value) { - timeSignature.value = value; + target.value = target.value.copyWith( + timeSignature: value.target, + ); + min.value = min.value.copyWith( + timeSignature: value.min, + ); + max.value = max.value.copyWith( + timeSignature: value.max, + ); }, ), - const SizedBox(height: 20), - FilledButton.icon( - icon: const Icon(SpotubeIcons.magic), - label: Text(context.l10n.generate_playlist), - onPressed: artists.value.isEmpty && - tracks.value.isEmpty && - genres.value.isEmpty - ? null - : () { - final PlaylistGenerateResultRouteState - routeState = ( - seeds: ( - artists: artists.value + const Gap(20), + Center( + child: Button.primary( + leading: const Icon(SpotubeIcons.magic), + onPressed: artists.value.isEmpty && + tracks.value.isEmpty && + genres.value.isEmpty + ? null + : () { + final routeState = + GeneratePlaylistProviderInput( + seedArtists: artists.value .map((a) => a.id!) .toList(), - tracks: tracks.value + seedTracks: tracks.value .map((t) => t.id!) .toList(), - genres: genres.value - ), - market: market.value, - limit: limit.value, - parameters: ( - acousticness: acousticness.value, - danceability: danceability.value, - energy: energy.value, - instrumentalness: instrumentalness.value, - liveness: liveness.value, - loudness: loudness.value, - speechiness: speechiness.value, - valence: valence.value, - popularity: popularity.value, - key: key.value, - duration_ms: durationMs.value, - tempo: tempo.value, - mode: mode.value, - time_signature: timeSignature.value, - ) - ); - GoRouter.of(context).push( - "/library/generate/result", - extra: routeState, - ); - }, + seedGenres: genres.value, + limit: limit.value, + max: max.value, + min: min.value, + target: target.value, + ); + context.navigateTo( + PlaylistGenerateResultRoute( + state: routeState, + ), + ); + }, + child: Text(context.l10n.generate), + ), ), ], ), diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index f751b65b..9e6f2987 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -1,73 +1,60 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/library/playlist_generate/simple_track_tile.dart'; -import 'package:spotube/components/playlist/playlist_create_dialog.dart'; -import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/button/back_button.dart'; +import 'package:spotube/modules/library/playlist_generate/simple_track_tile.dart'; +import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; +import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/services/queries/playlist.dart'; -import 'package:spotube/services/queries/queries.dart'; - -typedef PlaylistGenerateResultRouteState = ({ - ({List tracks, List artists, List genres})? seeds, - RecommendationParameters? parameters, - int limit, - Market? market, -}); +import 'package:spotube/models/spotify/recommendation_seeds.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +@RoutePage() class PlaylistGenerateResultPage extends HookConsumerWidget { - final PlaylistGenerateResultRouteState state; + static const name = "playlist_generate_result"; + + final GeneratePlaylistProviderInput state; const PlaylistGenerateResultPage({ - Key? key, + super.key, required this.state, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { - final router = GoRouter.of(context); - final scaffoldMessenger = ScaffoldMessenger.of(context); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final (:seeds, :parameters, :limit, :market) = state; + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final queryClient = useQueryClient(); - final generatedPlaylist = useQueries.playlist.generate( - ref, - seeds: seeds, - parameters: parameters, - limit: limit, - market: market, - ); + final generatedPlaylist = ref.watch(generatePlaylistProvider(state)); final selectedTracks = useState>( - generatedPlaylist.data?.map((e) => e.id!).toList() ?? [], + generatedPlaylist.asData?.value.map((e) => e.id!).toList() ?? [], ); useEffect(() { - if (generatedPlaylist.data != null) { + if (generatedPlaylist.asData?.value != null) { selectedTracks.value = - generatedPlaylist.data!.map((e) => e.id!).toList(); + generatedPlaylist.asData!.value.map((e) => e.id!).toList(); } return null; - }, [generatedPlaylist.data]); + }, [generatedPlaylist.asData?.value]); - final isAllTrackSelected = - selectedTracks.value.length == (generatedPlaylist.data?.length ?? 0); + final isAllTrackSelected = selectedTracks.value.length == + (generatedPlaylist.asData?.value.length ?? 0); - return WillPopScope( - onWillPop: () async { - queryClient.cache.removeQuery(generatedPlaylist); - return true; - }, + return SafeArea( + bottom: false, child: Scaffold( - appBar: const PageWindowTitleBar(leading: BackButton()), - body: generatedPlaylist.isLoading + headers: const [ + TitleBar(leading: [BackButton()]) + ], + child: generatedPlaylist.isLoading ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -92,49 +79,54 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { ), shrinkWrap: true, children: [ - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.play), - label: Text(context.l10n.play), + Button.primary( + leading: const Icon(SpotubeIcons.play), onPressed: selectedTracks.value.isEmpty ? null : () async { await playlistNotifier.load( - generatedPlaylist.data!.where( - (e) => - selectedTracks.value.contains(e.id!), - ), + generatedPlaylist.asData!.value + .where( + (e) => selectedTracks.value + .contains(e.id!), + ) + .toList(), autoPlay: true, ); }, + child: Text(context.l10n.play), ), - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.queueAdd), - label: Text(context.l10n.add_to_queue), + Button.primary( + leading: const Icon(SpotubeIcons.queueAdd), onPressed: selectedTracks.value.isEmpty ? null : () async { await playlistNotifier.addTracks( - generatedPlaylist.data!.where( + generatedPlaylist.asData!.value.where( (e) => selectedTracks.value.contains(e.id!), ), ); if (context.mounted) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.add_count_to_queue( - selectedTracks.value.length, + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + child: Text( + context.l10n.add_count_to_queue( + selectedTracks.value.length, + ), ), - ), - ), + ); + }, ); } }, + child: Text(context.l10n.add_to_queue), ), - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.addFilled), - label: Text(context.l10n.create_a_playlist), + Button.primary( + leading: const Icon(SpotubeIcons.addFilled), onPressed: selectedTracks.value.isEmpty ? null : () async { @@ -145,17 +137,19 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { ), ); - if (playlist != null) { - router.go( - '/playlist/${playlist.id}', - extra: playlist, + if (playlist != null && context.mounted) { + context.navigateTo( + PlaylistRoute( + id: playlist.id!, + playlist: playlist, + ), ); } }, + child: Text(context.l10n.create_a_playlist), ), - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.playlistAdd), - label: Text(context.l10n.add_to_playlist), + Button.primary( + leading: const Icon(SpotubeIcons.playlistAdd), onPressed: selectedTracks.value.isEmpty ? null : () async { @@ -166,7 +160,8 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { openFromPlaylist: null, tracks: selectedTracks.value .map( - (e) => generatedPlaylist.data! + (e) => generatedPlaylist + .asData!.value .firstWhere( (element) => element.id == e, ), @@ -176,22 +171,27 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { ); if (context.mounted && hasAdded == true) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.add_count_to_playlist( - selectedTracks.value.length, + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + child: Text( + context.l10n.add_count_to_playlist( + selectedTracks.value.length, + ), ), - ), - ), + ); + }, ); } }, + child: Text(context.l10n.add_to_playlist), ) ], ), const SizedBox(height: 16), - if (generatedPlaylist.data != null) + if (generatedPlaylist.asData?.value != null) Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -200,19 +200,20 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { selectedTracks.value.length, ), ), - ElevatedButton.icon( + Button.secondary( onPressed: () { if (isAllTrackSelected) { selectedTracks.value = []; } else { - selectedTracks.value = generatedPlaylist.data - ?.map((e) => e.id!) + selectedTracks.value = generatedPlaylist + .asData?.value + .map((e) => e.id!) .toList() ?? []; } }, - icon: const Icon(SpotubeIcons.selectionCheck), - label: Text( + leading: const Icon(SpotubeIcons.selectionCheck), + child: Text( isAllTrackSelected ? context.l10n.deselect_all : context.l10n.select_all, @@ -221,32 +222,45 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { ], ), const SizedBox(height: 8), - Card( - margin: const EdgeInsets.all(0), - child: SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final track in generatedPlaylist.data ?? []) - CheckboxListTile( - value: selectedTracks.value.contains(track.id), - onChanged: (value) { - if (value == true) { - selectedTracks.value.add(track.id!); - } else { - selectedTracks.value.remove(track.id); - } - selectedTracks.value = - selectedTracks.value.toList(); - }, - controlAffinity: - ListTileControlAffinity.leading, - contentPadding: EdgeInsets.zero, - dense: true, - title: SimpleTrackTile(track: track), - ) - ], - ), + SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final track + in generatedPlaylist.asData?.value ?? []) + Row( + spacing: 5, + children: [ + Checkbox( + state: selectedTracks.value.contains(track.id) + ? CheckboxState.checked + : CheckboxState.unchecked, + onChanged: (value) { + if (value == CheckboxState.checked) { + selectedTracks.value.add(track.id!); + } else { + selectedTracks.value.remove(track.id); + } + selectedTracks.value = + selectedTracks.value.toList(); + }, + ), + Expanded( + child: GestureDetector( + onTap: () { + selectedTracks.value.contains(track.id) + ? selectedTracks.value + .remove(track.id) + : selectedTracks.value.add(track.id!); + selectedTracks.value = + selectedTracks.value.toList(); + }, + child: SimpleTrackTile(track: track), + ), + ), + ], + ) + ], ), ), ], diff --git a/lib/pages/library/user_albums.dart b/lib/pages/library/user_albums.dart new file mode 100644 index 00000000..beaa779f --- /dev/null +++ b/lib/pages/library/user_albums.dart @@ -0,0 +1,134 @@ +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/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/authentication/authentication.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:auto_route/auto_route.dart'; + +@RoutePage() +class UserAlbumsPage extends HookConsumerWidget { + static const name = 'user_albums'; + const UserAlbumsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final auth = ref.watch(authenticationProvider); + final albumsQuery = ref.watch(favoriteAlbumsProvider); + final albumsQueryNotifier = ref.watch(favoriteAlbumsProvider.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 (auth.asData?.value == null) { + return const AnonymousFallback(); + } + + return SafeArea( + bottom: false, + child: Scaffold( + child: material.RefreshIndicator.adaptive( + onRefresh: () async { + ref.invalidate(favoriteAlbumsProvider); + }, + 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_artist), + ), + ), + ), + ), + 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.not_following_artists, + 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 new file mode 100644 index 00000000..35577cd7 --- /dev/null +++ b/lib/pages/library/user_artists.dart @@ -0,0 +1,176 @@ +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/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/authentication/authentication.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:auto_route/auto_route.dart'; + +@RoutePage() +class UserArtistsPage extends HookConsumerWidget { + static const name = 'user_artists'; + const UserArtistsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final auth = ref.watch(authenticationProvider); + + final artistQuery = ref.watch(followedArtistsProvider); + final artistQueryNotifier = ref.watch(followedArtistsProvider.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 (auth.asData?.value == null) { + return const AnonymousFallback(); + } + + return SafeArea( + bottom: false, + child: Scaffold( + child: material.RefreshIndicator.adaptive( + onRefresh: () async { + ref.invalidate(followedArtistsProvider); + }, + 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/components/library/user_downloads.dart b/lib/pages/library/user_downloads.dart similarity index 75% rename from lib/components/library/user_downloads.dart rename to lib/pages/library/user_downloads.dart index c8ceee66..1d8f560a 100644 --- a/lib/components/library/user_downloads.dart +++ b/lib/pages/library/user_downloads.dart @@ -1,13 +1,16 @@ import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/components/library/user_downloads/download_item.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'; -class UserDownloads extends HookConsumerWidget { - const UserDownloads({Key? key}) : super(key: key); +@RoutePage() +class UserDownloadsPage extends HookConsumerWidget { + static const name = 'user_downloads'; + const UserDownloadsPage({super.key}); @override Widget build(BuildContext context, ref) { @@ -31,15 +34,10 @@ class UserDownloads extends HookConsumerWidget { context.l10n .currently_downloading(downloadManager.$downloadCount), maxLines: 1, - style: Theme.of(context).textTheme.headlineMedium, - ), + ).semiBold(), ), const SizedBox(width: 10), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: Colors.red[50], - foregroundColor: Colors.red[400], - ), + Button.destructive( onPressed: downloadManager.$downloadCount == 0 ? null : downloadManager.cancelAll, diff --git a/lib/pages/library/user_local_tracks/local_folder.dart b/lib/pages/library/user_local_tracks/local_folder.dart new file mode 100644 index 00000000..a6f3ad51 --- /dev/null +++ b/lib/pages/library/user_local_tracks/local_folder.dart @@ -0,0 +1,413 @@ +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/extensions/constrains.dart'; +import 'package:spotube/extensions/string.dart'; +import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.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/artist_simple.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/local_track.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, { + LocalTrack? 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 != null && + currentTrack.id != playlist.activeTrack?.id) { + await playback.jumpToTrack(currentTrack); + } + } + + @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 = playlist.containsTracks( + trackSnapshot.asData?.value.values.flattened.toList() ?? []); + + 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 ?? false) return; + + final cacheDir = Directory( + await UserPreferencesNotifier.getMusicCacheDir(), + ); + + if (cacheDir.existsSync()) { + await cacheDir.delete(recursive: true); + } + }, + ), + 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), + Button.primary( + onPressed: trackSnapshot.asData?.value != null + ? () async { + if (trackSnapshot + .asData?.value.isNotEmpty == + true) { + if (!isPlaylistPlaying) { + await playLocalTracks( + ref, + trackSnapshot + .asData!.value[location] ?? + [], + ); + } + } + } + : null, + leading: Icon( + isPlaylistPlaying + ? SpotubeIcons.stop + : SpotubeIcons.play, + ), + child: Text(context.l10n.play), + ), + 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: ListView.builder( + controller: controller, + physics: + const AlwaysScrollableScrollPhysics(), + 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, + ); + }, + ); + }, + ), + ), + ), + ), + ); + }, + 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 new file mode 100644 index 00000000..67e02b0b --- /dev/null +++ b/lib/pages/library/user_local_tracks/user_local_tracks.dart @@ -0,0 +1,107 @@ +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: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'; +// ignore: depend_on_referenced_packages + +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 + ? 210 + : constrains.mdAndDown + ? 280 + : 250, + 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 new file mode 100644 index 00000000..8b9e0dc3 --- /dev/null +++ b/lib/pages/library/user_playlists.dart @@ -0,0 +1,148 @@ +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:spotify/spotify.dart'; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/playbutton_view/playbutton_view.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/authentication/authentication.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:auto_route/auto_route.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 auth = ref.watch(authenticationProvider); + + final playlistsQuery = ref.watch(favoritePlaylistsProvider); + final playlistsQueryNotifier = + ref.watch(favoritePlaylistsProvider.notifier); + + final likedTracksPlaylist = useMemoized( + () => PlaylistSimple() + ..name = context.l10n.liked_tracks + ..description = context.l10n.liked_tracks_description + ..type = "playlist" + ..collaborative = false + ..public = false + ..id = "user-liked-tracks" + ..images = [ + Image() + ..height = 300 + ..width = 300 + ..url = "assets/liked-tracks.jpg" + ], + [context.l10n], + ); + + final playlists = useMemoized( + () { + if (searchText.value.isEmpty) { + return [ + likedTracksPlaylist, + ...?playlistsQuery.asData?.value.items, + ]; + } + return [ + 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 (auth.asData?.value == null) { + return const AnonymousFallback(); + } + + return material.RefreshIndicator.adaptive( + onRefresh: () async { + ref.invalidate(favoritePlaylistsProvider); + }, + 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: Expanded( + child: Row( + children: [ + const 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 index ac4b61e7..1bd58d61 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -1,154 +1,114 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; 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/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/themed_button_tab_bar.dart'; -import 'package:spotube/extensions/constrains.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_custom_status_bar_color.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/pages/lyrics/plain_lyrics.dart'; import 'package:spotube/pages/lyrics/synced_lyrics.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class LyricsPage extends HookConsumerWidget { - final bool isModal; - const LyricsPage({Key? key, this.isModal = false}) : super(key: key); + static const name = "lyrics"; + + const LyricsPage({super.key}); @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlist = ref.watch(audioPlayerProvider); String albumArt = useMemoized( - () => TypeConversionUtils.image_X_UrlString( - playlist.activeTrack?.album?.images, + () => (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 mediaQuery = MediaQuery.of(context); + final selectedIndex = useState(0); - useCustomStatusBarColor( - palette.color, - true, - noSetBGColor: true, + 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)), + ], + ), ); - final tabbar = ThemedButtonsTabBar( - tabs: [ - Tab(text: " ${context.l10n.synced} "), - Tab(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), ], ); - final auth = ref.watch(AuthenticationNotifier.provider); - - if (auth == null) { - return Scaffold( - appBar: !kIsMacOS && !isModal ? const PageWindowTitleBar() : null, - body: const AnonymousFallback(), - ); - } - - if (isModal) { - return DefaultTabController( - length: 2, - child: SafeArea( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: Container( - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.background.withOpacity(.4), - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(10), - topRight: Radius.circular(10), - ), - ), - child: Column( - children: [ - const SizedBox(height: 5), - Container( - height: 7, - width: 150, - decoration: BoxDecoration( - color: palette.titleTextColor, - borderRadius: BorderRadius.circular(10), - ), - ), - AppBar( - leadingWidth: double.infinity, - leading: tabbar, - backgroundColor: Colors.transparent, - automaticallyImplyLeading: false, - actions: [ - IconButton( - icon: const Icon(SpotubeIcons.minimize), - onPressed: () => Navigator.of(context).pop(), - ), - const SizedBox(width: 5), - ], - ), - Expanded( - child: TabBarView( - children: [ - SyncedLyrics(palette: palette, isModal: isModal), - PlainLyrics(palette: palette, isModal: isModal), - ], - ), - ), - ], - ), - ), - ), - ), - ); - } - return DefaultTabController( - length: 2, - child: SafeArea( - bottom: mediaQuery.mdAndUp, - child: Scaffold( - extendBodyBehindAppBar: true, - appBar: !kIsMacOS - ? PageWindowTitleBar( + 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, - body: Container( - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - image: DecorationImage( - image: UniversalImage.imageProvider(albumArt), - fit: BoxFit.cover, - ), - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(10), - ), + : tabbar + ], + child: Container( + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + image: DecorationImage( + image: UniversalImage.imageProvider(albumArt), + fit: BoxFit.cover, ), - margin: const EdgeInsets.only(bottom: 10), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), - child: ColoredBox( - color: palette.color.withOpacity(.7), - child: SafeArea( - child: TabBarView( - children: [ - SyncedLyrics(palette: palette, isModal: isModal), - PlainLyrics(palette: palette, isModal: isModal), - ], - ), + ), + 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 index 2cf73728..4c28eddd 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -1,27 +1,28 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; -import 'package:go_router/go_router.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/components/player/player_controls.dart'; -import 'package:spotube/components/player/player_queue.dart'; -import 'package:spotube/components/root/sidebar.dart'; -import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/components/shared/page_window_title_bar.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/authentication_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.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({Key? key, required this.prevSize}) : super(key: key); + const MiniLyricsPage({super.key, required this.prevSize}); @override Widget build(BuildContext context, ref) { @@ -29,28 +30,23 @@ class MiniLyricsPage extends HookConsumerWidget { final update = useForceUpdate(); final wasMaximized = useRef(false); - final playlistQueue = ref.watch(ProxyPlaylistNotifier.provider); + final playlistQueue = ref.watch(audioPlayerProvider); + + final index = useState(0); final areaActive = useState(false); final hoverMode = useState(true); final showLyrics = useState(true); useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) async { - wasMaximized.value = await DesktopTools.window.isMaximized(); - }); + if (kIsDesktop) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + wasMaximized.value = await windowManager.isMaximized(); + }); + } return null; }, []); - final auth = ref.watch(AuthenticationNotifier.provider); - - if (auth == null) { - return const Scaffold( - appBar: PageWindowTitleBar(), - body: AnonymousFallback(), - ); - } - return MouseRegion( onEnter: !hoverMode.value ? null @@ -62,12 +58,11 @@ class MiniLyricsPage extends HookConsumerWidget { : (event) { areaActive.value = false; }, - child: DefaultTabController( - length: 2, - child: Scaffold( - backgroundColor: theme.colorScheme.surface.withOpacity(0.4), - appBar: PreferredSize( - preferredSize: const Size.fromHeight(60), + 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 @@ -76,190 +71,215 @@ class MiniLyricsPage extends HookConsumerWidget { secondChild: const SizedBox(), firstChild: DragToMoveArea( child: Row( + spacing: 2, children: [ - const SizedBox(width: 10), - SizedBox( - height: 30, - width: 30, - child: Sidebar.brandLogo(), - ), - const Spacer(), + const Gap(10), + if (kIsMacOS) const SizedBox(width: 65), if (showLyrics.value) - SizedBox( - height: 30, - child: TabBar( - tabs: [ - Tab(text: context.l10n.synced), - Tab(text: context.l10n.plain), - ], - isScrollable: true, - ), + Tabs( + index: index.value, + onChanged: (i) { + index.value = i; + }, + children: [ + TabItem(child: Text(context.l10n.synced)), + TabItem(child: Text(context.l10n.plain)), + ], ), const Spacer(), - IconButton( - tooltip: context.l10n.lyrics, - icon: showLyrics.value - ? const Icon(SpotubeIcons.lyrics) - : const Icon(SpotubeIcons.lyricsOff), - style: ButtonStyle( - foregroundColor: showLyrics.value - ? MaterialStateProperty.all( - theme.colorScheme.primary) - : null, - ), - onPressed: () async { - showLyrics.value = !showLyrics.value; - areaActive.value = true; - hoverMode.value = false; + 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; - await DesktopTools.window.setSize( - showLyrics.value - ? const Size(400, 500) - : const Size(400, 150), - ); - }, - ), - IconButton( - tooltip: context.l10n.show_hide_ui_on_hover, - icon: hoverMode.value - ? const Icon(SpotubeIcons.hoverOn) - : const Icon(SpotubeIcons.hoverOff), - style: ButtonStyle( - foregroundColor: hoverMode.value - ? MaterialStateProperty.all( - theme.colorScheme.primary) - : null, + if (kIsDesktop) { + await windowManager.setSize( + showLyrics.value + ? const Size(400, 500) + : const Size(400, 150), + ); + } + }, ), - onPressed: () async { - areaActive.value = true; - hoverMode.value = !hoverMode.value; - }, ), - FutureBuilder( - future: DesktopTools.window.isAlwaysOnTop(), - builder: (context, snapshot) { - return IconButton( - tooltip: context.l10n.always_on_top, - icon: Icon( - snapshot.data == true - ? SpotubeIcons.pinOn - : SpotubeIcons.pinOff, - ), - style: ButtonStyle( - foregroundColor: snapshot.data == true - ? MaterialStateProperty.all( - theme.colorScheme.primary) - : null, - ), - onPressed: snapshot.data == null - ? null - : () async { - await DesktopTools.window.setAlwaysOnTop( - snapshot.data == true ? false : true, - ); - update(); - }, - ); - }, + 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(); + }, + ), + ); + }, + ), ], ), ), ), ), - body: Column( - children: [ - if (playlistQueue.activeTrack != null) - Text( - playlistQueue.activeTrack!.name!, - style: theme.textTheme.titleMedium, - ), - if (showLyrics.value) - Expanded( - child: TabBarView( - 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( + ], + child: Column( + children: [ + if (playlistQueue.activeTrack != null) + Text(playlistQueue.activeTrack!.name!).semiBold(), + if (showLyrics.value) + Expanded( + child: IndexedStack( + index: index.value, children: [ - IconButton( + 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), - tooltip: context.l10n.queue, onPressed: playlistQueue.activeTrack != null ? () { - showModalBottomSheet( + openDrawer( context: context, - isDismissible: true, - enableDrag: true, - isScrollControlled: true, - backgroundColor: Colors.black12, - barrierColor: Colors.black12, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + 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, + ), + ); + }, ), - constraints: BoxConstraints( - maxHeight: - MediaQuery.of(context).size.height * .7, - ), - builder: (context) { - return const PlayerQueue(floating: true); - }, ); } : null, ), - Flexible(child: PlayerControls(compact: true)), - IconButton( - tooltip: context.l10n.exit_mini_player, + ), + 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 DesktopTools.window + await windowManager .setMinimumSize(const Size(300, 700)); - await DesktopTools.window.setAlwaysOnTop(false); + await windowManager.setAlwaysOnTop(false); if (wasMaximized.value) { - await DesktopTools.window.maximize(); + await windowManager.maximize(); } else { - await DesktopTools.window.setSize(prevSize); + await windowManager.setSize(prevSize); } - await DesktopTools.window - .setAlignment(Alignment.center); + await windowManager.setAlignment(Alignment.center); if (!kIsLinux) { - await DesktopTools.window.setHasShadow(true); + await windowManager.setHasShadow(true); } await Future.delayed( const Duration(milliseconds: 200)); } finally { if (context.mounted) { - GoRouter.of(context).go('/lyrics'); + context.navigateTo(const LyricsRoute()); } } }, ), - ], - ), - ) - ], - ), + ), + ], + ), + ) + ], ), ), ); diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index bee5114d..0b5354a0 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -1,20 +1,18 @@ import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; -import 'package:spotify/spotify.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/lyrics/zoom_controls.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart'; +import 'package:spotube/modules/lyrics/zoom_controls.dart'; +import 'package:spotube/components/shimmers/shimmer_lyrics.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; - -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class PlainLyrics extends HookConsumerWidget { final PaletteColor palette; @@ -24,16 +22,15 @@ class PlainLyrics extends HookConsumerWidget { required this.palette, this.isModal, this.defaultTextZoom = 100, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final lyricsQuery = - useQueries.lyrics.spotifySynced(ref, playlist.activeTrack); + final playlist = ref.watch(audioPlayerProvider); + final lyricsQuery = ref.watch(syncedLyricsProvider(playlist.activeTrack)); final mediaQuery = MediaQuery.of(context); - final textTheme = Theme.of(context).textTheme; + final typography = Theme.of(context).typography; final textZoomLevel = useState(defaultTextZoom); @@ -47,21 +44,19 @@ class PlainLyrics extends HookConsumerWidget { child: Text( playlist.activeTrack?.name ?? "", style: mediaQuery.mdAndUp - ? textTheme.displaySmall - : textTheme.headlineMedium?.copyWith( - fontSize: 25, + ? typography.h3 + : typography.h4.copyWith( color: palette.titleTextColor, ), ), ), Center( child: Text( - TypeConversionUtils.artists_X_String( - playlist.activeTrack?.artists ?? []), - style: (mediaQuery.mdAndUp - ? textTheme.headlineSmall - : textTheme.titleLarge) - ?.copyWith(color: palette.bodyTextColor), + playlist.activeTrack?.artists?.asString() ?? "", + style: (mediaQuery.mdAndUp ? typography.h4 : typography.large) + .copyWith( + color: palette.bodyTextColor, + ), ), ) ], @@ -83,7 +78,7 @@ class PlainLyrics extends HookConsumerWidget { children: [ Text( context.l10n.no_lyrics_available, - style: textTheme.bodyLarge?.copyWith( + style: typography.large.copyWith( color: palette.bodyTextColor, ), textAlign: TextAlign.center, @@ -96,9 +91,9 @@ class PlainLyrics extends HookConsumerWidget { } final lyrics = - lyricsQuery.data?.lyrics.mapIndexed((i, e) { - final next = - lyricsQuery.data?.lyrics.elementAtOrNull(i + 1); + 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)) { @@ -111,7 +106,9 @@ class PlainLyrics extends HookConsumerWidget { return AnimatedDefaultTextStyle( duration: const Duration(milliseconds: 200), style: TextStyle( - color: palette.bodyTextColor, + color: isModal == true + ? context.theme.colorScheme.foreground + : palette.bodyTextColor, fontSize: 24 * textZoomLevel.value / 100, height: textZoomLevel.value < 70 ? 1.5 @@ -123,6 +120,7 @@ class PlainLyrics extends HookConsumerWidget { lyrics == null && playlist.activeTrack == null ? "No Track being played currently" : lyrics ?? "", + textAlign: TextAlign.center, ), ); }, diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index ddef1c65..b7423e14 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -1,26 +1,25 @@ -import 'package:flutter/material.dart'; +import 'dart:async'; + import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; -import 'package:spotify/spotify.dart' hide Offset; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/lyrics/zoom_controls.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart'; +import 'package:spotube/modules/lyrics/zoom_controls.dart'; +import 'package:spotube/components/shimmers/shimmer_lyrics.dart'; +import 'package:spotube/extensions/artist_simple.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/components/lyrics/use_synced_lyrics.dart'; +import 'package:spotube/modules/lyrics/use_synced_lyrics.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/services/logger/logger.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:stroke_text/stroke_text.dart'; -final _delay = StateProvider((ref) => 0); - class SyncedLyrics extends HookConsumerWidget { final PaletteColor palette; final bool? isModal; @@ -30,135 +29,154 @@ class SyncedLyrics extends HookConsumerWidget { required this.palette, this.isModal, this.defaultTextZoom = 100, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final mediaQuery = MediaQuery.sizeOf(context); + final theme = Theme.of(context); + + final playlist = ref.watch(audioPlayerProvider); - final mediaQuery = MediaQuery.of(context); final controller = useAutoScrollController(); - final delay = ref.watch(_delay); + final delay = ref.watch(syncedLyricsDelayProvider); final timedLyricsQuery = - useQueries.lyrics.spotifySynced(ref, playlist.activeTrack); + ref.watch(syncedLyricsProvider(playlist.activeTrack)); - final lyricValue = timedLyricsQuery.data; + final lyricValue = timedLyricsQuery.asData?.value; - final isUnSyncLyric = useMemoized( - () => lyricValue?.lyrics.every((l) => l.time == Duration.zero), - [lyricValue], + final lyricsState = ref.watch( + syncedLyricsMapProvider(playlist.activeTrack), ); - - final lyricsMap = useMemoized( - () => - lyricValue?.lyrics - .map((lyric) => {lyric.time.inSeconds: lyric.text}) - .reduce((accumulator, lyricSlice) => - {...accumulator, ...lyricSlice}) ?? - {}, - [lyricValue], - ); - final currentTime = useSyncedLyrics(ref, lyricsMap, delay); + final currentTime = + useSyncedLyrics(ref, lyricsState.asData?.value.lyricsMap ?? {}, delay); final textZoomLevel = useState(defaultTextZoom); - final textTheme = Theme.of(context).textTheme; + final typography = Theme.of(context).typography; ref.listen( - ProxyPlaylistNotifier.provider.select((s) => s.activeTrack), + audioPlayerProvider.select((s) => s.activeTrack), (previous, next) { - controller.scrollToIndex(0); - ref.read(_delay.notifier).state = 0; + controller.animateTo( + 0, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); + ref.read(syncedLyricsDelayProvider.notifier).state = 0; }, ); final headlineTextStyle = (mediaQuery.mdAndUp - ? textTheme.displaySmall - : textTheme.headlineMedium?.copyWith(fontSize: 25)) - ?.copyWith(color: palette.titleTextColor); + ? typography.h3 + : typography.h4.copyWith(fontSize: 25)) + .copyWith( + color: palette.titleTextColor, + ); - final bodyTextTheme = textTheme.bodyLarge?.copyWith( + 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: [ - Column( - children: [ + CustomScrollView( + controller: controller, + slivers: [ if (isModal != true) - Center( - child: Text( - playlist.activeTrack?.name ?? "Not Playing", + SliverAppBar( + automaticallyImplyLeading: false, + backgroundColor: Colors.transparent, + centerTitle: true, + title: Text( + playlist.activeTrack?.name ?? context.l10n.not_playing, style: headlineTextStyle, ), - ), - if (isModal != true) - Center( - child: Text( - TypeConversionUtils.artists_X_String( - playlist.activeTrack?.artists ?? []), - style: mediaQuery.mdAndUp - ? textTheme.headlineSmall - : textTheme.titleLarge, + 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 && - isUnSyncLyric == false) - Expanded( - child: ListView.builder( - controller: controller, - itemCount: lyricValue.lyrics.length, - itemBuilder: (context, index) { - final lyricSlice = lyricValue.lyrics[index]; - final isActive = lyricSlice.time.inSeconds == currentTime; + 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( + 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 - ? EdgeInsets.only( - bottom: mediaQuery.size.height / 2, + ? const EdgeInsets.all(8.0).copyWith( + bottom: 100, ) - : 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( - fontWeight: isActive - ? FontWeight.w500 - : FontWeight.normal, - fontSize: (isActive ? 28 : 26) * - (textZoomLevel.value / 100), - ), - textAlign: TextAlign.center, - child: InkWell( + : const EdgeInsets.all(8.0), + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 250), + style: TextStyle( + 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 duration = - await audioPlayer.duration ?? - Duration.zero; final time = Duration( seconds: lyricSlice.time.inSeconds - delay, ); - if (time > duration || time.isNegative) { + if (time > audioPlayer.duration || + time.isNegative) { return; } audioPlayer.seek(time); @@ -168,9 +186,15 @@ class SyncedLyrics extends HookConsumerWidget { text: lyricSlice.text, textStyle: DefaultTextStyle.of(context).style, - textColor: isActive - ? Colors.white - : palette.bodyTextColor, + textColor: switch (( + isActive, + isModal == true + )) { + (true, _) => Colors.white, + (_, true) => + theme.colorScheme.mutedForeground, + (_, _) => palette.bodyTextColor, + }, strokeColor: isActive ? Colors.black : Colors.transparent, @@ -180,30 +204,32 @@ class SyncedLyrics extends HookConsumerWidget { ), ), ), - ); - }, - ), + ), + ); + }, ), if (playlist.activeTrack != null && (timedLyricsQuery.isLoading || timedLyricsQuery.isRefreshing)) - const Expanded( - child: ShimmerLyrics(), - ) + const SliverToBoxAdapter(child: ShimmerLyrics()) else if (playlist.activeTrack != null && (timedLyricsQuery.hasError)) ...[ - Container( - alignment: Alignment.center, - padding: const EdgeInsets.all(16), - child: Text( - context.l10n.no_lyrics_available, - style: bodyTextTheme, - textAlign: TextAlign.center, + SliverToBoxAdapter( + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.all(16), + child: Text( + context.l10n.no_lyrics_available, + style: bodyTextTheme, + textAlign: TextAlign.center, + ), ), ), - const Gap(26), - const Icon(SpotubeIcons.noLyrics, size: 60), - ] else if (isUnSyncLyric == true) - Expanded( + 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, @@ -216,7 +242,7 @@ class SyncedLyrics extends HookConsumerWidget { ), TextSpan( text: " Plain Lyrics ", - style: textTheme.bodyLarge?.copyWith( + style: typography.large.copyWith( color: palette.bodyTextColor, fontWeight: FontWeight.bold, ), @@ -235,7 +261,8 @@ class SyncedLyrics extends HookConsumerWidget { final actions = [ ZoomControls( value: delay, - onChanged: (value) => ref.read(_delay.notifier).state = value, + onChanged: (value) => + ref.read(syncedLyricsDelayProvider.notifier).state = value, interval: 1, unit: "s", increaseIcon: const Icon(SpotubeIcons.add), diff --git a/lib/pages/mobile_login/hooks/login_callback.dart b/lib/pages/mobile_login/hooks/login_callback.dart new file mode 100644 index 00000000..986b7f4a --- /dev/null +++ b/lib/pages/mobile_login/hooks/login_callback.dart @@ -0,0 +1,81 @@ +import 'dart:io'; + +import 'package:auto_route/auto_route.dart'; +import 'package:desktop_webview_window/desktop_webview_window.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide join; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/pages/mobile_login/no_webview_runtime_dialog.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/utils/platform.dart'; + +Future Function() useLoginCallback(WidgetRef ref) { + final context = useContext(); + final theme = Theme.of(context); + final authNotifier = ref.read(authenticationProvider.notifier); + + return useCallback(() async { + if (kIsMobile || kIsMacOS) { + context.navigateTo(const WebViewLoginRoute()); + return; + } + + try { + final exp = RegExp(r"https:\/\/accounts.spotify.com\/.+\/status"); + final applicationSupportDir = await getApplicationSupportDirectory(); + final userDataFolder = Directory( + join(applicationSupportDir.path, "webview_window_Webview2"), + ); + + if (!await userDataFolder.exists()) { + await userDataFolder.create(); + } + + final webview = await WebviewWindow.create( + configuration: CreateConfiguration( + title: "Spotify Login", + titleBarTopPadding: kIsMacOS ? 20 : 0, + windowHeight: 720, + windowWidth: 1280, + userDataFolderWindows: userDataFolder.path, + ), + ); + webview + ..setBrightness(theme.colorScheme.brightness) + ..launch("https://accounts.spotify.com/") + ..setOnUrlRequestCallback((url) { + if (exp.hasMatch(url)) { + webview.getAllCookies().then((cookies) async { + final cookieHeader = + "sp_dc=${cookies.firstWhere((element) => element.name.contains("sp_dc")).value.replaceAll("\u0000", "")}"; + + await authNotifier.login(cookieHeader); + + webview.close(); + if (context.mounted) { + context.navigateTo(const HomeRoute()); + } + }); + } + + return true; + }); + } on PlatformException catch (_) { + if (!await WebviewWindow.isWebviewAvailable()) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + showDialog( + context: context, + builder: (context) { + return const NoWebviewRuntimeDialog(); + }, + ); + }); + } + } + }, [authNotifier, theme, context.navigateTo]); +} diff --git a/lib/pages/mobile_login/mobile_login.dart b/lib/pages/mobile_login/mobile_login.dart index 8b9bce4c..eb50316f 100644 --- a/lib/pages/mobile_login/mobile_login.dart +++ b/lib/pages/mobile_login/mobile_login.dart @@ -1,45 +1,54 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:go_router/go_router.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/components/button/back_button.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:auto_route/auto_route.dart'; -class WebViewLogin extends HookConsumerWidget { - const WebViewLogin({Key? key}) : super(key: key); +@RoutePage() +class WebViewLoginPage extends HookConsumerWidget { + static const name = "login"; + const WebViewLoginPage({super.key}); @override Widget build(BuildContext context, ref) { - final mounted = useIsMounted(); - final authenticationNotifier = - ref.watch(AuthenticationNotifier.provider.notifier); + final authenticationNotifier = ref.watch(authenticationProvider.notifier); if (kIsDesktop) { const Scaffold( - body: Center( + child: Center( child: Text('This feature is not available on desktop'), ), ); } - return Scaffold( - body: SafeArea( + return SafeArea( + bottom: false, + child: Scaffold( + headers: const [ + TitleBar( + leading: [BackButton(color: Colors.white)], + backgroundColor: Colors.transparent, + ), + ], + floatingHeader: true, child: InAppWebView( - initialOptions: InAppWebViewGroupOptions( - crossPlatform: InAppWebViewOptions( - userAgent: - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 afari/537.36", - ), + initialSettings: InAppWebViewSettings( + userAgent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 safari/537.36", ), initialUrlRequest: URLRequest( - url: Uri.parse("https://accounts.spotify.com/"), + url: WebUri("https://accounts.spotify.com/"), ), - androidOnPermissionRequest: (controller, origin, resources) async { - return PermissionRequestResponse( - resources: resources, - action: PermissionRequestResponseAction.GRANT, + onPermissionRequest: (controller, permissionRequest) async { + return PermissionResponse( + resources: permissionRequest.resources, + action: PermissionResponseAction.GRANT, ); }, onLoadStop: (controller, action) async { @@ -57,12 +66,10 @@ class WebViewLogin extends HookConsumerWidget { final cookieHeader = "sp_dc=${cookies.firstWhere((element) => element.name == "sp_dc").value}"; - authenticationNotifier.setCredentials( - await AuthenticationCredentials.fromCookie(cookieHeader), - ); - if (mounted()) { + await authenticationNotifier.login(cookieHeader); + if (context.mounted) { // ignore: use_build_context_synchronously - GoRouter.of(context).go("/"); + context.navigateTo(const HomeRoute()); } } }, diff --git a/lib/pages/mobile_login/no_webview_runtime_dialog.dart b/lib/pages/mobile_login/no_webview_runtime_dialog.dart new file mode 100644 index 00000000..b0919e5c --- /dev/null +++ b/lib/pages/mobile_login/no_webview_runtime_dialog.dart @@ -0,0 +1,57 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class NoWebviewRuntimeDialog extends StatelessWidget { + const NoWebviewRuntimeDialog({super.key}); + + @override + Widget build(BuildContext context) { + final ThemeData(:platform) = Theme.of(context); + + return AlertDialog( + title: Text(context.l10n.webview_not_found), + content: Text(context.l10n.webview_not_found_description), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(context.l10n.cancel), + ), + Button.primary( + onPressed: () async { + final url = switch (platform) { + TargetPlatform.windows => + 'https://developer.microsoft.com/en-us/microsoft-edge/webview2', + TargetPlatform.macOS => 'https://www.apple.com/safari/', + TargetPlatform.linux => + 'https://webkitgtk.org/reference/webkit2gtk/stable/', + _ => "", + }; + if (url.isEmpty) { + showToast( + context: context, + builder: (context, overlay) { + return const SurfaceCard( + child: Basic( + title: Text('Unsupported platform'), + ), + ); + }, + ); + } + + await launchUrlString(url); + }, + child: Text(switch (platform) { + TargetPlatform.windows => 'Download Edge WebView2', + TargetPlatform.macOS => 'Download Safari', + TargetPlatform.linux => 'Download Webkit2Gtk', + _ => 'Download Webview', + }), + ), + ], + ); + } +} diff --git a/lib/pages/player/lyrics.dart b/lib/pages/player/lyrics.dart new file mode 100644 index 00000000..01a4e921 --- /dev/null +++ b/lib/pages/player/lyrics.dart @@ -0,0 +1,64 @@ +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/extensions/image.dart'; +import 'package:spotube/hooks/utils/use_palette_color.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 = Padding( + padding: const EdgeInsets.all(10), + child: 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 new file mode 100644 index 00000000..829db6eb --- /dev/null +++ b/lib/pages/player/queue.dart @@ -0,0 +1,28 @@ +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 new file mode 100644 index 00000000..8e370daf --- /dev/null +++ b/lib/pages/player/sources.dart @@ -0,0 +1,15 @@ +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 index 1fb2e1dc..5f7591ab 100644 --- a/lib/pages/playlist/liked_playlist.dart +++ b/lib/pages/playlist/liked_playlist.dart @@ -1,44 +1,57 @@ +import 'package:flutter/material.dart' as material; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/tracks_view/track_view.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; +import 'package:spotube/components/track_presentation/track_presentation.dart'; +import 'package:spotube/pages/playlist/playlist.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class LikedPlaylistPage extends HookConsumerWidget { + static const name = PlaylistPage.name; + final PlaylistSimple playlist; const LikedPlaylistPage({ - Key? key, + super.key, required this.playlist, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { - final likedTracks = useQueries.playlist.likedTracksQuery(ref); - final tracks = likedTracks.data ?? []; + final likedTracks = ref.watch(likedTracksProvider); + final tracks = likedTracks.asData?.value ?? []; - return InheritedTrackView( - collectionId: playlist.id!, - image: "assets/liked-tracks.jpg", - pagination: PaginationProps( - hasNextPage: false, - isLoading: false, - onFetchMore: () {}, - onFetchAll: () async { - return tracks.toList(); - }, - onRefresh: () async { - await likedTracks.refresh(); - }, + return material.RefreshIndicator.adaptive( + onRefresh: () async { + ref.invalidate(likedTracksProvider); + }, + child: TrackPresentation( + options: TrackPresentationOptions( + collection: playlist, + image: "assets/liked-tracks.jpg", + pagination: PaginationProps( + hasNextPage: false, + isLoading: likedTracks.isLoading, + onFetchMore: () {}, + onFetchAll: () async { + return tracks.toList(); + }, + onRefresh: () async { + ref.invalidate(likedTracksProvider); + }, + ), + title: playlist.name!, + description: playlist.description, + tracks: tracks, + routePath: '/playlist/${playlist.id}', + isLiked: false, + shareUrl: null, + onHeart: null, + owner: playlist.owner?.displayName, + ), ), - title: playlist.name!, - description: playlist.description, - tracks: tracks, - routePath: '/playlist/${playlist.id}', - isLiked: false, - shareUrl: "", - onHeart: null, - child: const TrackView(), ); } } diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index 89a279ab..62ced353 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -1,98 +1,108 @@ +import 'package:flutter/material.dart' as material; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; -import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart'; -import 'package:spotube/components/shared/tracks_view/track_view.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.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/extensions/infinite_query.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class PlaylistPage extends HookConsumerWidget { - final PlaylistSimple playlist; + static const name = "playlist"; + + final PlaylistSimple _playlist; + final String id; const PlaylistPage({ - Key? key, - required this.playlist, - }) : super(key: key); + super.key, + @PathParam("id") required this.id, + required PlaylistSimple playlist, + }) : _playlist = playlist; @override Widget build(BuildContext context, ref) { - final spotify = ref.watch(spotifyProvider); - final tracksQuery = useQueries.playlist.tracksOfQuery(ref, playlist.id!); + final playlist = ref + .watch( + favoritePlaylistsProvider.select( + (value) => value.whenData( + (value) => + value.items.firstWhereOrNull((s) => s.id == _playlist.id), + ), + ), + ) + .asData + ?.value ?? + _playlist; - final tracks = useMemoized( - () { - return tracksQuery.pages.expand((page) => page).toList(); - }, - [tracksQuery.pages], - ); + final tracks = ref.watch(playlistTracksProvider(playlist.id!)); + final tracksNotifier = + ref.watch(playlistTracksProvider(playlist.id!).notifier); + final isFavoritePlaylist = + ref.watch(isFavoritePlaylistProvider(playlist.id!)); - final me = useQueries.user.me(ref); - - final isLikedQuery = useQueries.playlist.doesUserFollow( - ref, - playlist.id!, - me.data?.id ?? '', - ); - - final togglePlaylistLike = useMutations.playlist.toggleFavorite( - ref, - playlist.id!, - refreshQueries: [ - isLikedQuery.key, - ], - ); + final favoritePlaylistsNotifier = + ref.watch(favoritePlaylistsProvider.notifier); final isUserPlaylist = useIsUserPlaylist(ref, playlist.id!); - return InheritedTrackView( - collectionId: playlist.id!, - image: TypeConversionUtils.image_X_UrlString( - playlist.images, - placeholder: ImagePlaceholder.collection, - ), - pagination: PaginationProps.fromQuery( - tracksQuery, - onFetchAll: () { - return tracksQuery.fetchAllTracks( - getAllTracks: () async { - final res = await spotify.playlists - .getTracksByPlaylistId(playlist.id!) - .all(); - return res.toList(); - }, - ); - }, - ), - title: playlist.name!, - description: playlist.description, - tracks: tracks, - routePath: '/playlist/${playlist.id}', - isLiked: isLikedQuery.data ?? false, - shareUrl: playlist.externalUrls?.spotify ?? "", - onHeart: () async { - if (!isLikedQuery.hasData || togglePlaylistLike.isMutating) { - return false; - } - final confirmed = isUserPlaylist - ? await showPromptDialog( - context: context, - title: context.l10n.delete_playlist, - message: context.l10n.delete_playlist_confirmation, - ) - : true; - if (confirmed) { - await togglePlaylistLike.mutate(isLikedQuery.data!); - return isUserPlaylist; - } - return null; + return material.RefreshIndicator.adaptive( + onRefresh: () async { + ref.invalidate(playlistTracksProvider(playlist.id!)); + ref.invalidate(isFavoritePlaylistProvider(playlist.id!)); + ref.invalidate(favoritePlaylistsProvider); }, - child: const TrackView(), + 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(playlistTracksProvider(playlist.id!)); + }, + onFetchAll: () async { + return await tracksNotifier.fetchAll(); + }, + ), + title: playlist.name!, + description: playlist.description, + owner: playlist.owner?.displayName, + ownerImage: playlist.owner?.images?.lastOrNull?.url, + tracks: tracks.asData?.value.items ?? [], + routePath: '/playlist/${playlist.id}', + isLiked: isFavoritePlaylist.asData?.value ?? false, + shareUrl: playlist.externalUrls?.spotify ?? + "https://open.spotify.com/playlist/${playlist.id}", + 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) { + 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 new file mode 100644 index 00000000..b6c4a2cd --- /dev/null +++ b/lib/pages/profile/profile.dart @@ -0,0 +1,143 @@ +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/fake.dart'; +import 'package:spotube/collections/spotify_markets.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/extensions/image.dart'; +import 'package:spotube/provider/spotify/spotify.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(meProvider); + 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: spotifyMarkets + .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.displayName ?? context.l10n.no_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( + "https://www.spotify.com/account/profile/", + 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 index aaf3e30a..65b97d4f 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -1,209 +1,61 @@ -import 'dart:async'; - -import 'package:fl_query/fl_query.dart'; -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/player/player_queue.dart'; -import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart'; -import 'package:spotube/components/root/bottom_player.dart'; -import 'package:spotube/components/root/sidebar.dart'; -import 'package:spotube/components/root/spotube_navigation_bar.dart'; -import 'package:spotube/extensions/context.dart'; +import 'package:shadcn_flutter/shadcn_flutter.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/hooks/configurators/use_update_checker.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/utils/persisted_state_notifier.dart'; +import 'package:spotube/modules/root/use_downloader_dialogs.dart'; +import 'package:spotube/modules/root/use_global_subscriptions.dart'; +import 'package:spotube/provider/glance/glance.dart'; -const rootPaths = { - "/": 0, - "/search": 1, - "/library": 2, - "/lyrics": 3, -}; - -class RootApp extends HookConsumerWidget { - final Widget child; - const RootApp({ - required this.child, - Key? key, - }) : super(key: key); +@RoutePage() +class RootAppPage extends HookConsumerWidget { + const RootAppPage({super.key}); @override Widget build(BuildContext context, ref) { - final isMounted = useIsMounted(); - final showingDialogCompleter = useRef(Completer()..complete()); - final downloader = ref.watch(downloadManagerProvider); - final scaffoldMessenger = ScaffoldMessenger.of(context); - final theme = Theme.of(context); - final location = GoRouterState.of(context).matchedLocation; + final backgroundColor = Theme.of(context).colorScheme.background; + final brightness = Theme.of(context).brightness; - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) async { - final sharedPreferences = await SharedPreferences.getInstance(); - - if (sharedPreferences.getBool(kIsUsingEncryption) == false && - context.mounted) { - await PersistedStateNotifier.showNoEncryptionDialog(context); - } - }); - - final subscription = - QueryClient.connectivity.onConnectivityChanged.listen((status) { - if (status) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Row( - children: [ - Icon( - SpotubeIcons.wifi, - color: theme.colorScheme.onPrimary, - ), - const SizedBox(width: 10), - Text(context.l10n.connection_restored), - ], - ), - backgroundColor: theme.colorScheme.primary, - showCloseIcon: true, - width: 350, - ), - ); - } else { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Row( - children: [ - Icon( - SpotubeIcons.noWifi, - color: theme.colorScheme.onError, - ), - const SizedBox(width: 10), - Text(context.l10n.you_are_offline), - ], - ), - backgroundColor: theme.colorScheme.error, - showCloseIcon: true, - width: 300, - ), - ); - } - }); - - return () { - subscription.cancel(); - }; - }, []); - - useEffect(() { - downloader.onFileExists = (track) async { - if (!isMounted()) return false; - - if (!showingDialogCompleter.value.isCompleted) { - await showingDialogCompleter.value.future; - } - - final replaceAll = ref.read(replaceDownloadedFileState); - - if (replaceAll != null) return replaceAll; - - showingDialogCompleter.value = Completer(); - - if (context.mounted) { - final result = await showDialog( - context: context, - builder: (context) => ReplaceDownloadedDialog( - track: track, - ), - ) ?? - false; - - showingDialogCompleter.value.complete(); - return result; - } - - // it'll never reach here as root_app is always mounted - return false; - }; - return null; - }, [downloader]); - - // checks for latest version of the application - useUpdateChecker(ref); + ref.listen(glanceProvider, (_, __) {}); + useGlobalSubscriptions(ref); + useDownloaderDialogs(ref); useEndlessPlayback(ref); - - final backgroundColor = Theme.of(context).scaffoldBackgroundColor; + useCheckYtDlpInstalled(ref); useEffect(() { SystemChrome.setSystemUIOverlayStyle( SystemUiOverlayStyle( statusBarColor: backgroundColor, // status bar color - statusBarIconBrightness: backgroundColor.computeLuminance() > 0.179 - ? Brightness.dark - : Brightness.light, + statusBarIconBrightness: brightness == Brightness.dark + ? Brightness.light + : Brightness.dark, ), ); return null; - }, [backgroundColor]); + }, [backgroundColor, brightness]); - void onSelectIndexChanged(int d) { - final invertedRouteMap = - rootPaths.map((key, value) => MapEntry(value, key)); - - if (context.mounted) { - WidgetsBinding.instance.addPostFrameCallback((_) { - GoRouter.of(context).go(invertedRouteMap[d]!); - }); - } - } - - return WillPopScope( - onWillPop: () async { - if (rootPaths[location] != 0) { - onSelectIndexChanged(0); - return false; - } - return true; - }, - child: Scaffold( - body: Sidebar( - selectedIndex: rootPaths[location], - onSelectedIndexChanged: onSelectIndexChanged, - child: child, - ), - extendBody: true, - drawerScrimColor: Colors.transparent, - endDrawer: DesktopTools.platform.isDesktop - ? Container( - constraints: const BoxConstraints(maxWidth: 800), - decoration: BoxDecoration( - boxShadow: theme.brightness == Brightness.light - ? null - : kElevationToShadow[8], - ), - margin: const EdgeInsets.only( - top: 40, - bottom: 100, - ), - child: const PlayerQueue(floating: true), - ) - : null, - bottomNavigationBar: Column( - mainAxisSize: MainAxisSize.min, - children: [ + final scaffold = MediaQuery.removeViewInsets( + context: context, + removeBottom: true, + child: const SafeArea( + top: false, + child: Scaffold( + footers: [ BottomPlayer(), - SpotubeNavigationBar( - selectedIndex: rootPaths[location], - onSelectedIndexChanged: onSelectIndexChanged, - ), + SpotubeNavigationBar(), ], + floatingFooter: true, + child: Sidebar(child: AutoRouter()), ), ), ); + + return scaffold; } } diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index f4a78d4f..25fb046a 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -1,197 +1,251 @@ -import 'dart:async'; - -import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_undraw/flutter_undraw.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.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/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; import 'package:spotube/pages/search/sections/albums.dart'; import 'package:spotube/pages/search/sections/artists.dart'; import 'package:spotube/pages/search/sections/playlists.dart'; import 'package:spotube/pages/search/sections/tracks.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; - -import 'package:spotube/utils/platform.dart'; -import 'package:collection/collection.dart'; - -final searchTermStateProvider = StateProvider((ref) => ""); +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class SearchPage extends HookConsumerWidget { - const SearchPage({Key? key}) : super(key: key); + static const name = "search"; + + const SearchPage({super.key}); @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); - ref.watch(AuthenticationNotifier.provider); - final authenticationNotifier = - ref.watch(AuthenticationNotifier.provider.notifier); - final mediaQuery = MediaQuery.of(context); + final mediaQuery = MediaQuery.sizeOf(context); + + final scrollController = useScrollController(); + final controller = useShadcnTextEditingController(); + final focusNode = useFocusNode(); + + final auth = ref.watch(authenticationProvider); final searchTerm = ref.watch(searchTermStateProvider); - - final searchTrack = - useQueries.search.query(ref, searchTerm, SearchType.track); - final searchAlbum = - useQueries.search.query(ref, searchTerm, SearchType.album); - final searchPlaylist = - useQueries.search.query(ref, searchTerm, SearchType.playlist); - final searchArtist = - useQueries.search.query(ref, searchTerm, SearchType.artist); - - Future onSearch() async { - await Future.wait([ - searchTrack.reset(), - searchAlbum.reset(), - searchPlaylist.reset(), - searchArtist.reset(), - ]).then((_) { - return Future.wait([ - searchTrack.refreshAll(), - searchAlbum.refreshAll(), - searchPlaylist.refreshAll(), - searchArtist.refreshAll(), - ]); - }); - } + final searchTrack = ref.watch(searchProvider(SearchType.track)); + final searchAlbum = ref.watch(searchProvider(SearchType.album)); + final searchPlaylist = ref.watch(searchProvider(SearchType.playlist)); + final searchArtist = ref.watch(searchProvider(SearchType.artist)); final queries = [searchTrack, searchAlbum, searchPlaylist, searchArtist]; - final isFetching = queries.every( - (s) => - (!s.hasPageData && !s.hasPageError) || - s.isRefreshingPage || - !s.hasPageData, - ) && - searchTerm.isNotEmpty; - final resultWidget = HookBuilder( - builder: (context) { - final controller = useScrollController(); + final isFetching = queries.every((s) => s.isLoading); - return InterScrollbar( - controller: controller, - child: SingleChildScrollView( - controller: controller, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SearchTracksSection(query: searchTrack), - SearchPlaylistsSection(query: searchPlaylist), - const SizedBox(height: 20), - SearchArtistsSection(query: searchArtist), - const SizedBox(height: 20), - SearchAlbumsSection(query: searchAlbum), - ], - ), - ), - ), - ), - ); + useEffect(() { + controller.text = searchTerm; + + return null; + }, []); + + void onSubmitted(String value) { + ref.read(searchTermStateProvider.notifier).state = value; + 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: auth.asData?.value == null + ? const AnonymousFallback() + : Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(20), + 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 SafeArea( - bottom: false, - child: Scaffold( - appBar: kIsDesktop && !kIsMacOS ? const PageWindowTitleBar() : null, - body: !authenticationNotifier.isLoggedIn - ? const AnonymousFallback() - : Column( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 10, + return KeyboardListener( + focusNode: focusNode, + autofocus: true, + onKeyEvent: (value) { + final isEnter = value.logicalKey == + LogicalKeyboardKey.enter; + + if (isEnter) { + onSubmitted(controller.text); + focusNode.unfocus(); + } + }, + child: AutoComplete( + suggestions: suggestions.length <= 2 + ? [ + ...suggestions, + "Twenty One Pilots", + "Linkin Park", + "d4vd" + ] + : suggestions, + completer: (suggestion) => suggestion, + mode: AutoCompleteMode.replaceAll, + child: TextField( + autofocus: true, + controller: controller, + 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, + ), + ), + ); + }), + ), + ), + ], ), - color: theme.scaffoldBackgroundColor, - child: TextField( - autofocus: queries - .none((s) => s.hasPageData && !s.hasPageError) && - !kIsMobile, - decoration: InputDecoration( - prefixIcon: const Icon(SpotubeIcons.search), - hintText: "${context.l10n.search}...", - ), - onSubmitted: (value) async { - ref.read(searchTermStateProvider.notifier).state = - value; - // Fl-Query is too fast, so we need to delay the search - // to prevent spamming the API :) - Timer(const Duration(milliseconds: 50), () { - onSearch(); - }); - }, - ), - ), - Expanded( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: searchTerm.isEmpty - ? Column( + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: switch ((searchTerm.isEmpty, isFetching)) { + (true, false) => Column( children: [ SizedBox( - height: mediaQuery.size.height * 0.2, + height: mediaQuery.height * 0.2, ), - Icon( - SpotubeIcons.web, - size: 120, - color: theme.colorScheme.onBackground - .withOpacity(0.7), + Undraw( + illustration: UndrawIllustration.explore, + color: theme.colorScheme.primary, + height: 200 * theme.scaling, ), const SizedBox(height: 20), - Text( - context.l10n.search_to_get_results, - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w900, - color: theme.colorScheme.onBackground - .withOpacity(0.5), + 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(), + ], + ), + ), + _ => 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(), + ], + ), ), ), - ], - ) - : isFetching - ? Container( - constraints: BoxConstraints( - maxWidth: mediaQuery.lgAndUp - ? mediaQuery.size.width * 0.5 - : mediaQuery.size.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.onBackground - .withOpacity(0.7), - ), - ), - const SizedBox(height: 20), - const LinearProgressIndicator(), - ], - ), - ) - : resultWidget, + ), + ), + }, + ), ), - ), - ], - ), + ], + ), + ), ), ); } diff --git a/lib/pages/search/sections/albums.dart b/lib/pages/search/sections/albums.dart index 8aa33feb..105c23d5 100644 --- a/lib/pages/search/sections/albums.dart +++ b/lib/pages/search/sections/albums.dart @@ -1,39 +1,37 @@ -import 'package:fl_query/fl_query.dart'; - -import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class SearchAlbumsSection extends HookConsumerWidget { - final InfiniteQuery>, dynamic, int> query; const SearchAlbumsSection({ - required this.query, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { + final query = ref.watch(searchProvider(SearchType.album)); + final notifier = ref.watch(searchProvider(SearchType.album).notifier); final albums = useMemoized( - () => query.pages - .expand( - (page) => page.map((p) => p.items!).expand((element) => element), - ) - .whereType() - .map((e) => TypeConversionUtils.simpleAlbum_X_Album(e)) - .toList(), - [query.pages], + () => + query.asData?.value.items + .cast() + .map((e) => e.toAlbum()) + .toList() ?? + [], + [query.asData?.value], ); return HorizontalPlaybuttonCardView( isLoadingNextPage: query.isLoadingNextPage, - hasNextPage: query.hasNextPage, + hasNextPage: query.asData?.value.hasMore == true, items: albums, - onFetchMore: query.fetchNext, + onFetchMore: notifier.fetchMore, title: Text(context.l10n.albums), ); } diff --git a/lib/pages/search/sections/artists.dart b/lib/pages/search/sections/artists.dart index fe4459d6..9a94b3c1 100644 --- a/lib/pages/search/sections/artists.dart +++ b/lib/pages/search/sections/artists.dart @@ -1,37 +1,28 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class SearchArtistsSection extends HookConsumerWidget { - final InfiniteQuery>, dynamic, int> query; - const SearchArtistsSection({ - Key? key, - required this.query, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { - final artists = useMemoized( - () => query.pages - .expand( - (page) => page.map((p) => p.items!).expand((element) => element), - ) - .whereType() - .toList(), - [query.pages], - ); + final query = ref.watch(searchProvider(SearchType.artist)); + final notifier = ref.watch(searchProvider(SearchType.artist).notifier); + + final artists = query.asData?.value.items.cast() ?? []; return HorizontalPlaybuttonCardView( isLoadingNextPage: query.isLoadingNextPage, - hasNextPage: query.hasNextPage, + hasNextPage: query.asData?.value.hasMore == true, items: artists, - onFetchMore: query.fetchNext, + onFetchMore: notifier.fetchMore, title: Text(context.l10n.artists), ); } diff --git a/lib/pages/search/sections/playlists.dart b/lib/pages/search/sections/playlists.dart index 47614a70..17bf4849 100644 --- a/lib/pages/search/sections/playlists.dart +++ b/lib/pages/search/sections/playlists.dart @@ -1,35 +1,28 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class SearchPlaylistsSection extends HookConsumerWidget { - final InfiniteQuery>, dynamic, int> query; const SearchPlaylistsSection({ - required this.query, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { - final playlists = useMemoized( - () => query.pages - .expand( - (page) => page.map((p) => p.items!).expand((element) => element), - ) - .whereType() - .toList(), - [query.pages], - ); + final playlistsQuery = ref.watch(searchProvider(SearchType.playlist)); + final playlistsQueryNotifier = + ref.watch(searchProvider(SearchType.playlist).notifier); + final playlists = + playlistsQuery.asData?.value.items.cast() ?? []; return HorizontalPlaybuttonCardView( - isLoadingNextPage: query.isLoadingNextPage, - hasNextPage: query.hasNextPage, + isLoadingNextPage: playlistsQuery.isLoadingNextPage, + hasNextPage: playlistsQuery.asData?.value.hasMore == true, items: playlists, - onFetchMore: query.fetchNext, + onFetchMore: playlistsQueryNotifier.fetchMore, title: Text(context.l10n.playlists), ); } diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart index e77cd8f2..bacbbb57 100644 --- a/lib/pages/search/sections/tracks.dart +++ b/lib/pages/search/sections/tracks.dart @@ -1,34 +1,32 @@ import 'package:collection/collection.dart'; -import 'package:fl_query/fl_query.dart'; -import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; -import 'package:spotube/components/shared/track_tile/track_tile.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/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class SearchTracksSection extends HookConsumerWidget { - final InfiniteQuery>, dynamic, int> query; const SearchTracksSection({ - Key? key, - required this.query, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { - final searchTrack = query; - final tracks = useMemoized( - () => searchTrack.pages - .expand( - (page) => page.map((p) => p.items!).expand((element) => element), - ) - .whereType(), - [searchTrack.pages], - ); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.provider.notifier); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final searchTrack = ref.watch(searchProvider(SearchType.track)); + + final searchTrackNotifier = + ref.watch(searchProvider(SearchType.track).notifier); + + final tracks = searchTrack.asData?.value.items.cast() ?? []; + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + final playlist = ref.watch(audioPlayerProvider); final theme = Theme.of(context); return Column( @@ -40,53 +38,85 @@ class SearchTracksSection extends HookConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( context.l10n.songs, - style: theme.textTheme.titleLarge!, + style: theme.typography.h4, ), ), - if (!searchTrack.hasPageData && - !searchTrack.hasPageError && - !searchTrack.isLoadingNextPage) + if (searchTrack.isLoading) const CircularProgressIndicator() - else if (searchTrack.hasPageError) - Text( - searchTrack.errors.lastOrNull?.toString() ?? "", - ) + else if (searchTrack.hasError) + Text(searchTrack.error.toString()) else ...tracks.mapIndexed((i, track) { return TrackTile( index: i, track: track, + playlist: playlist, onTap: () async { - 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; + final isRemoteDevice = + await showSelectDeviceDialog(context, ref); - if (shouldPlay) { - await playlistNotifier.load( - [track], - autoPlay: true, - ); + 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, + ); + } } } }, ); }), - if (searchTrack.hasNextPage && tracks.isNotEmpty) + if (searchTrack.asData?.value.hasMore == true && tracks.isNotEmpty) Center( child: TextButton( onPressed: searchTrack.isLoadingNextPage ? null - : () => searchTrack.fetchNext(), + : searchTrackNotifier.fetchMore, child: searchTrack.isLoadingNextPage ? const CircularProgressIndicator() : Text(context.l10n.load_more), diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart index 00263680..27775f3c 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -1,22 +1,27 @@ -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/links/hyper_link.dart'; -import 'package:spotube/components/shared/page_window_title_bar.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:flutter_svg/flutter_svg.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"); }); -class AboutSpotube extends HookConsumerWidget { - const AboutSpotube({Key? key}) : super(key: key); +@RoutePage() +class AboutSpotubePage extends HookConsumerWidget { + static const name = "about"; + + const AboutSpotubePage({super.key}); @override Widget build(BuildContext context, ref) { @@ -24,204 +29,180 @@ class AboutSpotube extends HookConsumerWidget { final license = ref.watch(_licenseProvider); final theme = Theme.of(context); - const colon = Text(":"); + const colon = TableCell(child: Text(":")); - return Scaffold( - appBar: PageWindowTitleBar( - leading: const BackButton(), - title: Text(context.l10n.about_spotube), - ), - body: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - children: [ - Assets.spotubeLogoPng.image( - height: 200, - width: 200, - ), - Center( - child: Column( - children: [ - Text( - context.l10n.spotube_description, - style: theme.textTheme.titleLarge, - ), - const SizedBox(height: 20), - Table( - columnWidths: const { - 0: FixedColumnWidth(95), - 1: FixedColumnWidth(10), - 2: IntrinsicColumnWidth(), - }, - children: [ - TableRow( - children: [ - Text(context.l10n.founder), - colon, - Hyperlink( - context.l10n.kingkor_roy_tirtho, - "https://github.com/KRTirtho", - ) - ], - ), - TableRow( - children: [ - Text(context.l10n.version), - colon, - Text("v${packageInfo.version}") - ], - ), - TableRow( - children: [ - Text(context.l10n.build_number), - colon, - Text(packageInfo.buildNumber.replaceAll(".", " ")) - ], - ), - TableRow( - children: [ - Text(context.l10n.repository), - colon, - const Hyperlink( - "github.com/KRTirtho/spotube", - "https://github.com/KRTirtho/spotube", - ), - ], - ), - TableRow( - children: [ - Text(context.l10n.license), - colon, - const Hyperlink( - "BSD-4-Clause", - "https://raw.githubusercontent.com/KRTirtho/spotube/master/LICENSE", - ), - ], - ), - TableRow( - children: [ - Text(context.l10n.bug_issues), - colon, - const Hyperlink( - "github.com/KRTirtho/spotube/issues", - "https://github.com/KRTirtho/spotube/issues", - ), - ], - ), - ], - ), - ], + 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.spotubeLogoPng.image( + height: 200, + width: 200, ), - ), - 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), - Wrap( - runSpacing: 20, - spacing: 20, - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - runAlignment: WrapAlignment.center, - children: [ - MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () { - launchUrl( - Uri.parse("https://www.buymeacoffee.com/krtirtho"), - mode: LaunchMode.externalApplication, - ); - }, - child: SvgPicture.network( - "https://img.buymeacoffee.com/button-api/?text=Buy me a coffee&emoji=&slug=krtirtho&button_colour=FF5F5F&font_colour=ffffff&font_family=Inter&outline_colour=000000&coffee_colour=FFDD00", - height: 45, - ), - ), - ), - MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () { - launchUrl( - Uri.parse( - "https://opencollective.com/spotube", + 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", + ), + ) + ], ), - mode: LaunchMode.externalApplication, - ); - }, - child: Image.network( - "https://opencollective.com/spotube/donate/button.png?color=blue", - height: 45, + 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", + ), + ), + ], + ), + ], ), - ), - ), - MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () { - launchUrl( - Uri.parse("https://patreon.com/krtirtho"), - mode: LaunchMode.externalApplication, - ); - }, - child: Image.network( - "https://user-images.githubusercontent.com/61944859/180249027-678b01b8-c336-451e-b147-6d84a5b9d0e7.png", - height: 45, - ), - ), - ), - ], - ), - const SizedBox(height: 20), - Text( - context.l10n.made_with, - textAlign: TextAlign.center, - style: theme.textTheme.bodySmall, - ), - Text( - context.l10n.copyright(DateTime.now().year), - textAlign: TextAlign.center, - style: theme.textTheme.bodySmall, - ), - const SizedBox(height: 20), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 750), - child: SafeArea( - child: license.when( - data: (data) { - return Text( - data, - style: theme.textTheme.bodySmall, - ); - }, - loading: () { - return const Center( - child: CircularProgressIndicator(), - ); - }, - error: (e, s) { - return Text( - e.toString(), - style: theme.textTheme.bodySmall, - ); - }, + ], ), ), - ), - ], + 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 index b4ce5044..8ac2c1b9 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -1,88 +1,98 @@ -import 'package:flutter/material.dart'; 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/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/page_window_title_bar.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 { - const BlackListPage({Key? key}) : super(key: key); + static const name = "blacklist"; + + const BlackListPage({super.key}); @override Widget build(BuildContext context, ref) { final controller = useScrollController(); - final blacklist = ref.watch(BlackListNotifier.provider); + final blacklist = ref.watch(blacklistProvider); final searchText = useState(""); final filteredBlacklist = useMemoized( () { if (searchText.value.isEmpty) { - return blacklist; + return blacklist.asData?.value ?? []; } - return blacklist - .map( - (e) => ( - weightedRatio("${e.name} ${e.type.name}", searchText.value), - e, - ), - ) - .sorted((a, b) => b.$1.compareTo(a.$1)) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList(); + 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 Scaffold( - appBar: PageWindowTitleBar( - title: Text(context.l10n.blacklist), - centerTitle: true, - leading: const BackButton(), - ), - body: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: TextField( - onChanged: (value) => searchText.value = value, - decoration: InputDecoration( - hintText: context.l10n.search, - prefixIcon: const Icon(SpotubeIcons.search), + 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), + leading: const Icon(SpotubeIcons.search), ), ), - ), - InterScrollbar( - controller: controller, - child: ListView.builder( + InterScrollbar( controller: controller, - shrinkWrap: true, - itemCount: filteredBlacklist.length, - itemBuilder: (context, index) { - final item = filteredBlacklist.elementAt(index); - return ListTile( - leading: Text("${index + 1}."), - title: Text("${item.name} (${item.type.name})"), - subtitle: Text(item.id), - trailing: IconButton( - icon: Icon(SpotubeIcons.trash, color: Colors.red[400]), - onPressed: () { - ref - .read(BlackListNotifier.provider.notifier) - .remove(filteredBlacklist.elementAt(index)); - }, - ), - ); - }, + 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 index cfb28d18..4985b57a 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -1,145 +1,102 @@ -import 'dart:async'; -import 'dart:io'; -import 'package:flutter/material.dart'; 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/settings/section_card_with_heading.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/page_window_title_bar.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/models/logger.dart'; +import 'package:spotube/provider/logs/logs_provider.dart'; +import 'package:spotube/services/logger/logger.dart'; +import 'package:auto_route/auto_route.dart'; -class LogsPage extends HookWidget { - const LogsPage({Key? key}) : super(key: key); +@RoutePage() +class LogsPage extends HookConsumerWidget { + static const name = "logs"; - List<({DateTime? date, String body})> parseLogs(String raw) { - return raw - .split( - "======================================================================", - ) - .map( - (line) { - DateTime? date; - line = line - .replaceAll( - "============================== CATCHER LOG ==============================", - "", - ) - .split("\n") - .map((l) { - if (l.startsWith("Crash occurred on")) { - date = DateTime.parse( - l.split("Crash occurred on")[1].trim(), - ); - return ""; - } - return l; - }) - .where((l) => l.replaceAll("\n", "").trim().isNotEmpty) - .join("\n"); - - return ( - date: date, - body: line, - ); - }, - ) - .where((e) => e.date != null && e.body.isNotEmpty) - .toList() - ..sort((a, b) => b.date!.compareTo(a.date!)); - } + const LogsPage({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, ref) { final controller = useScrollController(); - final logs = useState>([]); - final rawLogs = useRef(""); - final path = useRef(null); - useEffect(() { - final timer = Timer.periodic(const Duration(seconds: 5), (t) async { - path.value ??= await getLogsPath(); - final raw = await path.value!.readAsString(); - final hasChanged = rawLogs.value != raw; - rawLogs.value = raw; - if (hasChanged) logs.value = parseLogs(rawLogs.value); - }); - - return () { - timer.cancel(); - }; - }, []); + final logsQuery = ref.watch(logsProvider); return Scaffold( - appBar: PageWindowTitleBar( - title: Text(context.l10n.logs), - leading: const BackButton(), - actions: [ - IconButton( - icon: const Icon(SpotubeIcons.clipboard), - iconSize: 16, - onPressed: () async { - await Clipboard.setData(ClipboardData(text: rawLogs.value)); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.copied_to_clipboard("")), - ), - ); - } - }, - ), - ], - ), - body: SafeArea( - child: InterScrollbar( - controller: controller, - child: ListView.builder( - controller: controller, - itemCount: logs.value.length, - itemBuilder: (context, index) { - final log = logs.value[index]; - return Stack( - children: [ - SectionCardWithHeading( - heading: log.date.toString(), - children: [ - Padding( - padding: const EdgeInsets.all(12.0), - child: SelectableText(log.body), - ), - ], - ), - Positioned( - right: 10, - top: 0, - child: IconButton( - icon: const Icon(SpotubeIcons.clipboard), - onPressed: () async { - await Clipboard.setData( - ClipboardData(text: log.body), + 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("")), + ), ); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.copied_to_clipboard( - log.date.toString(), - ), - ), - ), - ); - } }, - ), - ), - ], - ); - }, + ); + } + }, + ), + IconButton.ghost( + icon: const Icon( + SpotubeIcons.trash, + size: 16, + ), + onPressed: () async { + ref.invalidate(logsProvider); + + final logsFile = await AppLogger.getLogsPath(); + + await logsFile.writeAsString(""); + }, + ) + ], ), - ), - ), + ) + ], + 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/sections/about.dart b/lib/pages/settings/sections/about.dart index 9fe59662..82c98e90 100644 --- a/lib/pages/settings/sections/about.dart +++ b/lib/pages/settings/sections/about.dart @@ -1,17 +1,20 @@ +import 'package:auto_route/auto_route.dart'; import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.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/components/settings/section_card_with_heading.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_list_tile.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({Key? key}) : super(key: key); + const SettingsAboutSection({super.key}); @override Widget build(BuildContext context, ref) { @@ -21,62 +24,73 @@ class SettingsAboutSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.about, children: [ - 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, + 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) => FilledButton( - style: ButtonStyle( - backgroundColor: MaterialStatePropertyAll(Colors.red[100]), - foregroundColor: - const MaterialStatePropertyAll(Colors.pinkAccent), - padding: const MaterialStatePropertyAll(EdgeInsets.all(15)), - ), - onPressed: () { - launchUrlString( - "https://opencollective.com/spotube", - mode: LaunchMode.externalApplication, - ); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(SpotubeIcons.heart), - const SizedBox(width: 5), - Text(context.l10n.please_sponsor), - ], + 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) - SwitchListTile( - secondary: const Icon(SpotubeIcons.update), + ListTile( + leading: const Icon(SpotubeIcons.update), title: Text(context.l10n.check_for_updates), - value: preferences.checkUpdate, - onChanged: (checked) => preferencesNotifier.setCheckUpdate(checked), + 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: () { - GoRouter.of(context).push("/settings/about"); + context.navigateTo(const AboutSpotubeRoute()); }, ) ], diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index 83740866..a381dc23 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -1,33 +1,58 @@ +import 'package:auto_route/auto_route.dart'; import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.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/settings/section_card_with_heading.dart'; +import 'package:spotube/modules/settings/section_card_with_heading.dart'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/scrobbler_provider.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/mobile_login/hooks/login_callback.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/scrobbler/scrobbler.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class SettingsAccountSection extends HookConsumerWidget { - const SettingsAccountSection({Key? key}) : super(key: key); + const SettingsAccountSection({super.key}); @override Widget build(context, ref) { final theme = Theme.of(context); - final auth = ref.watch(AuthenticationNotifier.provider); - final scrobbler = ref.watch(scrobblerProvider); - final router = GoRouter.of(context); - final logoutBtnStyle = FilledButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - ); + final auth = ref.watch(authenticationProvider); + final scrobbler = ref.watch(scrobblerProvider); + final me = ref.watch(meProvider); + final meData = me.asData?.value; + + final onLogin = useLoginCallback(ref); return SectionCardWithHeading( heading: context.l10n.account, children: [ - if (auth == null) + if (auth.asData?.value != null) + ListTile( + leading: const Icon(SpotubeIcons.user), + title: Text(context.l10n.user_profile), + trailing: Padding( + padding: const EdgeInsets.all(8.0), + child: Avatar( + initials: Avatar.getInitials(meData?.displayName ?? "User"), + provider: UniversalImage.imageProvider( + (meData?.images).asUrlString( + placeholder: ImagePlaceholder.artist, + ), + ), + ), + ), + onTap: () { + context.navigateTo(const ProfileRoute()); + }, + ), + if (auth.asData?.value == null) LayoutBuilder(builder: (context, constrains) { return ListTile( leading: Icon( @@ -44,24 +69,11 @@ class SettingsAccountSection extends HookConsumerWidget { ), ), ), - onTap: constrains.mdAndUp - ? null - : () { - router.push("/login"); - }, + onTap: constrains.mdAndUp ? null : onLogin, trailing: constrains.smAndDown ? null - : FilledButton( - onPressed: () { - router.push("/login"); - }, - style: ButtonStyle( - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(25.0), - ), - ), - ), + : Button.primary( + onPressed: onLogin, child: Text( context.l10n.connect_with_spotify.toUpperCase(), ), @@ -83,42 +95,36 @@ class SettingsAccountSection extends HookConsumerWidget { ), ), ), - trailing: FilledButton( - style: logoutBtnStyle, + trailing: Button.destructive( onPressed: () async { - ref.read(AuthenticationNotifier.provider.notifier).logout(); - GoRouter.of(context).pop(); + ref.read(authenticationProvider.notifier).logout(); + context.maybePop(); }, child: Text(context.l10n.logout), ), ); }), - if (scrobbler == null) + if (scrobbler.asData?.value == null) ListTile( leading: const Icon(SpotubeIcons.lastFm), title: Text(context.l10n.login_with_lastfm), subtitle: Text(context.l10n.scrobble_to_lastfm), - trailing: FilledButton.icon( - icon: const Icon(SpotubeIcons.lastFm), - label: Text(context.l10n.connect), + trailing: Button.secondary( + leading: const Icon(SpotubeIcons.lastFm), onPressed: () { - router.push("/lastfm-login"); + context.navigateTo(const LastFMLoginRoute()); }, - style: FilledButton.styleFrom( - backgroundColor: const Color.fromARGB(255, 186, 0, 0), - foregroundColor: Colors.white, - ), + child: Text(context.l10n.connect), ), ) else ListTile( leading: const Icon(SpotubeIcons.lastFm), title: Text(context.l10n.disconnect_lastfm), - trailing: FilledButton( + trailing: Button.destructive( onPressed: () { ref.read(scrobblerProvider.notifier).logout(); }, - style: logoutBtnStyle, child: Text(context.l10n.disconnect), ), ), diff --git a/lib/pages/settings/sections/appearance.dart b/lib/pages/settings/sections/appearance.dart index 3d941212..88f39a25 100644 --- a/lib/pages/settings/sections/appearance.dart +++ b/lib/pages/settings/sections/appearance.dart @@ -1,21 +1,21 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show ListTile; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; -import 'package:spotube/components/settings/section_card_with_heading.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_select_tile.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'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; class SettingsAppearanceSection extends HookConsumerWidget { final bool isGettingStarted; const SettingsAppearanceSection({ - Key? key, + super.key, this.isGettingStarted = false, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { @@ -41,15 +41,15 @@ class SettingsAppearanceSection extends HookConsumerWidget { } }, options: [ - DropdownMenuItem( + SelectItemButton( value: LayoutMode.adaptive, child: Text(context.l10n.adaptive), ), - DropdownMenuItem( + SelectItemButton( value: LayoutMode.compact, child: Text(context.l10n.compact), ), - DropdownMenuItem( + SelectItemButton( value: LayoutMode.extended, child: Text(context.l10n.extended), ), @@ -60,15 +60,15 @@ class SettingsAppearanceSection extends HookConsumerWidget { title: Text(context.l10n.theme), value: preferences.themeMode, options: [ - DropdownMenuItem( + SelectItemButton( value: ThemeMode.dark, child: Text(context.l10n.dark), ), - DropdownMenuItem( + SelectItemButton( value: ThemeMode.light, child: Text(context.l10n.light), ), - DropdownMenuItem( + SelectItemButton( value: ThemeMode.system, child: Text(context.l10n.system), ), @@ -79,13 +79,14 @@ class SettingsAppearanceSection extends HookConsumerWidget { } }, ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.amoled), - title: Text(context.l10n.use_amoled_mode), - subtitle: Text(context.l10n.pitch_dark_theme), - value: preferences.amoledDarkTheme, - onChanged: preferencesNotifier.setAmoledDarkTheme, - ), + // 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), @@ -93,20 +94,22 @@ class SettingsAppearanceSection extends HookConsumerWidget { horizontal: 15, vertical: 5, ), - trailing: ColorTile.compact( + trailing: ColorChip( color: preferences.accentColorScheme, + name: preferences.accentColorScheme.name, onPressed: pickColorScheme(), - isActive: true, + isActive: false, ), onTap: pickColorScheme(), ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.colorSync), - title: Text(context.l10n.sync_album_color), - subtitle: Text(context.l10n.sync_album_color_description), - value: preferences.albumColorSync, - onChanged: preferencesNotifier.setAlbumColorSync, - ), + // 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) { diff --git a/lib/pages/settings/sections/desktop.dart b/lib/pages/settings/sections/desktop.dart index ae721fc4..ad45c689 100644 --- a/lib/pages/settings/sections/desktop.dart +++ b/lib/pages/settings/sections/desktop.dart @@ -1,15 +1,15 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.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/spotube_icons.dart'; -import 'package:spotube/components/settings/section_card_with_heading.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_select_tile.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'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; class SettingsDesktopSection extends HookConsumerWidget { - const SettingsDesktopSection({Key? key}) : super(key: key); + const SettingsDesktopSection({super.key}); @override Widget build(BuildContext context, ref) { @@ -19,16 +19,17 @@ class SettingsDesktopSection extends HookConsumerWidget { 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: [ - DropdownMenuItem( + SelectItemButton( value: CloseBehavior.close, child: Text(context.l10n.close), ), - DropdownMenuItem( + SelectItemButton( value: CloseBehavior.minimizeToTray, child: Text(context.l10n.minimize_to_tray), ), @@ -39,25 +40,30 @@ class SettingsDesktopSection extends HookConsumerWidget { } }, ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.tray), + ListTile( + leading: const Icon(SpotubeIcons.tray), title: Text(context.l10n.show_tray_icon), - value: preferences.showSystemTrayIcon, - onChanged: preferencesNotifier.setShowSystemTrayIcon, + trailing: Switch( + value: preferences.showSystemTrayIcon, + onChanged: preferencesNotifier.setShowSystemTrayIcon, + ), ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.window), + ListTile( + leading: const Icon(SpotubeIcons.window), title: Text(context.l10n.use_system_title_bar), - value: preferences.systemTitleBar, - onChanged: preferencesNotifier.setSystemTitleBar, + trailing: Switch( + value: preferences.systemTitleBar, + onChanged: preferencesNotifier.setSystemTitleBar, + ), ), - if (!DesktopTools.platform.isMacOS) - SwitchListTile( - secondary: const Icon(SpotubeIcons.discord), - title: Text(context.l10n.discord_rich_presence), + 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 index 4b5f58a6..0862e023 100644 --- a/lib/pages/settings/sections/developers.dart +++ b/lib/pages/settings/sections/developers.dart @@ -1,12 +1,15 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart' show ListTile; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.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/settings/section_card_with_heading.dart'; +import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/extensions/context.dart'; class SettingsDevelopersSection extends HookWidget { - const SettingsDevelopersSection({Key? key}) : super(key: key); + const SettingsDevelopersSection({super.key}); @override Widget build(BuildContext context) { @@ -18,7 +21,7 @@ class SettingsDevelopersSection extends HookWidget { title: Text(context.l10n.logs), trailing: const Icon(SpotubeIcons.angleRight), onTap: () { - GoRouter.of(context).push("/settings/logs"); + context.navigateTo(const LogsRoute()); }, ) ], diff --git a/lib/pages/settings/sections/downloads.dart b/lib/pages/settings/sections/downloads.dart index b1e360d0..516d2aca 100644 --- a/lib/pages/settings/sections/downloads.dart +++ b/lib/pages/settings/sections/downloads.dart @@ -1,16 +1,17 @@ import 'package:file_picker/file_picker.dart'; import 'package:file_selector/file_selector.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.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/components/settings/section_card_with_heading.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({Key? key}) : super(key: key); + const SettingsDownloadsSection({super.key}); @override Widget build(BuildContext context, ref) { @@ -18,7 +19,7 @@ class SettingsDownloadsSection extends HookConsumerWidget { final preferences = ref.watch(userPreferencesProvider); final pickDownloadLocation = useCallback(() async { - if (DesktopTools.platform.isMobile || DesktopTools.platform.isMacOS) { + if (kIsMobile || kIsMacOS) { final dirStr = await FilePicker.platform.getDirectoryPath( initialDirectory: preferences.downloadLocation, ); @@ -40,9 +41,9 @@ class SettingsDownloadsSection extends HookConsumerWidget { leading: const Icon(SpotubeIcons.download), title: Text(context.l10n.download_location), subtitle: Text(preferences.downloadLocation), - trailing: FilledButton( + trailing: IconButton.secondary( onPressed: pickDownloadLocation, - child: const Icon(SpotubeIcons.folder), + 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 index fbfe1030..44b364af 100644 --- a/lib/pages/settings/sections/language_region.dart +++ b/lib/pages/settings/sections/language_region.dart @@ -1,16 +1,25 @@ -import 'package:flutter/material.dart'; +import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/language_codes.dart'; import 'package:spotube/collections/spotify_markets.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/settings/section_card_with_heading.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_select_tile.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); + return ( + locale: e, + name: "${isoCodeName.name} (${isoCodeName.nativeName})", + ); +}).sortedBy((e) => e.name); + class SettingsLanguageRegionSection extends HookConsumerWidget { const SettingsLanguageRegionSection({super.key}); @@ -32,22 +41,12 @@ class SettingsLanguageRegionSection extends HookConsumerWidget { title: Text(context.l10n.language), secondary: const Icon(SpotubeIcons.language), options: [ - DropdownMenuItem( + SelectItemButton( value: const Locale("system", "system"), child: Text(context.l10n.system_default), ), - for (final locale in L10n.all) - DropdownMenuItem( - value: locale, - child: Builder(builder: (context) { - final isoCodeName = LanguageLocals.getDisplayLanguage( - locale.languageCode, - ); - return Text( - "${isoCodeName.name} (${isoCodeName.nativeName})", - ); - }), - ), + for (final (:locale, :name) in localWithName) + SelectItemButton(value: locale, child: Text(name)), ], ), AdaptiveSelectTile( @@ -55,14 +54,14 @@ class SettingsLanguageRegionSection extends HookConsumerWidget { secondary: const Icon(SpotubeIcons.shoppingBag), title: Text(context.l10n.market_place_region), subtitle: Text(context.l10n.recommendation_country), - value: preferences.recommendationMarket, + value: preferences.market, onChanged: (value) { if (value == null) return; preferencesNotifier.setRecommendationMarket(value); }, options: spotifyMarkets .map( - (country) => DropdownMenuItem( + (country) => SelectItemButton( value: country.$1, child: Text(country.$2), ), diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart index bd2e33b9..6acc70b1 100644 --- a/lib/pages/settings/sections/playback.dart +++ b/lib/pages/settings/sections/playback.dart @@ -1,20 +1,34 @@ +import 'dart:io'; + +import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' show ListTile; + import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:piped_client/piped_client.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Consumer; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/settings/section_card_with_heading.dart'; -import 'package:spotube/components/shared/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/playback/edit_instance_url_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/piped_instances_provider.dart'; +import 'package:spotube/modules/settings/youtube_engine_not_installed_dialog.dart'; +import 'package:spotube/provider/audio_player/sources/invidious_instances_provider.dart'; +import 'package:spotube/provider/audio_player/sources/piped_instances_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; + import 'package:spotube/services/sourced_track/enums.dart'; +import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart'; +import 'package:spotube/utils/platform.dart'; class SettingsPlaybackSection extends HookConsumerWidget { - const SettingsPlaybackSection({Key? key}) : super(key: key); + const SettingsPlaybackSection({super.key}); @override Widget build(BuildContext context, ref) { @@ -30,15 +44,15 @@ class SettingsPlaybackSection extends HookConsumerWidget { title: Text(context.l10n.audio_quality), value: preferences.audioQuality, options: [ - DropdownMenuItem( + SelectItemButton( value: SourceQualities.high, child: Text(context.l10n.high), ), - DropdownMenuItem( + SelectItemButton( value: SourceQualities.medium, child: Text(context.l10n.medium), ), - DropdownMenuItem( + SelectItemButton( value: SourceQualities.low, child: Text(context.l10n.low), ), @@ -54,7 +68,7 @@ class SettingsPlaybackSection extends HookConsumerWidget { title: Text(context.l10n.audio_source), value: preferences.audioSource, options: AudioSource.values - .map((e) => DropdownMenuItem( + .map((e) => SelectItemButton( value: e, child: Text(e.label), )) @@ -64,135 +78,337 @@ class SettingsPlaybackSection extends HookConsumerWidget { preferencesNotifier.setAudioSource(value); }, ), - AnimatedSwitcher( + AnimatedCrossFade( duration: const Duration(milliseconds: 300), - child: preferences.audioSource != AudioSource.piped - ? const SizedBox.shrink() - : Consumer(builder: (context, ref, child) { - final instanceList = ref.watch(pipedInstancesFutureProvider); + crossFadeState: preferences.audioSource != AudioSource.piped + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: const SizedBox.shrink(), + secondChild: Consumer( + builder: (context, ref, child) { + final instanceList = ref.watch(pipedInstancesFutureProvider); - return instanceList.when( - data: (data) { - return AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.piped), - title: Text(context.l10n.piped_instance), - subtitle: RichText( - text: TextSpan( - children: [ - TextSpan( - text: context.l10n.piped_description, - style: theme.textTheme.bodyMedium, + return instanceList.when( + data: (data) { + return AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.piped), + title: Text(context.l10n.piped_instance), + subtitle: Text( + "${context.l10n.piped_description}\n" + "${context.l10n.piped_warning}", + ), + value: preferences.pipedInstance, + showValueWhenUnfolded: false, + trailing: [ + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.add_custom_url), + ).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) => + SettingsPlaybackEditInstanceUrlDialog( + title: context.l10n.piped_instance, + initialValue: preferences.pipedInstance, + onSave: (value) { + preferencesNotifier.setPipedInstance(value); + }, ), - const TextSpan(text: "\n"), - TextSpan( - text: context.l10n.piped_warning, - style: theme.textTheme.labelMedium, - ) - ], + ); + }, + ), + ) + ], + options: [ + if (data + .none((e) => e.apiUrl == preferences.pipedInstance)) + SelectItemButton( + value: preferences.pipedInstance, + child: Text.rich( + TextSpan( + style: theme.typography.xSmall.copyWith( + color: theme.colorScheme.foreground, + ), + children: [ + TextSpan(text: context.l10n.custom), + const TextSpan(text: "\n"), + TextSpan(text: preferences.pipedInstance), + ], + ), ), ), - value: preferences.pipedInstance, - showValueWhenUnfolded: false, - options: data - .sortedBy((e) => e.name) - .map( - (e) => DropdownMenuItem( - value: e.apiUrl, - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: "${e.name.trim()}\n", - style: theme.textTheme.labelLarge, - ), - TextSpan( - text: e.locations - .map(countryCodeToEmoji) - .join(""), - style: GoogleFonts.notoColorEmoji(), - ), - ], - ), - ), + for (final e in data.sortedBy((e) => e.name)) + SelectItemButton( + value: e.apiUrl, + child: RichText( + text: TextSpan( + style: theme.typography.normal.copyWith( + color: theme.colorScheme.foreground, ), - ) - .toList(), - onChanged: (value) { - if (value != null) { - preferencesNotifier.setPipedInstance(value); - } - }, - ); + children: [ + TextSpan( + text: "${e.name.trim()}\n", + ), + TextSpan( + text: e.locations + .map(countryCodeToEmoji) + .join(""), + style: GoogleFonts.notoColorEmoji(), + ), + ], + ), + ), + ), + ], + onChanged: (value) { + if (value != null) { + preferencesNotifier.setPipedInstance(value); + } }, - loading: () => const Center( - child: CircularProgressIndicator(), - ), - error: (error, stackTrace) => Text(error.toString()), ); - }), - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: preferences.audioSource != AudioSource.piped - ? const SizedBox.shrink() - : AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.search), - title: Text(context.l10n.search_mode), - value: preferences.searchMode, - options: SearchMode.values - .map((e) => DropdownMenuItem( - value: e, - child: Text(e.label), - )) - .toList(), - onChanged: (value) { - if (value == null) return; - preferencesNotifier.setSearchMode(value); - }, + }, + loading: () => const Center( + child: CircularProgressIndicator(), ), + error: (error, stackTrace) => Text(error.toString()), + ); + }, + ), ), - AnimatedSwitcher( + AnimatedCrossFade( duration: const Duration(milliseconds: 300), - child: preferences.searchMode == SearchMode.youtube && + crossFadeState: preferences.audioSource != AudioSource.invidious + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: const SizedBox.shrink(), + secondChild: Consumer( + builder: (context, ref, child) { + final instanceList = ref.watch(invidiousInstancesProvider); + + return instanceList.when( + data: (data) { + return AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.piped), + title: Text(context.l10n.invidious_instance), + subtitle: Text( + "${context.l10n.invidious_description}\n" + "${context.l10n.invidious_warning}", + ), + trailing: [ + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.add_custom_url), + ).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) => + SettingsPlaybackEditInstanceUrlDialog( + title: context.l10n.invidious_instance, + initialValue: preferences.invidiousInstance, + onSave: (value) { + preferencesNotifier + .setInvidiousInstance(value); + }, + ), + ); + }, + ), + ) + ], + value: preferences.invidiousInstance, + showValueWhenUnfolded: false, + options: [ + if (data.none((e) => + e.details.uri == preferences.invidiousInstance)) + SelectItemButton( + value: preferences.invidiousInstance, + child: Text.rich( + TextSpan( + style: theme.typography.xSmall.copyWith( + color: theme.colorScheme.foreground, + ), + children: [ + TextSpan(text: context.l10n.custom), + const TextSpan(text: "\n"), + TextSpan(text: preferences.invidiousInstance), + ], + ), + ), + ), + for (final e in data.sortedBy((e) => e.name)) + SelectItemButton( + value: e.details.uri, + child: RichText( + text: TextSpan( + style: theme.typography.normal.copyWith( + color: theme.colorScheme.foreground, + ), + children: [ + TextSpan( + text: "${e.name.trim()}\n", + ), + TextSpan( + text: countryCodeToEmoji( + e.details.region, + ), + style: GoogleFonts.notoColorEmoji(), + ), + ], + ), + ), + ), + ], + onChanged: (value) { + if (value != null) { + preferencesNotifier.setInvidiousInstance(value); + } + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stackTrace) => Text(error.toString()), + ); + }, + ), + ), + switch (preferences.audioSource) { + AudioSource.youtube => 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); + }, + ), + AudioSource.piped || + AudioSource.invidious => + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.search), + title: Text(context.l10n.search_mode), + value: preferences.searchMode, + options: SearchMode.values + .map((e) => SelectItemButton( + value: e, + child: Text(e.label), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + preferencesNotifier.setSearchMode(value); + }, + ), + _ => const SizedBox.shrink(), + }, + AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + crossFadeState: preferences.searchMode == SearchMode.youtube && (preferences.audioSource == AudioSource.piped || - preferences.audioSource == AudioSource.youtube) - ? SwitchListTile( - secondary: const Icon(SpotubeIcons.skip), - title: Text(context.l10n.skip_non_music), - value: preferences.skipNonMusic, - onChanged: (state) { - preferencesNotifier.setSkipNonMusic(state); - }, - ) - : const SizedBox.shrink(), + preferences.audioSource == AudioSource.youtube || + preferences.audioSource == AudioSource.invidious) + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: ListTile( + leading: const Icon(SpotubeIcons.skip), + title: Text(context.l10n.skip_non_music), + trailing: Switch( + value: preferences.skipNonMusic, + onChanged: (state) { + preferencesNotifier.setSkipNonMusic(state); + }, + ), + ), + secondChild: const SizedBox.shrink(), + ), + 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: () { - GoRouter.of(context).push("/settings/blacklist"); + context.navigateTo(const BlackListRoute()); }, trailing: const Icon(SpotubeIcons.angleRight), ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.normalize), + ListTile( + leading: const Icon(SpotubeIcons.normalize), title: Text(context.l10n.normalize_audio), - value: preferences.normalizeAudio, - onChanged: preferencesNotifier.setNormalizeAudio, + trailing: Switch( + value: preferences.normalizeAudio, + onChanged: preferencesNotifier.setNormalizeAudio, + ), ), - if (preferences.audioSource != AudioSource.jiosaavn) + if (preferences.audioSource != AudioSource.jiosaavn) ...[ AdaptiveSelectTile( + popupConstraints: const BoxConstraints(maxWidth: 300), secondary: const Icon(SpotubeIcons.stream), title: Text(context.l10n.streaming_music_codec), value: preferences.streamMusicCodec, showValueWhenUnfolded: false, options: SourceCodecs.values - .map((e) => DropdownMenuItem( + .map((e) => SelectItemButton( value: e, child: Text( e.label, - style: theme.textTheme.labelMedium, + style: theme.typography.small, ), )) .toList(), @@ -201,18 +417,18 @@ class SettingsPlaybackSection extends HookConsumerWidget { preferencesNotifier.setStreamMusicCodec(value); }, ), - if (preferences.audioSource != AudioSource.jiosaavn) AdaptiveSelectTile( + popupConstraints: const BoxConstraints(maxWidth: 300), secondary: const Icon(SpotubeIcons.file), title: Text(context.l10n.download_music_codec), value: preferences.downloadMusicCodec, showValueWhenUnfolded: false, options: SourceCodecs.values - .map((e) => DropdownMenuItem( + .map((e) => SelectItemButton( value: e, child: Text( e.label, - style: theme.textTheme.labelMedium, + style: theme.typography.small, ), )) .toList(), @@ -221,11 +437,45 @@ class SettingsPlaybackSection extends HookConsumerWidget { preferencesNotifier.setDownloadMusicCodec(value); }, ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.repeat), - title: Text(context.l10n.endless_playback), - value: preferences.endlessPlayback, - onChanged: preferencesNotifier.setEndlessPlayback, + ], + 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 index f773b809..0948bdeb 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -1,9 +1,9 @@ import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.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:spotube/components/shared/page_window_title_bar.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'; @@ -14,9 +14,14 @@ 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 { - const SettingsPage({Key? key}) : super(key: key); + static const name = "settings"; + + const SettingsPage({super.key}); @override Widget build(BuildContext context, ref) { @@ -26,37 +31,40 @@ class SettingsPage extends HookConsumerWidget { return SafeArea( bottom: false, child: Scaffold( - appBar: PageWindowTitleBar( - title: Text(context.l10n.settings), - centerTitle: true, - ), - body: Scrollbar( + 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: ListView( - controller: controller, - children: [ - const SettingsAccountSection(), - const SettingsLanguageRegionSection(), - const SettingsAppearanceSection(), - const SettingsPlaybackSection(), - const SettingsDownloadsSection(), - if (DesktopTools.platform.isDesktop) - const SettingsDesktopSection(), - if (!kIsWeb) const SettingsDevelopersSection(), - const SettingsAboutSection(), - Center( - child: FilledButton( - onPressed: preferencesNotifier.reset, - child: Text(context.l10n.restore_defaults), + 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: 10), - ], + const SizedBox(height: 200), + ], + ), ), ), ), diff --git a/lib/pages/stats/albums/albums.dart b/lib/pages/stats/albums/albums.dart new file mode 100644 index 00000000..834837af --- /dev/null +++ b/lib/pages/stats/albums/albums.dart @@ -0,0 +1,60 @@ +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/spotify/spotify.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 new file mode 100644 index 00000000..f3d2f0dd --- /dev/null +++ b/lib/pages/stats/artists/artists.dart @@ -0,0 +1,63 @@ +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/spotify/spotify.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( + () => topTracks.asData?.value.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 new file mode 100644 index 00000000..2f1e4107 --- /dev/null +++ b/lib/pages/stats/fees/fees.dart @@ -0,0 +1,138 @@ +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/spotify/spotify.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( + () => topTracks.asData?.value.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.spotify_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 new file mode 100644 index 00000000..2ee4c8d7 --- /dev/null +++ b/lib/pages/stats/minutes/minutes.dart @@ -0,0 +1,65 @@ +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/spotify/spotify.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 * track.track.duration!.inMinutes)), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/stats/playlists/playlists.dart b/lib/pages/stats/playlists/playlists.dart new file mode 100644 index 00000000..03ea5126 --- /dev/null +++ b/lib/pages/stats/playlists/playlists.dart @@ -0,0 +1,64 @@ +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/spotify/spotify.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 new file mode 100644 index 00000000..da7c64f3 --- /dev/null +++ b/lib/pages/stats/stats.dart @@ -0,0 +1,46 @@ +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 new file mode 100644 index 00000000..0d919a44 --- /dev/null +++ b/lib/pages/stats/streams/streams.dart @@ -0,0 +1,65 @@ +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/spotify/spotify.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 index 14052c10..8e6df748 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -1,43 +1,50 @@ import 'dart:ui'; -import 'package:flutter/material.dart'; -import 'package:gap/gap.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/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/heart_button.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/links/link_text.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/shared/track_tile/track_options.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.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/extensions/list.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; -import 'package:spotube/extensions/constrains.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({ - Key? key, - required this.trackId, - }) : super(key: key); + super.key, + @PathParam("id") required this.trackId, + }); @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme, :colorScheme) = Theme.of(context); + final ThemeData(:typography, :colorScheme) = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final playlist = ref.watch(audioPlayerProvider); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final isActive = playlist.activeTrack?.id == trackId; - final trackQuery = useQueries.tracks.track(ref, trackId); + final trackQuery = ref.watch(trackProvider(trackId)); - final track = trackQuery.data ?? FakeData.track; + final track = trackQuery.asData?.value ?? FakeData.track; void onPlay() async { if (isActive) { @@ -47,180 +54,203 @@ class TrackPage extends HookConsumerWidget { } } - return Scaffold( - appBar: const PageWindowTitleBar( - automaticallyImplyLeading: true, - backgroundColor: Colors.transparent, - ), - extendBodyBehindAppBar: true, - body: Stack( - children: [ - Positioned.fill( - child: Container( - decoration: BoxDecoration( - image: DecorationImage( - image: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - track.album!.images, - placeholder: ImagePlaceholder.albumArt, + 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, ), - fit: BoxFit.cover, - colorFilter: ColorFilter.mode( - colorScheme.surface.withOpacity(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.surface, - Colors.transparent, - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - stops: const [0.2, 1], + 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: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString( - track.album!.images, - placeholder: ImagePlaceholder.albumArt, + 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, + ), ), - 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!, - style: textTheme.titleLarge, - ), - const Gap(10), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(SpotubeIcons.album), - const Gap(5), - Flexible( - child: LinkText( - track.album!.name!, - '/album/${track.album!.id}', - push: true, - extra: track.album, - ), - ), - ], - ), - const Gap(10), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(SpotubeIcons.artist), - const Gap(5), - TypeConversionUtils - .artists_X_ClickableArtists( - track.artists!, - ), - ], - ), - const Gap(10), - ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: 350), - child: Row( - mainAxisSize: mediaQuery.smAndDown - ? MainAxisSize.max - : MainAxisSize.min, + 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), - if (!isActive && - !playlist.tracks.contains(track)) - OutlinedButton.icon( - icon: const Icon(SpotubeIcons.queueAdd), - label: Text(context.l10n.queue), - onPressed: () { - playlistNotifier.addTrack(track); - }, + Flexible( + child: LinkText( + track.album!.name!, + AlbumRoute( + id: track.album!.id!, + album: track.album!, + ), + push: true, ), - const Gap(5), - if (!isActive && - !playlist.tracks.contains(track)) - IconButton.outlined( - icon: - const Icon(SpotubeIcons.lightning), - tooltip: context.l10n.play_next, - onPressed: () { - playlistNotifier - .addTracksAtFirst([track]); - }, - ), - const Gap(5), - IconButton.filled( - tooltip: isActive - ? context.l10n.pause_playback - : context.l10n.play, - icon: Icon( - isActive - ? SpotubeIcons.pause - : SpotubeIcons.play, - color: colorScheme.onPrimary, - ), - onPressed: onPlay, ), - const Gap(5), - if (mediaQuery.smAndDown) - const Spacer() - else - const Gap(20), - TrackHeartButton(track: track), - TrackOptions( - track: track, - userPlaylist: false, - ), - const Gap(5), ], ), - ), - ], + 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), + TrackOptions( + 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 new file mode 100644 index 00000000..aa93bd4f --- /dev/null +++ b/lib/provider/audio_player/audio_player.dart @@ -0,0 +1,369 @@ +import 'dart:math'; + +import 'package:drift/drift.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:media_kit/media_kit.dart' hide Track; +import 'package:spotify/spotify.dart' hide Playlist; +import 'package:spotube/extensions/list.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/models/local_track.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.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); + + 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: [], + id: const Value(0), + ), + ); + + playerState = + await database.select(database.audioPlayerStateTable).getSingle(); + } else { + await audioPlayer.setLoopMode(playerState.loopMode); + await audioPlayer.setShuffle(playerState.shuffled); + } + + var playlist = + await database.select(database.playlistTable).getSingleOrNull(); + final medias = await database.select(database.playlistMediaTable).get(); + + if (playlist == null) { + await database.into(database.playlistTable).insert( + PlaylistTableCompanion.insert( + audioPlayerStateId: 0, + index: audioPlayer.playlist.index, + id: const Value(0), + ), + ); + + playlist = await database.select(database.playlistTable).getSingle(); + } + + if (medias.isEmpty && audioPlayer.playlist.medias.isNotEmpty) { + await database.batch((batch) { + batch.insertAll( + database.playlistMediaTable, + [ + for (final media in audioPlayer.playlist.medias) + PlaylistMediaTableCompanion.insert( + playlistId: playlist!.id, + uri: media.uri, + extras: Value(media.extras), + httpHeaders: Value(media.httpHeaders), + ), + ], + ); + }); + } else if (medias.isNotEmpty) { + await audioPlayer.openPlaylist( + medias + .map( + (media) => SpotubeMedia.fromMedia( + Media( + media.uri, + extras: media.extras, + httpHeaders: media.httpHeaders, + ), + ), + ) + .toList(), + initialIndex: playlist.index, + 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); + } + + Future _updatePlaylist( + Playlist playlist, + ) async { + final database = ref.read(databaseProvider); + + await database.batch((batch) { + batch.update( + database.playlistTable, + PlaylistTableCompanion(index: Value(playlist.index)), + where: (tb) => tb.id.equals(0), + ); + + batch.deleteAll(database.playlistMediaTable); + + if (playlist.medias.isEmpty) return; + batch.insertAll( + database.playlistMediaTable, + [ + for (final media in playlist.medias) + PlaylistMediaTableCompanion.insert( + playlistId: 0, + uri: media.uri, + extras: Value(media.extras), + httpHeaders: Value(media.httpHeaders), + ), + ], + ); + }); + } + + @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 { + state = state.copyWith(playlist: playlist); + + await _updatePlaylist(playlist); + } 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, + playlist: audioPlayer.playlist, + shuffled: audioPlayer.isShuffled, + 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]); + } + + // Tracks related methods + + Future addTracksAtFirst( + Iterable tracks, { + bool allowDuplicates = false, + }) async { + if (state.tracks.length == 1) { + return addTracks(tracks); + } + + tracks = _blacklist.filter(tracks).toList() as List; + + for (int i = 0; i < tracks.length; i++) { + final track = tracks.elementAt(i); + + if (!allowDuplicates && + state.tracks.any((element) => _compareTracks(element, track))) { + continue; + } + + await audioPlayer.addTrackAt( + SpotubeMedia(track), + max(state.playlist.index, 0) + i + 1, + ); + } + } + + Future addTrack(Track track) async { + if (_blacklist.contains(track)) return; + if (state.tracks.any((element) => _compareTracks(element, track))) return; + await audioPlayer.addTrack(SpotubeMedia(track)); + } + + Future addTracks(Iterable tracks) async { + tracks = _blacklist.filter(tracks).toList() as List; + for (final track in tracks) { + await audioPlayer.addTrack(SpotubeMedia(track)); + } + } + + Future removeTrack(String trackId) async { + final index = state.tracks.indexWhere((element) => element.id == trackId); + + if (index == -1) return; + + await audioPlayer.removeTrack(index); + } + + Future removeTracks(Iterable trackIds) async { + for (final trackId in trackIds) { + await removeTrack(trackId); + } + } + + bool _compareTracks(Track a, Track b) { + if ((a is LocalTrack && b is! LocalTrack) || + (a is! LocalTrack && b is LocalTrack)) { + return false; + } + + return a is LocalTrack && b is LocalTrack + ? (a).path == (b).path + : a.id == b.id; + } + + Future load( + List tracks, { + int initialIndex = 0, + bool autoPlay = false, + }) async { + final medias = (_blacklist.filter(tracks).toList() as List) + .asMediaList() + .unique((a, b) => _compareTracks(a.track, b.track)); + + // Giving the initial track a boost so MediaKit won't skip + // because of timeout + final intendedActiveTrack = medias.elementAt(initialIndex); + if (intendedActiveTrack.track is! LocalTrack) { + await ref.read(sourcedTrackProvider(intendedActiveTrack).future); + } + + if (medias.isEmpty) return; + + await removeCollections(state.collections); + + await audioPlayer.openPlaylist( + medias.map((s) => s as Media).toList(), + initialIndex: initialIndex, + autoPlay: autoPlay, + ); + } + + Future jumpToTrack(Track 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 { + await audioPlayer.stop(); + await removeCollections(state.collections); + 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 new file mode 100644 index 00000000..c221a2b0 --- /dev/null +++ b/lib/provider/audio_player/audio_player_streams.dart @@ -0,0 +1,161 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/local_track.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/skip_segments/skip_segments.dart'; +import 'package:spotube/provider/scrobbler/scrobbler.dart'; +import 'package:spotube/provider/server/sourced_track.dart'; +import 'package:spotube/provider/spotify/spotify.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 LocalTrack + ? (audioPlayerState.activeTrack as LocalTrack).path + : audioPlayerState.activeTrack?.id; + + if (audioPlayerState.activeTrack == null || + lastScrobbled == uid || + position.inSeconds < 30) { + return; + } + + scrobbler.scrobble(audioPlayerState.activeTrack!); + lastScrobbled = uid; + + /// The [Track] from Playlist.getTracks doesn't contain artist images + /// so we need to fetch them from the API + final activeTrack = + Track.fromJson(audioPlayerState.activeTrack!.toJson()); + if (audioPlayerState.activeTrack!.artists + ?.any((a) => a.images == null) ?? + false) { + activeTrack.artists = + await ref.read(spotifyProvider).api.artists.list([ + for (final artist in audioPlayerState.activeTrack!.artists!) + artist.id!, + ]).then((value) => value.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.playlist.index == -1 || + audioPlayerState.playlist.index == + audioPlayerState.tracks.length - 1) { + return; + } + final nextTrack = SpotubeMedia.fromMedia( + audioPlayerState.playlist.medias + .elementAt(audioPlayerState.playlist.index + 1), + ); + + if (lastTrack == nextTrack.track.id || nextTrack.track is LocalTrack) { + return; + } + + try { + await ref.read(sourcedTrackProvider(nextTrack).future); + } finally { + lastTrack = nextTrack.track.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 new file mode 100644 index 00000000..55590d48 --- /dev/null +++ b/lib/provider/audio_player/querying_track_info.dart @@ -0,0 +1,24 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/server/sourced_track.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; + +final queryingTrackInfoProvider = Provider((ref) { + final media = audioPlayer.playlist.index == -1 || + audioPlayer.playlist.medias.isEmpty + ? null + : audioPlayer.playlist.medias.elementAtOrNull(audioPlayer.playlist.index); + final audioPlayerActiveTrack = + media == null ? null : SpotubeMedia.fromMedia(media); + + final activeMedia = ref.watch(audioPlayerProvider.select( + (s) => s.activeMedia == null + ? null + : SpotubeMedia.fromMedia(s.activeMedia!), + )) ?? + audioPlayerActiveTrack; + + if (activeMedia == null) return false; + + return ref.watch(sourcedTrackProvider(activeMedia)).isLoading; +}); diff --git a/lib/provider/audio_player/sources/invidious_instances_provider.dart b/lib/provider/audio_player/sources/invidious_instances_provider.dart new file mode 100644 index 00000000..c04ac765 --- /dev/null +++ b/lib/provider/audio_player/sources/invidious_instances_provider.dart @@ -0,0 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotube/services/sourced_track/sources/invidious.dart'; + +final invidiousInstancesProvider = FutureProvider((ref) async { + final invidious = ref.watch(invidiousProvider); + + final instances = await invidious.instances(); + + return instances + .where((instance) => instance.details.type == "https") + .toList(); +}); diff --git a/lib/provider/piped_instances_provider.dart b/lib/provider/audio_player/sources/piped_instances_provider.dart similarity index 82% rename from lib/provider/piped_instances_provider.dart rename to lib/provider/audio_player/sources/piped_instances_provider.dart index d571f730..3c5d5f04 100644 --- a/lib/provider/piped_instances_provider.dart +++ b/lib/provider/audio_player/sources/piped_instances_provider.dart @@ -1,4 +1,4 @@ -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:piped_client/piped_client.dart'; import 'package:spotube/services/sourced_track/sources/piped.dart'; @@ -10,7 +10,7 @@ final pipedInstancesFutureProvider = FutureProvider>( return await pipedClient.instanceList(); } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); + AppLogger.reportError(e, stack); return []; } }, diff --git a/lib/provider/audio_player/state.dart b/lib/provider/audio_player/state.dart new file mode 100644 index 00000000..0e3004f5 --- /dev/null +++ b/lib/provider/audio_player/state.dart @@ -0,0 +1,108 @@ +import 'package:media_kit/media_kit.dart' hide Track; +import 'package:spotify/spotify.dart' hide Playlist; +import 'package:spotube/services/audio_player/audio_player.dart'; + +class AudioPlayerState { + final bool playing; + final PlaylistMode loopMode; + final bool shuffled; + final Playlist playlist; + + final List tracks; + final List collections; + + AudioPlayerState({ + required this.playing, + required this.loopMode, + required this.shuffled, + required this.playlist, + required this.collections, + List? tracks, + }) : tracks = tracks ?? + playlist.medias + .map((media) => SpotubeMedia.fromMedia(media).track) + .toList(); + + factory AudioPlayerState.fromJson(Map json) { + return AudioPlayerState( + playing: json['playing'], + loopMode: PlaylistMode.values.firstWhere( + (e) => e.name == json['loopMode'], + orElse: () => audioPlayer.loopMode, + ), + shuffled: json['shuffled'], + playlist: Playlist( + json['playlist']['medias'] + .map( + (media) => SpotubeMedia.fromMedia(Media( + media['uri'], + extras: media['extras'], + httpHeaders: media['httpHeaders'], + )), + ) + .cast() + .toList(), + index: json['playlist']['index'], + ), + collections: List.from(json['collections']), + ); + } + + Map toJson() { + return { + 'playing': playing, + 'loopMode': loopMode.name, + 'shuffled': shuffled, + 'playlist': { + 'medias': playlist.medias + .map((media) => { + 'uri': media.uri, + 'extras': media.extras, + 'httpHeaders': media.httpHeaders, + }) + .toList(), + 'index': playlist.index, + }, + 'collections': collections, + }; + } + + AudioPlayerState copyWith({ + bool? playing, + PlaylistMode? loopMode, + bool? shuffled, + Playlist? playlist, + List? collections, + }) { + return AudioPlayerState( + playing: playing ?? this.playing, + loopMode: loopMode ?? this.loopMode, + shuffled: shuffled ?? this.shuffled, + playlist: playlist ?? this.playlist, + collections: collections ?? this.collections, + tracks: playlist == null ? tracks : null, + ); + } + + Track? get activeTrack { + if (playlist.index == -1) return null; + return tracks.elementAtOrNull(playlist.index); + } + + Media? get activeMedia { + if (playlist.index == -1 || playlist.medias.isEmpty) return null; + return playlist.medias.elementAt(playlist.index); + } + + bool containsTrack(Track track) { + return tracks.any((t) => t.id == track.id); + } + + bool containsTracks(List tracks) { + return tracks.every(containsTrack); + } + + bool containsCollection(String collectionId) { + return collections.contains(collectionId); + } +} diff --git a/lib/provider/authentication/authentication.dart b/lib/provider/authentication/authentication.dart new file mode 100644 index 00000000..497ccf79 --- /dev/null +++ b/lib/provider/authentication/authentication.dart @@ -0,0 +1,309 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:desktop_webview_window/desktop_webview_window.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_http2_adapter/dio_http2_adapter.dart'; +import 'package:drift/drift.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart' + hide X509Certificate; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/routes.dart'; +import 'package:spotube/components/dialogs/prompt_dialog.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/services/logger/logger.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:otp_util/otp_util.dart'; +// ignore: implementation_imports +import 'package:otp_util/src/utils/generic_util.dart'; +import 'package:spotube/utils/service_utils.dart'; + +extension ExpirationAuthenticationTableData on AuthenticationTableData { + bool get isExpired => DateTime.now().isAfter(expiration); + + String? getCookie(String key) => cookie.value + .split("; ") + .firstWhereOrNull((c) => c.trim().startsWith("$key=")) + ?.trim() + .split("=") + .last + .replaceAll(";", ""); +} + +class AuthenticationNotifier extends AsyncNotifier { + static final Dio dio = () { + final dio = Dio() + ..httpClientAdapter = Http2Adapter( + ConnectionManager( + idleTimeout: const Duration(seconds: 10), + onClientCreate: (uri, clientSettings) { + clientSettings.onBadCertificate = (X509Certificate cert) { + return uri.host.endsWith("spotify.com"); + }; + }, + ), + ); + + return dio; + }(); + + @override + build() async { + final database = ref.watch(databaseProvider); + + final data = await (database.select(database.authenticationTable) + ..where((s) => s.id.equals(0))) + .getSingleOrNull(); + + Timer? refreshTimer; + + listenSelf((prevData, newData) async { + if (newData.asData?.value == null) return; + + if (newData.asData!.value!.isExpired) { + await refreshCredentials(); + } + + // set the refresh timer + refreshTimer?.cancel(); + refreshTimer = Timer( + newData.asData!.value!.expiration.difference(DateTime.now()), + () => refreshCredentials(), + ); + }); + + final subscription = + database.select(database.authenticationTable).watch().listen( + (event) { + state = AsyncData(event.isEmpty ? null : event.first); + }, + ); + + ref.onDispose(() { + subscription.cancel(); + refreshTimer?.cancel(); + }); + + return data; + } + + Future refreshCredentials() async { + final database = ref.read(databaseProvider); + final refreshedCredentials = + await credentialsFromCookie(state.asData!.value!.cookie.value); + + await database + .update(database.authenticationTable) + .replace(refreshedCredentials); + } + + Future login(String cookie) async { + final database = ref.read(databaseProvider); + final refreshedCredentials = await credentialsFromCookie(cookie); + + await database + .into(database.authenticationTable) + .insert(refreshedCredentials, mode: InsertMode.replace); + } + + String base32FromBytes(Uint8List e, String secretSauce) { + var t = 0; + var n = 0; + var r = ""; + for (int i = 0; i < e.length; i++) { + n = n << 8 | e[i]; + t += 8; + while (t >= 5) { + r += secretSauce[n >>> t - 5 & 31]; + t -= 5; + } + } + if (t > 0) { + r += secretSauce[n << 5 - t & 31]; + } + return r; + } + + Uint8List cleanBuffer(String e) { + e = e.replaceAll(" ", ""); + final t = List.filled(e.length ~/ 2, 0); + final n = Uint8List.fromList(t); + for (int r = 0; r < e.length; r += 2) { + n[r ~/ 2] = int.parse(e.substring(r, r + 2), radix: 16); + } + return n; + } + + Future generateTotp() async { + const secretSauce = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + final secretCipherBytes = const [ + 12, + 56, + 76, + 33, + 88, + 44, + 88, + 33, + 78, + 78, + 11, + 66, + 22, + 22, + 55, + 69, + 54 + ].mapIndexed((t, e) => e ^ t % 33 + 9).toList(); + + final secretBytes = cleanBuffer( + utf8 + .encode(secretCipherBytes.join("")) + .map((e) => e.toRadixString(16)) + .join(), + ); + + final secret = base32FromBytes(secretBytes, secretSauce); + + final res = await dio.get( + "https://open.spotify.com/server-time", + options: Options( + headers: { + "Host": "open.spotify.com", + "User-Agent": ServiceUtils.randomUserAgent( + kIsDesktop ? UserAgentDevice.desktop : UserAgentDevice.mobile, + ), + "accept": "*/*", + }, + ), + ); + final serverTimeSeconds = res.data["serverTime"] as int; + + final totp = TOTP( + secret: secret, + algorithm: OTPAlgorithm.SHA1, + digits: 6, + interval: 30, + ); + + return totp.generateOTP( + input: Util.timeFormat( + time: DateTime.fromMillisecondsSinceEpoch(serverTimeSeconds * 1000), + interval: 30, + ), + ); + } + + Future getToken({ + required String totp, + required int timestamp, + String mode = "transport", + String? spDc, + }) async { + assert(mode == "transport" || mode == "init"); + + final accessTokenUrl = Uri.parse( + "https://open.spotify.com/get_access_token?reason=$mode&productType=web-player" + "&totp=$totp&totpVer=5&ts=$timestamp", + ); + + final res = await dio.getUri( + accessTokenUrl, + options: Options( + headers: { + "Cookie": spDc ?? "", + "User-Agent": ServiceUtils.randomUserAgent( + kIsDesktop ? UserAgentDevice.desktop : UserAgentDevice.mobile, + ), + }, + ), + ); + + return res; + } + + Future credentialsFromCookie( + String cookie, + ) async { + try { + final spDc = cookie + .split("; ") + .firstWhereOrNull((c) => c.trim().startsWith("sp_dc=")) + ?.trim(); + + final totp = await generateTotp(); + + final timestamp = (DateTime.now().millisecondsSinceEpoch / 1000).floor(); + + var res = await getToken( + totp: totp, + timestamp: timestamp, + spDc: spDc, + mode: "transport", + ); + + if ((res.data["accessToken"]?.length ?? 0) != 374) { + res = await getToken( + totp: totp, + timestamp: timestamp, + spDc: spDc, + mode: "init", + ); + } + + final body = res.data as Map; + + if (body["accessToken"] == null) { + AppLogger.reportError( + "The access token is only ${body["accessToken"]?.length} characters long instead of 374\n" + "Your authentication probably doesn't work", + StackTrace.current, + ); + } + + return AuthenticationTableCompanion.insert( + id: const Value(0), + cookie: DecryptedText("${res.headers["set-cookie"]?.join(";")}; $spDc"), + accessToken: DecryptedText(body['accessToken']), + expiration: DateTime.fromMillisecondsSinceEpoch( + body['accessTokenExpirationTimestampMs'], + ), + ); + } catch (e) { + if (rootNavigatorKey.currentContext != null) { + showPromptDialog( + context: rootNavigatorKey.currentContext!, + title: rootNavigatorKey.currentContext!.l10n + .error("Authentication Failure"), + message: e.toString(), + cancelText: null, + ); + } + rethrow; + } + } + + Future logout() async { + state = const AsyncData(null); + final database = ref.read(databaseProvider); + await (database.delete(database.authenticationTable) + ..where((s) => s.id.equals(0))) + .go(); + if (kIsMobile) { + WebStorageManager.instance().deleteAllData(); + CookieManager.instance().deleteAllCookies(); + } + if (kIsDesktop) { + await WebviewWindow.clearAll(); + } + } +} + +final authenticationProvider = + AsyncNotifierProvider( + () => AuthenticationNotifier(), +); diff --git a/lib/provider/authentication_provider.dart b/lib/provider/authentication_provider.dart deleted file mode 100644 index cd77e7bb..00000000 --- a/lib/provider/authentication_provider.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:fl_query/fl_query.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:http/http.dart'; -import 'package:spotube/collections/routes.dart'; -import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/utils/persisted_state_notifier.dart'; -import 'package:spotube/utils/platform.dart'; - -class AuthenticationCredentials { - String cookie; - String accessToken; - DateTime expiration; - - bool get isExpired => DateTime.now().isAfter(expiration); - - AuthenticationCredentials({ - required this.cookie, - required this.accessToken, - required this.expiration, - }); - - static Future fromCookie(String cookie) async { - try { - final res = await get( - Uri.parse( - "https://open.spotify.com/get_access_token?reason=transport&productType=web_player", - ), - headers: { - "Cookie": cookie, - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36" - }, - ); - final body = jsonDecode(res.body); - - if (res.statusCode >= 400) { - throw Exception( - "Failed to get access token: ${body['error'] ?? res.reasonPhrase}", - ); - } - - return AuthenticationCredentials( - cookie: cookie, - accessToken: body['accessToken'], - expiration: DateTime.fromMillisecondsSinceEpoch( - body['accessTokenExpirationTimestampMs'], - ), - ); - } catch (e) { - if (rootNavigatorKey?.currentContext != null && - await QueryClient.connectivity.isConnected) { - showPromptDialog( - context: rootNavigatorKey!.currentContext!, - title: rootNavigatorKey!.currentContext!.l10n - .error("Authentication Failure"), - message: e.toString(), - cancelText: null, - ); - } - rethrow; - } - } - - factory AuthenticationCredentials.fromJson(Map json) { - return AuthenticationCredentials( - cookie: json['cookie'] as String, - accessToken: json['accessToken'] as String, - expiration: DateTime.parse(json['expiration'] as String), - ); - } - - Map toJson() { - return { - 'cookie': cookie, - 'accessToken': accessToken, - 'expiration': expiration.toIso8601String(), - }; - } - - AuthenticationCredentials copyWith({ - String? cookie, - String? accessToken, - DateTime? expiration, - }) { - return AuthenticationCredentials( - cookie: cookie ?? this.cookie, - accessToken: accessToken ?? this.accessToken, - expiration: expiration ?? this.expiration, - ); - } -} - -class AuthenticationNotifier - extends PersistedStateNotifier { - static final provider = - StateNotifierProvider( - (ref) => AuthenticationNotifier(), - ); - - bool get isLoggedIn => state != null; - - AuthenticationNotifier() : super(null, "authentication", encrypted: true); - - Timer? _refreshTimer; - - @override - FutureOr onInit() async { - super.onInit(); - if (isLoggedIn && state!.isExpired) { - await refreshCredentials(); - } - - addListener((state) { - _refreshTimer?.cancel(); - if (isLoggedIn && !state!.isExpired) { - _refreshTimer = Timer( - state.expiration.difference(DateTime.now()), - () => refreshCredentials(), - ); - } - }); - } - - void setCredentials(AuthenticationCredentials credentials) { - state = credentials; - } - - Future logout() async { - state = null; - if (kIsMobile) { - WebStorageManager.instance().android.deleteAllData(); - CookieManager.instance().deleteAllCookies(); - } - } - - Future refreshCredentials() async { - if (!isLoggedIn) { - return; - } - - state = await AuthenticationCredentials.fromCookie(state!.cookie); - } - - @override - FutureOr fromJson(Map json) { - return AuthenticationCredentials.fromJson(json); - } - - @override - Map toJson() { - return state?.toJson() ?? {}; - } -} diff --git a/lib/provider/blacklist_provider.dart b/lib/provider/blacklist_provider.dart index 363d4b4c..a51d399f 100644 --- a/lib/provider/blacklist_provider.dart +++ b/lib/provider/blacklist_provider.dart @@ -2,74 +2,59 @@ import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/current_playlist.dart'; -import 'package:spotube/utils/persisted_state_notifier.dart'; - -enum BlacklistedType { - artist, - track; - - static BlacklistedType fromName(String name) => - BlacklistedType.values.firstWhere((e) => e.name == name); -} - -class BlacklistedElement { - final String id; - final String name; - final BlacklistedType type; - - BlacklistedElement.artist(this.id, this.name) : type = BlacklistedType.artist; - - BlacklistedElement.track(this.id, this.name) : type = BlacklistedType.track; - - BlacklistedElement.fromJson(Map json) - : id = json['id'], - name = json['name'], - type = BlacklistedType.fromName(json['type']); - - Map toJson() => {'id': id, 'type': type.name, 'name': name}; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; +class BlackListNotifier extends AsyncNotifier> { @override - operator ==(other) => - other is BlacklistedElement && - other.id == id && - other.type == type && - other.name == name; + build() async { + final database = ref.watch(databaseProvider); - @override - int get hashCode => id.hashCode ^ type.hashCode ^ name.hashCode; -} + final subscription = database + .select(database.blacklistTable) + .watch() + .listen((event) => state = AsyncData(event)); -class BlackListNotifier - extends PersistedStateNotifier> { - BlackListNotifier() : super({}, "blacklist"); + ref.onDispose(() { + subscription.cancel(); + }); - static final provider = - StateNotifierProvider>( - (ref) => BlackListNotifier(), - ); - - void add(BlacklistedElement element) { - state = state.union({element}); + return await database.select(database.blacklistTable).get(); } - void remove(BlacklistedElement element) { - state = state.difference({element}); + 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(TrackSimple track) { final containsTrack = - state.contains(BlacklistedElement.track(track.id!, track.name!)); + state.asData?.value.any((element) => element.elementId == track.id) ?? + false; final containsTrackArtists = track.artists?.any( - (artist) => state.contains( - BlacklistedElement.artist(artist.id!, artist.name!), - ), + (artist) => + state.asData?.value.any((el) => el.elementId == artist.id) ?? + false, ) ?? false; return containsTrack || containsTrackArtists; } + bool containsArtist(ArtistSimple artist) { + return state.asData?.value + .any((element) => element.elementId == artist.id) ?? + false; + } + /// Filters the non blacklisted tracks from the given [tracks] Iterable filter(Iterable tracks) { return tracks.whereNot(contains).toList(); @@ -80,29 +65,12 @@ class BlackListNotifier id: playlist.id, name: playlist.name, thumbnail: playlist.thumbnail, - tracks: playlist.tracks.where( - (track) { - return !state - .contains(BlacklistedElement.track(track.id!, track.name!)) && - !(track.artists ?? []).any( - (artist) => state.contains( - BlacklistedElement.artist(artist.id!, artist.name!), - ), - ); - }, - ).toList(), + tracks: playlist.tracks.where((track) => !contains(track)).toList(), ); } - - @override - Set fromJson(Map json) { - return json['blacklist'] - .map((e) => BlacklistedElement.fromJson(e)) - .toSet(); - } - - @override - Map toJson() { - return {'blacklist': state.map((e) => e.toJson()).toList()}; - } } + +final blacklistProvider = + AsyncNotifierProvider>( + () => BlackListNotifier(), +); diff --git a/lib/provider/connect/clients.dart b/lib/provider/connect/clients.dart new file mode 100644 index 00000000..51578a7b --- /dev/null +++ b/lib/provider/connect/clients.dart @@ -0,0 +1,117 @@ +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 new file mode 100644 index 00000000..93d2fb88 --- /dev/null +++ b/lib/provider/connect/connect.dart @@ -0,0 +1,229 @@ +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/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:spotify/spotify.dart' hide Playlist; +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, + playlist: audioPlayer.playlist, + 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(Track 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/custom_spotify_endpoint_provider.dart b/lib/provider/custom_spotify_endpoint_provider.dart index 4857a358..1f36282a 100644 --- a/lib/provider/custom_spotify_endpoint_provider.dart +++ b/lib/provider/custom_spotify_endpoint_provider.dart @@ -1,8 +1,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/custom_spotify_endpoints/spotify_endpoints.dart'; final customSpotifyEndpointProvider = Provider((ref) { - final auth = ref.watch(AuthenticationNotifier.provider); - return CustomSpotifyEndpoints(auth?.accessToken ?? ""); + ref.watch(spotifyProvider); + final auth = ref.watch(authenticationProvider); + return CustomSpotifyEndpoints(auth.asData?.value?.accessToken.value ?? ""); }); diff --git a/lib/provider/database/database.dart b/lib/provider/database/database.dart new file mode 100644 index 00000000..95976e56 --- /dev/null +++ b/lib/provider/database/database.dart @@ -0,0 +1,4 @@ +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 index 3aa547a9..8f81fc51 100644 --- a/lib/provider/discord_provider.dart +++ b/lib/provider/discord_provider.dart @@ -1,70 +1,124 @@ -import 'package:dart_discord_rpc/dart_discord_rpc.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'dart:async'; + +import 'package:flutter_discord_rpc/flutter_discord_rpc.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/env.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/logger/logger.dart'; +import 'package:spotube/utils/platform.dart'; -class Discord extends ChangeNotifier { - final DiscordRPC? discordRPC; - final bool isEnabled; +class DiscordNotifier extends AsyncNotifier { + @override + FutureOr build() async { + if (!kIsDesktop) return; - Discord(this.isEnabled) - : discordRPC = (DesktopTools.platform.isWindows || - DesktopTools.platform.isLinux) && - isEnabled - ? DiscordRPC(applicationId: Env.discordAppId) - : null { - discordRPC?.start(autoRegister: true); + 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); + } } - void updatePresence(Track track) { - clear(); - final artistNames = - TypeConversionUtils.artists_X_String(track.artists ?? []); - discordRPC?.updatePresence( - DiscordPresence( - details: "Song: ${track.name} by $artistNames", - state: "Vibing in Music", - startTimeStamp: DateTime.now().millisecondsSinceEpoch, - largeImageKey: "spotube-logo-foreground", - largeImageText: "Spotube", - smallImageKey: "spotube-logo-foreground", - smallImageText: "Spotube", + Future updatePresence(Track 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 != null ? "by $artistNames" : null, + assets: RPCAssets( + largeImage: + track.album?.images?.first.url ?? "spotube-logo-foreground", + largeText: track.album?.name ?? "Unknown album", + smallImage: "spotube-logo-foreground", + smallText: "Spotube", + ), + buttons: [ + RPCButton( + label: "Listen on Spotify", + url: track.externalUrls?.spotify ?? + "https://open.spotify.com/tracks/${track.id}", + ), + ], + timestamps: RPCTimestamps( + start: isPlaying + ? DateTime.now().millisecondsSinceEpoch - position.inMilliseconds + : null, + ), + activityType: ActivityType.listening, ), ); } - void clear() { - discordRPC?.clearPresence(); + Future clear() async { + if (!kIsDesktop) return; + await FlutterDiscordRPC.instance.clearActivity(); } - void shutdown() { - discordRPC?.shutDown(); - } - - @override - void dispose() { - clear(); - shutdown(); - super.dispose(); + Future close() async { + if (!kIsDesktop) return; + await FlutterDiscordRPC.instance.disconnect(); } } -final discordProvider = ChangeNotifierProvider( - (ref) { - final isEnabled = - ref.watch(userPreferencesProvider.select((s) => s.discordPresence)); - final playback = ref.read(ProxyPlaylistNotifier.provider); - final discord = Discord(isEnabled); - - if (playback.activeTrack != null) { - discord.updatePresence(playback.activeTrack!); - } - - return discord; - }, -); +final discordProvider = + AsyncNotifierProvider(() => DiscordNotifier()); diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index dc538938..1b588399 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -1,20 +1,22 @@ import 'dart:async'; import 'dart:io'; -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/download_manager/download_manager.dart'; import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/primitive_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/utils/service_utils.dart'; class DownloadManagerProvider extends ChangeNotifier { DownloadManagerProvider({required this.ref}) @@ -22,66 +24,57 @@ class DownloadManagerProvider extends ChangeNotifier { $backHistory = {}, dl = DownloadManager() { dl.statusStream.listen((event) async { - final (:request, :status) = event; + try { + final (:request, :status) = event; - final track = $history.firstWhereOrNull( - (element) => element.getUrlOfCodec(downloadCodec) == request.url, - ); - if (track == null) return; + final track = $history.firstWhereOrNull( + (element) => element.getUrlOfCodec(downloadCodec) == request.url, + ); + if (track == null) return; - final savePath = getTrackFileUrl(track); - // related to onFileExists - final oldFile = File("$savePath.old"); + final savePath = getTrackFileUrl(track); + // related to onFileExists + final oldFile = File("$savePath.old"); - // if download failed and old file exists, rename it back - if ((status == DownloadStatus.failed || - status == DownloadStatus.canceled) && - await oldFile.exists()) { - await oldFile.rename(savePath); + // if download failed and old file exists, rename it back + if ((status == DownloadStatus.failed || + status == DownloadStatus.canceled) && + await oldFile.exists()) { + await oldFile.rename(savePath); + } + if (status != DownloadStatus.completed || + //? WebA audiotagging is not supported yet + //? Although in future by converting weba to opus & then tagging it + //? is possible using vorbis comments + downloadCodec == SourceCodecs.weba) { + return; + } + + final file = File(request.path); + + if (await oldFile.exists()) { + await oldFile.delete(); + } + + final imageBytes = await ServiceUtils.downloadImage( + (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + index: 1, + ), + ); + + final metadata = track.toMetadata( + fileLength: await file.length(), + imageBytes: imageBytes, + ); + + await MetadataGod.writeMetadata( + file: file.path, + metadata: metadata, + ); + } catch (e, stack) { + AppLogger.reportError(e, stack); } - if (status != DownloadStatus.completed || - //? WebA audiotagging is not supported yet - //? Although in future by converting weba to opus & then tagging it - //? is possible using vorbis comments - downloadCodec == SourceCodecs.weba) return; - - final file = File(request.path); - - if (await oldFile.exists()) { - await oldFile.delete(); - } - - final imageBytes = await downloadImage( - TypeConversionUtils.image_X_UrlString(track.album?.images, - placeholder: ImagePlaceholder.albumArt, index: 1), - ); - - final metadata = Metadata( - title: track.name, - artist: track.artists?.map((a) => a.name).join(", "), - album: track.album?.name, - albumArtist: track.artists?.map((a) => a.name).join(", "), - year: track.album?.releaseDate != null - ? int.tryParse(track.album!.releaseDate!.split("-").first) ?? 1969 - : 1969, - trackNumber: track.trackNumber, - discNumber: track.discNumber, - durationMs: track.durationMs?.toDouble() ?? 0.0, - fileSize: await file.length(), - trackTotal: track.album?.tracks?.length ?? 0, - picture: imageBytes != null - ? Picture( - data: imageBytes, - // Spotify images are always JPEGs - mimeType: 'image/jpeg', - ) - : null, - ); - - await MetadataGod.writeMetadata( - file: file.path, - metadata: metadata, - ); }); } @@ -109,32 +102,9 @@ class DownloadManagerProvider extends ChangeNotifier { final Set $backHistory; final DownloadManager dl; - /// Spotify Images are always JPEGs - Future downloadImage( - String imageUrl, - ) async { - try { - final fileStream = DefaultCacheManager().getImageFile(imageUrl); - - final bytes = List.empty(growable: true); - - await for (final data in fileStream) { - if (data is FileInfo) { - bytes.addAll(data.file.readAsBytesSync()); - break; - } - } - - return Uint8List.fromList(bytes); - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - return null; - } - } - String getTrackFileUrl(Track track) { final name = - "${track.name} - ${TypeConversionUtils.artists_X_String(track.artists ?? [])}.${downloadCodec.name}"; + "${track.name} - ${track.artists?.asString() ?? ""}.${downloadCodec.name}"; return join(downloadDirectory, PrimitiveUtils.toSafeFileName(name)); } @@ -213,7 +183,7 @@ class DownloadManagerProvider extends ChangeNotifier { ); } } catch (e) { - Catcher2.reportCheckedError(e, StackTrace.current); + AppLogger.reportError(e, StackTrace.current); continue; } } diff --git a/lib/provider/glance/glance.dart b/lib/provider/glance/glance.dart new file mode 100644 index 00000000..22faa13f --- /dev/null +++ b/lib/provider/glance/glance.dart @@ -0,0 +1,169 @@ +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:spotify/spotify.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(Track? track) async { + if (track == null) { + await _saveWidgetData("activeTrack", null); + await _updateWidget(); + return; + } + + final jsonTrack = track.toJson(); + + final image = track.album?.images?.first; + final cachedImage = await DefaultCacheManager().getSingleFile(image!.url!); + final data = { + ...jsonTrack, + "album": { + ...jsonTrack["album"], + "images": [ + { + ...image.toJson(), + "path": cachedImage.path, + } + ] + } + }; + + 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 new file mode 100644 index 00000000..25b722ff --- /dev/null +++ b/lib/provider/history/history.dart @@ -0,0 +1,78 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/database/database.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) ?? false), + '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(Track track) async { + assert( + track.artists?.every((a) => a.images != null) ?? false, + '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 new file mode 100644 index 00000000..1ee2a5d6 --- /dev/null +++ b/lib/provider/history/recent.dart @@ -0,0 +1,55 @@ +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 new file mode 100644 index 00000000..99df4c11 --- /dev/null +++ b/lib/provider/history/summary.dart @@ -0,0 +1,197 @@ +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"$.duration_ms").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 new file mode 100644 index 00000000..b52e65e2 --- /dev/null +++ b/lib/provider/history/top.dart @@ -0,0 +1,17 @@ +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 new file mode 100644 index 00000000..b11e62d2 --- /dev/null +++ b/lib/provider/history/top/albums.dart @@ -0,0 +1,142 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +typedef PlaybackHistoryAlbum = ({int count, AlbumSimple album}); + +class HistoryTopAlbumsState extends PaginatedState { + HistoryTopAlbumsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + HistoryTopAlbumsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return HistoryTopAlbumsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class HistoryTopAlbumsNotifier extends FamilyPaginatedAsyncNotifier< + PlaybackHistoryAlbum, HistoryTopAlbumsState, 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 = AlbumSimple.fromJson(jsonDecode(data)); + return album; + }); + } + + @override + fetch(arg, offset, limit) async { + final albumsQuery = createAlbumsQuery(limit: limit, offset: offset); + + final items = getAlbumsWithCount(await albumsQuery.get()); + + return ( + items: items, + hasMore: items.length == limit, + nextOffset: offset + limit, + ); + } + + @override + build(arg) async { + final (items: albums, :hasMore, :nextOffset) = await fetch(arg, 0, 20); + + 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 HistoryTopAlbumsState( + items: albums, + offset: nextOffset, + limit: 20, + hasMore: hasMore, + ); + } + + 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, HistoryTopAlbumsState, HistoryDuration>( + () => HistoryTopAlbumsNotifier(), +); diff --git a/lib/provider/history/top/playlists.dart b/lib/provider/history/top/playlists.dart new file mode 100644 index 00000000..19eb3622 --- /dev/null +++ b/lib/provider/history/top/playlists.dart @@ -0,0 +1,110 @@ +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +typedef PlaybackHistoryPlaylist = ({int count, PlaylistSimple playlist}); + +class HistoryTopPlaylistsState extends PaginatedState { + HistoryTopPlaylistsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + HistoryTopPlaylistsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return HistoryTopPlaylistsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class HistoryTopPlaylistsNotifier extends FamilyPaginatedAsyncNotifier< + PlaybackHistoryPlaylist, HistoryTopPlaylistsState, 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(arg, offset, limit) async { + final playlistsQuery = createPlaylistsQuery()..limit(limit, offset: offset); + + final items = getPlaylistsWithCount(await playlistsQuery.get()); + + return ( + items: items, + hasMore: items.length == limit, + nextOffset: offset + limit, + ); + } + + @override + build(arg) async { + final (items: playlists, :hasMore, :nextOffset) = await fetch(arg, 0, 20); + + 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 HistoryTopPlaylistsState( + items: playlists, + offset: nextOffset, + limit: 20, + hasMore: hasMore, + ); + } + + 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, HistoryTopPlaylistsState, HistoryDuration>( + () => HistoryTopPlaylistsNotifier(), +); diff --git a/lib/provider/history/top/tracks.dart b/lib/provider/history/top/tracks.dart new file mode 100644 index 00000000..3c057e56 --- /dev/null +++ b/lib/provider/history/top/tracks.dart @@ -0,0 +1,210 @@ +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +typedef PlaybackHistoryTrack = ({int count, Track track}); +typedef PlaybackHistoryArtist = ({int count, Artist artist}); + +class HistoryTopTracksState extends PaginatedState { + HistoryTopTracksState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + List get artists { + return getArtistsWithCount( + 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(); + } + + @override + HistoryTopTracksState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return HistoryTopTracksState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class HistoryTopTracksNotifier extends FamilyPaginatedAsyncNotifier< + PlaybackHistoryTrack, HistoryTopTracksState, 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 ref.read(spotifyProvider).api.artists.list(artistIds); + + final imagedArtistTracks = nonImageArtistTracks.map((e) { + final track = e.track!; + final includedArtists = track.artists! + .map((a) => artists.firstWhereOrNull((artist) => artist.id == a.id)) + .nonNulls + .toList(); + + track.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(arg, offset, limit) async { + final tracksQuery = createTracksQuery()..limit(limit, offset: offset); + + final entries = await tracksQuery.get(); + + final items = getTracksWithCount(entries); + + return ( + items: items, + hasMore: items.length == limit, + nextOffset: offset + limit, + ); + } + + @override + build(arg) async { + final (items: tracks, :hasMore, :nextOffset) = await fetch(arg, 0, 20); + + 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 HistoryTopTracksState( + items: tracks, + offset: nextOffset, + limit: 20, + hasMore: hasMore, + ); + } + + 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, HistoryTopTracksState, HistoryDuration>( + () => HistoryTopTracksNotifier(), +); diff --git a/lib/provider/local_tracks/local_tracks_provider.dart b/lib/provider/local_tracks/local_tracks_provider.dart new file mode 100644 index 00000000..c69d3169 --- /dev/null +++ b/lib/provider/local_tracks/local_tracks_provider.dart @@ -0,0 +1,142 @@ +import 'dart:async'; +import 'dart:io'; + +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:spotify/spotify.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/local_track.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", +}; + +final localTracksProvider = + FutureProvider>>((ref) async { + try { + if (kIsWeb) return {}; + final Map> libraryToTracks = {}; + + final downloadLocation = ref.watch( + userPreferencesProvider.select((s) => s.downloadLocation), + ); + 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}; + } + AppLogger.reportError(e, stack); + return null; + } + }), + ).then((value) => value.nonNulls.toList()); + + final tracksFromMetadata = filesWithMetadata + .map( + (fileWithMetadata) => LocalTrack.fromTrack( + track: Track().fromFile( + fileWithMetadata["file"], + metadata: fileWithMetadata["metadata"], + art: fileWithMetadata["art"], + ), + path: fileWithMetadata["file"].path, + ), + ) + .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 new file mode 100644 index 00000000..d39059ac --- /dev/null +++ b/lib/provider/logs/logs_provider.dart @@ -0,0 +1,19 @@ +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/palette_provider.dart b/lib/provider/palette_provider.dart deleted file mode 100644 index 8f0e9e29..00000000 --- a/lib/provider/palette_provider.dart +++ /dev/null @@ -1,4 +0,0 @@ -import 'package:palette_generator/palette_generator.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final paletteProvider = StateProvider((ref) => null); diff --git a/lib/provider/proxy_playlist/next_fetcher_mixin.dart b/lib/provider/proxy_playlist/next_fetcher_mixin.dart deleted file mode 100644 index 1d2cfde8..00000000 --- a/lib/provider/proxy_playlist/next_fetcher_mixin.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/models/local_track.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; - -final logger = getLogger("NextFetcherMixin"); - -mixin NextFetcher on StateNotifier { - Future> fetchTracks( - Ref ref, { - int count = 3, - int offset = 0, - }) async { - /// get [count] [state.tracks] that are not [SourcedTrack] and [LocalTrack] - - final bareTracks = state.tracks - .skip(offset) - .where((element) => element is! SourcedTrack && element is! LocalTrack) - .take(count); - - /// fetch [bareTracks] one by one with 100ms delay - final fetchedTracks = await Future.wait( - bareTracks.mapIndexed((i, track) async { - final future = SourcedTrack.fetchFromTrack( - ref: ref, - track: track, - ); - if (i == 0) { - return await future; - } - return await Future.delayed( - const Duration(milliseconds: 100), - () => future, - ); - }), - ); - - return fetchedTracks; - } - - /// Merges List of [SourcedTrack]s with [Track]s and outputs a mixed List - Set mergeTracks( - Iterable fetchTracks, - Iterable tracks, - ) { - return tracks.map((track) { - final fetchedTrack = fetchTracks.firstWhereOrNull( - (fetchTrack) => fetchTrack.id == track.id, - ); - if (fetchedTrack != null) { - return fetchedTrack; - } - return track; - }).toSet(); - } - - /// Checks if [Track] is playable - bool isUnPlayable(String source) { - return source.startsWith('https://youtube.com/unplayable.m4a?id='); - } - - bool isPlayable(String source) => !isUnPlayable(source); - - /// Returns [Track.id] from [isUnPlayable] source that is not playable - String getIdFromUnPlayable(String source) { - return source - .split('&') - .first - .replaceFirst('https://youtube.com/unplayable.m4a?id=', ''); - } - - /// Returns appropriate Media source for [Track] - /// - /// * If [Track] is [SourcedTrack] then return [SourcedTrack.ytUri] - /// * If [Track] is [LocalTrack] then return [LocalTrack.path] - /// * If [Track] is [Track] then return [Track.id] with [isUnPlayable] source - String makeAppropriateSource(Track track) { - if (track is SourcedTrack) { - return track.url; - } else if (track is LocalTrack) { - return track.path; - } else { - return trackToUnplayableSource(track); - } - } - - String trackToUnplayableSource(Track track) { - return "https://youtube.com/unplayable.m4a?id=${track.id}&title=${Uri.encodeComponent(track.name!)}"; - } - - List mapSourcesToTracks(List sources) { - return sources - .map((source) { - final track = state.tracks.firstWhereOrNull( - (track) => - trackToUnplayableSource(track) == source || - (track is SourcedTrack && track.url == source) || - (track is LocalTrack && track.path == source), - ); - return track; - }) - .whereNotNull() - .toList(); - } -} diff --git a/lib/provider/proxy_playlist/proxy_playlist.dart b/lib/provider/proxy_playlist/proxy_playlist.dart deleted file mode 100644 index 026b3403..00000000 --- a/lib/provider/proxy_playlist/proxy_playlist.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; -import 'package:spotube/models/local_track.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; - -class ProxyPlaylist { - final Set tracks; - final Set collections; - final int? active; - - ProxyPlaylist(this.tracks, [this.active, this.collections = const {}]); - - factory ProxyPlaylist.fromJson( - Map json, - Ref ref, - ) { - return ProxyPlaylist( - List.castFrom>( - json['tracks'] ?? >[], - ).map((t) => _makeAppropriateTrack(t, ref)).toSet(), - json['active'] as int?, - json['collections'] == null - ? {} - : (json['collections'] as List).toSet().cast(), - ); - } - - Track? get activeTrack => - active == null || active == -1 ? null : tracks.elementAtOrNull(active!); - - bool get isFetching => - activeTrack != null && - activeTrack is! SourcedTrack && - activeTrack is! LocalTrack; - - bool containsCollection(String collection) { - return collections.contains(collection); - } - - bool containsTrack(TrackSimple track) { - return tracks.firstWhereOrNull((element) => element.id == track.id) != null; - } - - bool containsTracks(Iterable tracks) { - if (tracks.isEmpty) return false; - return tracks.every(containsTrack); - } - - static Track _makeAppropriateTrack(Map track, Ref ref) { - if (track.containsKey("ytUri")) { - return SourcedTrack.fromJson(track, ref: ref); - } else if (track.containsKey("path")) { - return LocalTrack.fromJson(track); - } else { - return Track.fromJson(track); - } - } - - /// To make sure proper instance method is used for JSON serialization - /// Otherwise default super.toJson() is used - static Map _makeAppropriateTrackJson(Track track) { - return switch (track.runtimeType) { - LocalTrack => track.toJson(), - SourcedTrack => track.toJson(), - _ => track.toJson(), - }; - } - - Map toJson() { - return { - 'tracks': tracks.map(_makeAppropriateTrackJson).toList(), - 'active': active, - 'collections': collections.toList(), - }; - } - - ProxyPlaylist copyWith({ - Set? tracks, - int? active, - Set? collections, - }) { - return ProxyPlaylist( - tracks ?? this.tracks, - active ?? this.active, - collections ?? this.collections, - ); - } -} diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart deleted file mode 100644 index 0811fe35..00000000 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ /dev/null @@ -1,635 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:math'; - -import 'package:catcher_2/catcher_2.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:http/http.dart'; -import 'package:palette_generator/palette_generator.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/models/local_track.dart'; -import 'package:spotube/models/logger.dart'; - -import 'package:spotube/models/skip_segment.dart'; - -import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/palette_provider.dart'; -import 'package:spotube/provider/proxy_playlist/next_fetcher_mixin.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/provider/scrobbler_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/audio_services/audio_services.dart'; -import 'package:spotube/provider/discord_provider.dart'; -import 'package:spotube/services/sourced_track/exceptions.dart'; -import 'package:spotube/services/sourced_track/models/source_info.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/services/sourced_track/sources/piped.dart'; -import 'package:spotube/services/sourced_track/sources/youtube.dart'; - -import 'package:spotube/utils/persisted_state_notifier.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -/// Things implemented: -/// * [x] Sponsor-Block skip -/// * [x] Prefetch next track as [SourcedTrack] on 80% of current track -/// * [x] Mixed Queue containing both [SourcedTrack] and [LocalTrack] -/// * [x] Modification of the Queue -/// * [x] Add track at the end -/// * [x] Add track at the beginning -/// * [x] Remove track -/// * [x] Reorder track -/// * [x] Caching and loading of cache of tracks -/// * [x] Shuffling -/// * [x] loop => playlist, track, none -/// * [x] Alternative Track Source -/// * [x] Blacklisting of tracks and artist -/// -/// Don'ts: -/// * It'll not have any proxy method for [SpotubeAudioPlayer] -/// * It'll not store any sort of player state e.g playing, paused, shuffled etc -/// * For that, use [SpotubeAudioPlayer] - -class ProxyPlaylistNotifier extends PersistedStateNotifier - with NextFetcher { - final Ref ref; - late final AudioServices notificationService; - - ScrobblerNotifier get scrobbler => ref.read(scrobblerProvider.notifier); - UserPreferences get preferences => ref.read(userPreferencesProvider); - ProxyPlaylist get playlist => state; - BlackListNotifier get blacklist => - ref.read(BlackListNotifier.provider.notifier); - Discord get discord => ref.read(discordProvider); - - static final provider = - StateNotifierProvider( - (ref) => ProxyPlaylistNotifier(ref), - ); - - static AlwaysAliveRefreshable get notifier => - provider.notifier; - - ProxyPlaylistNotifier(this.ref) : super(ProxyPlaylist({}), "playlist") { - () async { - notificationService = await AudioServices.create(ref, this); - - // listeners state - final currentSegments = - // using source as unique id because alternative track source support - ObjectRef<({String source, List segments})?>(null); - final isPreSearching = ObjectRef(false); - final isFetchingSegments = ObjectRef(false); - - audioPlayer.activeSourceChangedStream.listen((newActiveSource) async { - try { - final newActiveTrack = - mapSourcesToTracks([newActiveSource]).firstOrNull; - - if (newActiveTrack == null || - newActiveTrack.id == state.activeTrack?.id) { - return; - } - - notificationService.addTrack(newActiveTrack); - discord.updatePresence(newActiveTrack); - state = state.copyWith( - active: state.tracks - .toList() - .indexWhere((element) => element.id == newActiveTrack.id), - ); - - updatePalette(); - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }); - - audioPlayer.shuffledStream.listen((event) { - try { - final newlyOrderedTracks = mapSourcesToTracks(audioPlayer.sources); - - final newActiveIndex = newlyOrderedTracks.indexWhere( - (element) => element.id == state.activeTrack?.id, - ); - - if (newActiveIndex == -1) return; - - state = state.copyWith( - tracks: newlyOrderedTracks.toSet(), - active: newActiveIndex, - ); - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }); - - listenTo2Percent(int percent) async { - if (isPreSearching.value || - audioPlayer.currentSource == null || - audioPlayer.nextSource == null || - isPlayable(audioPlayer.nextSource!)) return; - - try { - isPreSearching.value = true; - - final track = await ensureSourcePlayable(audioPlayer.nextSource!); - - if (track != null) { - state = state.copyWith(tracks: mergeTracks([track], state.tracks)); - } - } catch (e, stackTrace) { - // Removing tracks that were not found to avoid queue interruption - if (e is TrackNotFoundError) { - final oldTrack = - mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull; - await removeTrack(oldTrack!.id!); - } - Catcher2.reportCheckedError(e, stackTrace); - } finally { - isPreSearching.value = false; - } - } - - audioPlayer.percentCompletedStream(2).listen(listenTo2Percent); - - audioPlayer.positionStream.listen((position) async { - if (state.activeTrack == null || state.activeTrack is LocalTrack) { - isFetchingSegments.value = false; - return; - } - try { - final isNotYTMode = state.activeTrack is! YoutubeSourcedTrack && - (state.activeTrack is PipedSourcedTrack && - preferences.searchMode == SearchMode.youtubeMusic); - - if (isNotYTMode || !preferences.skipNonMusic) return; - - final isNotSameSegmentId = - currentSegments.value?.source != audioPlayer.currentSource; - - if (currentSegments.value == null || - (isNotSameSegmentId && !isFetchingSegments.value)) { - isFetchingSegments.value = true; - try { - currentSegments.value = ( - source: audioPlayer.currentSource!, - segments: await getAndCacheSkipSegments( - (state.activeTrack as SourcedTrack).sourceInfo.id, - ), - ); - } catch (e) { - if (audioPlayer.currentSource != null) { - currentSegments.value = ( - source: audioPlayer.currentSource!, - segments: [], - ); - } - } finally { - isFetchingSegments.value = false; - } - } - - // skipping in first 2 second breaks stream - if (currentSegments.value == null || - currentSegments.value!.segments.isEmpty || - position < const Duration(seconds: 3)) return; - - for (final segment in currentSegments.value!.segments) { - if (position.inSeconds >= segment.start && - position.inSeconds < segment.end) { - await audioPlayer.seek(Duration(seconds: segment.end)); - } - } - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }); - - String? lastScrobbled; - audioPlayer.positionStream.listen((position) { - try { - final uid = state.activeTrack is LocalTrack - ? (state.activeTrack as LocalTrack).path - : state.activeTrack?.id; - - if (state.activeTrack == null || - lastScrobbled == uid || - position.inSeconds < 30) { - return; - } - - scrobbler.scrobble(state.activeTrack!); - lastScrobbled = uid; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - } - }); - }(); - } - - Future ensureSourcePlayable(String source) async { - if (isPlayable(source)) return null; - - final track = mapSourcesToTracks([source]).firstOrNull; - - if (track == null || track is LocalTrack) { - return null; - } - - final nthFetchedTrack = switch (track.runtimeType) { - SourcedTrack => track as SourcedTrack, - _ => await SourcedTrack.fetchFromTrack(ref: ref, track: track), - }; - - await audioPlayer.replaceSource( - source, - nthFetchedTrack.url, - ); - - return nthFetchedTrack; - } - - // Basic methods for adding or removing tracks to playlist - - Future addTrack(Track track) async { - if (blacklist.contains(track)) return; - state = state.copyWith(tracks: {...state.tracks, track}); - await audioPlayer.addTrack(makeAppropriateSource(track)); - } - - Future addTracks(Iterable tracks) async { - tracks = blacklist.filter(tracks).toList() as List; - state = state.copyWith(tracks: {...state.tracks, ...tracks}); - for (final track in tracks) { - await audioPlayer.addTrack(makeAppropriateSource(track)); - } - } - - void addCollection(String collectionId) { - state = state.copyWith(collections: { - ...state.collections, - collectionId, - }); - } - - void removeCollection(String collectionId) { - state = state.copyWith(collections: { - ...state.collections..remove(collectionId), - }); - } - - // TODO: Safely Remove playing tracks - - Future removeTrack(String trackId) async { - final track = - state.tracks.firstWhereOrNull((element) => element.id == trackId); - if (track == null) return; - state = state.copyWith(tracks: {...state.tracks..remove(track)}); - final index = audioPlayer.sources.indexOf(makeAppropriateSource(track)); - if (index == -1) return; - await audioPlayer.removeTrack(index); - } - - Future removeTracks(Iterable tracksIds) async { - final tracks = - state.tracks.where((element) => tracksIds.contains(element.id)); - - state = state.copyWith(tracks: { - ...state.tracks..removeWhere((element) => tracksIds.contains(element.id)) - }); - - for (final track in tracks) { - final index = audioPlayer.sources.indexOf(makeAppropriateSource(track)); - if (index == -1) continue; - await audioPlayer.removeTrack(index); - } - } - - Future load( - Iterable tracks, { - int initialIndex = 0, - bool autoPlay = false, - }) async { - tracks = blacklist.filter(tracks).toList() as List; - final indexTrack = tracks.elementAtOrNull(initialIndex) ?? tracks.first; - - if (indexTrack is LocalTrack) { - state = state.copyWith( - tracks: tracks.toSet(), - active: initialIndex, - collections: {}, - ); - await notificationService.addTrack(indexTrack); - discord.updatePresence(indexTrack); - } else { - final addableTrack = await SourcedTrack.fetchFromTrack( - ref: ref, - track: tracks.elementAtOrNull(initialIndex) ?? tracks.first, - ).catchError((e, stackTrace) { - return SourcedTrack.fetchFromTrack( - ref: ref, - track: tracks.elementAtOrNull(initialIndex + 1) ?? tracks.first, - ); - }); - - state = state.copyWith( - tracks: mergeTracks([addableTrack], tracks), - active: initialIndex, - collections: {}, - ); - await notificationService.addTrack(addableTrack); - discord.updatePresence(addableTrack); - } - - await audioPlayer.openPlaylist( - state.tracks.map(makeAppropriateSource).toList(), - initialIndex: initialIndex, - autoPlay: autoPlay, - ); - } - - Future jumpTo(int index) async { - final oldTrack = - mapSourcesToTracks([audioPlayer.currentSource!]).firstOrNull; - - state = state.copyWith(active: index); - await audioPlayer.pause(); - final track = await ensureSourcePlayable(audioPlayer.sources[index]); - - if (track != null) { - state = state.copyWith( - tracks: mergeTracks([track], state.tracks), - active: index, - ); - } - - await audioPlayer.jumpTo(index); - - if (oldTrack != null || track != null) { - await notificationService.addTrack(track ?? oldTrack!); - discord.updatePresence(track ?? oldTrack!); - } - } - - Future jumpToTrack(Track track) async { - final index = - state.tracks.toList().indexWhere((element) => element.id == track.id); - if (index == -1) return; - await jumpTo(index); - } - - // TODO: add safe guards for active/playing track that needs to be moved - 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; - - final tracks = state.tracks.toList(); - final track = tracks.removeAt(oldIndex); - tracks.insert(newIndex, track); - state = state.copyWith(tracks: {...tracks}); - - await audioPlayer.moveTrack(oldIndex, newIndex); - } - - Future addTracksAtFirst(Iterable tracks) async { - if (state.tracks.length == 1) { - return addTracks(tracks); - } - - tracks = blacklist.filter(tracks).toList() as List; - final destIndex = state.active != null ? state.active! + 1 : 0; - final newTracks = state.tracks.toList()..insertAll(destIndex, tracks); - state = state.copyWith(tracks: newTracks.toSet()); - - tracks.forEachIndexed((index, track) async { - audioPlayer.addTrackAt( - makeAppropriateSource(track), - destIndex + index, - ); - }); - } - - Future populateSibling() async { - if (state.activeTrack is SourcedTrack) { - final activeTrackWithSiblingsForSure = - await (state.activeTrack as SourcedTrack).copyWithSibling(); - - state = state.copyWith( - tracks: mergeTracks([activeTrackWithSiblingsForSure], state.tracks), - active: state.tracks.toList().indexWhere( - (element) => element.id == activeTrackWithSiblingsForSure.id), - ); - } - } - - Future swapSibling(SourceInfo sibling) async { - if (state.activeTrack is SourcedTrack) { - await populateSibling(); - final newTrack = - await (state.activeTrack as SourcedTrack).swapWithSibling(sibling); - if (newTrack == null) return; - state = state.copyWith( - tracks: mergeTracks([newTrack], state.tracks), - active: state.tracks - .toList() - .indexWhere((element) => element.id == newTrack.id), - ); - await audioPlayer.pause(); - await audioPlayer.replaceSource( - audioPlayer.currentSource!, - makeAppropriateSource(newTrack), - ); - } - } - - Future next() async { - if (audioPlayer.nextSource == null) return; - final oldTrack = mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull; - - state = state.copyWith( - active: state.tracks - .toList() - .indexWhere((element) => element.id == oldTrack?.id), - ); - - await audioPlayer.pause(); - final track = await ensureSourcePlayable(audioPlayer.nextSource!); - - if (track != null) { - state = state.copyWith( - tracks: mergeTracks([track], state.tracks), - active: state.tracks - .toList() - .indexWhere((element) => element.id == track.id), - ); - } - await audioPlayer.skipToNext(); - - if (oldTrack != null || track != null) { - await notificationService.addTrack(track ?? oldTrack!); - discord.updatePresence(track ?? oldTrack!); - } - } - - Future previous() async { - if (audioPlayer.previousSource == null) return; - final oldTrack = - mapSourcesToTracks([audioPlayer.previousSource!]).firstOrNull; - state = state.copyWith( - active: state.tracks - .toList() - .indexWhere((element) => element.id == oldTrack?.id), - ); - await audioPlayer.pause(); - final track = await ensureSourcePlayable(audioPlayer.previousSource!); - if (track != null) { - state = state.copyWith( - tracks: mergeTracks([track], state.tracks), - active: state.tracks - .toList() - .indexWhere((element) => element.id == track.id), - ); - } - await audioPlayer.skipToPrevious(); - if (oldTrack != null || track != null) { - await notificationService.addTrack(track ?? oldTrack!); - discord.updatePresence(track ?? oldTrack!); - } - } - - Future stop() async { - state = ProxyPlaylist({}); - await audioPlayer.stop(); - discord.clear(); - } - - Future updatePalette() async { - final palette = ref.read(paletteProvider); - if (!preferences.albumColorSync) { - if (palette != null) ref.read(paletteProvider.notifier).state = null; - return; - } - return Future.microtask(() async { - if (state.activeTrack == null) return; - - final palette = await PaletteGenerator.fromImageProvider( - UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - state.activeTrack?.album?.images, - placeholder: ImagePlaceholder.albumArt, - ), - height: 50, - width: 50, - ), - ); - ref.read(paletteProvider.notifier).state = palette; - }); - } - - Future> getAndCacheSkipSegments(String id) async { - if (!preferences.skipNonMusic || - (preferences.audioSource == AudioSource.piped && - preferences.searchMode == SearchMode.youtubeMusic)) return []; - - try { - final cached = await SkipSegment.box.get(id); - if (cached != null && cached.isNotEmpty) { - return List.castFrom( - (cached as List) - .map( - (json) => SkipSegment.fromJson( - Map.castFrom(json), - ), - ) - .toList(), - ); - } - - final res = await get(Uri( - scheme: "https", - host: "sponsor.ajay.app", - path: "/api/skipSegments", - queryParameters: { - "videoID": id, - "category": [ - 'sponsor', - 'selfpromo', - 'interaction', - 'intro', - 'outro', - 'music_offtopic' - ], - "actionType": 'skip' - }, - )); - - if (res.body == "Not Found") { - return List.castFrom([]); - } - - final data = jsonDecode(res.body) as List; - final segments = data.map((obj) { - final start = obj["segment"].first.toInt(); - final end = obj["segment"].last.toInt(); - return SkipSegment( - start, - end, - ); - }).toList(); - getLogger('getSkipSegments').t( - "[SponsorBlock] successfully fetched skip segments for $id", - ); - - await SkipSegment.box.put( - id, - segments.map((e) => e.toJson()).toList(), - ); - return List.castFrom(segments); - } catch (e, stack) { - await SkipSegment.box.put(id, []); - Catcher2.reportCheckedError(e, stack); - return List.castFrom([]); - } - } - - @override - set state(state) { - super.state = state; - if (state.tracks.isEmpty && ref.read(paletteProvider) != null) { - ref.read(paletteProvider.notifier).state = null; - } else { - updatePalette(); - } - } - - @override - onInit() async { - if (state.tracks.isEmpty) return null; - final oldCollections = state.collections; - await load( - state.tracks, - initialIndex: max(state.active ?? 0, 0), - autoPlay: false, - ); - state = state.copyWith(collections: oldCollections); - } - - @override - FutureOr fromJson(Map json) { - return ProxyPlaylist.fromJson(json, ref); - } - - @override - Map toJson() { - final json = state.toJson(); - return json; - } -} diff --git a/lib/provider/scrobbler/scrobbler.dart b/lib/provider/scrobbler/scrobbler.dart new file mode 100644 index 00000000..8aff0438 --- /dev/null +++ b/lib/provider/scrobbler/scrobbler.dart @@ -0,0 +1,134 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:scrobblenaut/scrobblenaut.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/env.dart'; +import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/models/database/database.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: track.duration, + timestamp: DateTime.now().toUtc(), + trackNumber: track.trackNumber, + ); + } 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(Track track) { + _scrobbleController.add(track); + } + + Future love(Track track) async { + await state.asData?.value?.track.love( + artist: track.artists!.asString(), + track: track.name!, + ); + } + + Future unlove(Track track) async { + await state.asData?.value?.track.unLove( + artist: track.artists!.asString(), + track: track.name!, + ); + } +} + +final scrobblerProvider = + AsyncNotifierProvider( + () => ScrobblerNotifier(), +); diff --git a/lib/provider/scrobbler_provider.dart b/lib/provider/scrobbler_provider.dart deleted file mode 100644 index bf234e62..00000000 --- a/lib/provider/scrobbler_provider.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'dart:async'; - -import 'package:catcher_2/catcher_2.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:scrobblenaut/scrobblenaut.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/env.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/utils/persisted_state_notifier.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class ScrobblerState { - final String username; - final String passwordHash; - - final Scrobblenaut scrobblenaut; - - ScrobblerState({ - required this.username, - required this.passwordHash, - required this.scrobblenaut, - }); - - Map toJson() { - return { - 'username': username, - 'passwordHash': passwordHash, - }; - } -} - -class ScrobblerNotifier extends PersistedStateNotifier { - final Scrobblenaut? scrobblenaut; - - /// Directly scrobbling in set state of [ProxyPlaylistNotifier] - /// brings extra latency in playback - final StreamController _scrobbleController = - StreamController.broadcast(); - - ScrobblerNotifier() - : scrobblenaut = null, - super(null, "scrobbler", encrypted: true) { - _scrobbleController.stream.listen((track) async { - try { - await state?.scrobblenaut.track.scrobble( - artist: track.artists!.first.name!, - track: track.name!, - album: track.album!.name!, - chosenByUser: true, - duration: track.duration, - timestamp: DateTime.now().toUtc(), - trackNumber: track.trackNumber, - ); - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }); - } - - Future login( - String username, - String password, - ) async { - final lastFm = await LastFM.authenticate( - apiKey: Env.lastFmApiKey, - apiSecret: Env.lastFmApiSecret, - username: username, - password: password, - ); - if (!lastFm.isAuth) throw Exception("Invalid credentials"); - state = ScrobblerState( - username: username, - passwordHash: lastFm.passwordHash!, - scrobblenaut: Scrobblenaut(lastFM: lastFm), - ); - } - - Future logout() async { - state = null; - } - - void scrobble(Track track) { - _scrobbleController.add(track); - } - - Future love(Track track) async { - await state?.scrobblenaut.track.love( - artist: TypeConversionUtils.artists_X_String(track.artists!), - track: track.name!, - ); - } - - Future unlove(Track track) async { - await state?.scrobblenaut.track.unLove( - artist: TypeConversionUtils.artists_X_String(track.artists!), - track: track.name!, - ); - } - - @override - FutureOr fromJson(Map json) async { - if (json.isEmpty) { - return null; - } - - return ScrobblerState( - username: json['username'], - passwordHash: json['passwordHash'], - scrobblenaut: Scrobblenaut( - lastFM: await LastFM.authenticateWithPasswordHash( - apiKey: Env.lastFmApiKey, - apiSecret: Env.lastFmApiSecret, - username: json["username"], - passwordHash: json["passwordHash"], - ), - ), - ); - } - - @override - Map toJson() { - return state?.toJson() ?? {}; - } -} - -final scrobblerProvider = - StateNotifierProvider( - (ref) => ScrobblerNotifier(), -); diff --git a/lib/provider/server/active_sourced_track.dart b/lib/provider/server/active_sourced_track.dart new file mode 100644 index 00000000..37d0dec8 --- /dev/null +++ b/lib/provider/server/active_sourced_track.dart @@ -0,0 +1,47 @@ +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:spotube/services/sourced_track/models/source_info.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; + +class ActiveSourcedTrackNotifier extends Notifier { + @override + build() { + return null; + } + + void update(SourcedTrack? sourcedTrack) { + state = sourcedTrack; + } + + Future populateSibling() async { + if (state == null) return; + state = await state!.copyWithSibling(); + } + + Future swapSibling(SourceInfo sibling) async { + if (state == null) return; + await populateSibling(); + final newTrack = await state!.swapWithSibling(sibling); + if (newTrack == null) return; + + state = newTrack; + await audioPlayer.pause(); + + final playbackNotifier = ref.read(audioPlayerProvider.notifier); + final oldActiveIndex = audioPlayer.currentIndex; + + await playbackNotifier.addTracksAtFirst([newTrack], allowDuplicates: true); + await Future.delayed(const Duration(milliseconds: 50)); + await playbackNotifier.jumpToTrack(newTrack); + + await audioPlayer.removeTrack(oldActiveIndex); + + await audioPlayer.resume(); + } +} + +final activeSourcedTrackProvider = + NotifierProvider( + () => ActiveSourcedTrackNotifier(), +); diff --git a/lib/provider/server/bonsoir.dart b/lib/provider/server/bonsoir.dart new file mode 100644 index 00000000..fcc40e54 --- /dev/null +++ b/lib/provider/server/bonsoir.dart @@ -0,0 +1,41 @@ +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 new file mode 100644 index 00000000..8f97ce89 --- /dev/null +++ b/lib/provider/server/pipeline.dart @@ -0,0 +1,11 @@ +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 new file mode 100644 index 00000000..06ff4a24 --- /dev/null +++ b/lib/provider/server/router.dart @@ -0,0 +1,24 @@ +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.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 new file mode 100644 index 00000000..6c4d8ce0 --- /dev/null +++ b/lib/provider/server/routes/connect.dart @@ -0,0 +1,249 @@ +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:spotify/spotify.dart'; +import 'package:spotube/collections/routes.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/connect/connect.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, + autoPlay: true, + initialIndex: event.data.initialIndex ?? 0, + ); + + if (event.data.collectionId == null) return; + audioPlayerNotifier.addCollection(event.data.collectionId!); + if (event.data.collection is AlbumSimple) { + historyNotifier + .addAlbums([event.data.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists( + [event.data.collection as PlaylistSimple]); + } + }); + + event.onPause((event) async { + await audioPlayer.pause(); + }); + + event.onResume((event) async { + await audioPlayer.resume(); + }); + + event.onStop((event) async { + await audioPlayer.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 new file mode 100644 index 00000000..ccbeb7ab --- /dev/null +++ b/lib/provider/server/routes/playback.dart @@ -0,0 +1,249 @@ +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/extensions/artist_simple.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/extensions/track.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_sourced_track.dart'; +import 'package:spotube/provider/server/sourced_track.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/enums.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<({dio_lib.Response response, Uint8List? bytes})> + streamTrack( + SourcedTrack track, + Map headers, + ) async { + final trackCacheFile = File( + join( + await UserPreferencesNotifier.getMusicCacheDir(), + ServiceUtils.sanitizeFilename( + '${track.name} - ${track.artists?.asString()} (${track.sourceInfo.id}).${track.codec.name}', + ), + ), + ); + final trackPartialCacheFile = File("${trackCacheFile.path}.part"); + + var options = Options( + headers: { + ...headers, + "user-agent": _randomUserAgent, + "Cache-Control": "max-age=3600", + "Connection": "keep-alive", + "host": Uri.parse(track.url).host, + }, + responseType: ResponseType.bytes, + validateStatus: (status) => status! < 400, + ); + + final headersRes = await Future.value( + dio.head( + track.url, + options: options, + ), + ).catchError((_) async => null); + + final contentLength = headersRes?.headers.value("content-length"); + + if (await trackCacheFile.exists() && userPreferences.cacheMusic) { + final bytes = await trackCacheFile.readAsBytes(); + final cachedFileLength = bytes.length; + + return ( + response: dio_lib.Response( + statusCode: 200, + headers: Headers.fromMap({ + "content-type": ["audio/${track.codec.name}"], + "content-length": ["$cachedFileLength"], + "accept-ranges": ["bytes"], + "content-range": ["bytes 0-$cachedFileLength/$cachedFileLength"], + }), + requestOptions: RequestOptions(path: track.url), + ), + bytes: bytes, + ); + } + + /// Forcing partial content range as mpv sometimes greedily wants + /// everything at one go. Slows down overall streaming. + final range = RangeHeader.parse(headers["range"] ?? ""); + final contentPartialLength = int.tryParse(contentLength ?? ""); + if ((range.end == null) && + contentPartialLength != null && + range.start == 0) { + options = options.copyWith( + headers: { + ...?options.headers, + "range": "$range${(contentPartialLength * 0.3).ceil()}", + }, + ); + } + + final res = await dio + .get( + track.url, + options: options.copyWith(headers: { + ...?options.headers, + "user-agent": _randomUserAgent, + }), + ) + .catchError((e, stack) async { + AppLogger.reportError(e, stack); + final sourcedTrack = await ref + .read(sourcedTrackProvider(SpotubeMedia(track)).notifier) + .refreshStreamingUrl(); + + if (playlist.activeTrack?.id == sourcedTrack?.id && + sourcedTrack != null) { + ref.read(activeSourcedTrackProvider.notifier).update(sourcedTrack); + } + + return await dio.get( + sourcedTrack!.url, + options: options.copyWith(headers: { + ...?options.headers, + "user-agent": _randomUserAgent, + }), + ); + }); + + final bytes = res.data; + + if (bytes == null || !userPreferences.cacheMusic) { + return (response: res, bytes: bytes); + } + + final contentRange = + ContentRangeHeader.parse(res.headers.value("content-range") ?? ""); + + if (!await trackPartialCacheFile.exists()) { + await trackPartialCacheFile.create(recursive: true); + } + + // Write the stream to the file based on the range + final partialCacheFile = + await trackPartialCacheFile.open(mode: FileMode.writeOnlyAppend); + int fileLength = 0; + try { + await partialCacheFile.setPosition(contentRange.start); + await partialCacheFile.writeFrom(bytes); + fileLength = await partialCacheFile.length(); + } finally { + await partialCacheFile.close(); + } + + if (fileLength == contentRange.total) { + await trackPartialCacheFile.rename(trackCacheFile.path); + } + + if (contentRange.total == fileLength && track.codec != SourceCodecs.weba) { + final imageBytes = await ServiceUtils.downloadImage( + (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + index: 1, + ), + ); + + await MetadataGod.writeMetadata( + file: trackCacheFile.path, + metadata: track.toMetadata( + fileLength: fileLength, + imageBytes: imageBytes, + ), + ); + } + + return (bytes: bytes, response: res); + } + + /// @get('/stream/') + Future getStreamTrackId(Request request, String trackId) async { + try { + final track = + playlist.tracks.firstWhere((element) => element.id == trackId); + + final activeSourcedTrack = ref.read(activeSourcedTrackProvider); + final sourcedTrack = activeSourcedTrack?.id == track.id + ? activeSourcedTrack + : await ref.read(sourcedTrackProvider(SpotubeMedia(track)).future); + + if (playlist.activeTrack?.id == sourcedTrack?.id && + sourcedTrack != null) { + ref.read(activeSourcedTrackProvider.notifier).update(sourcedTrack); + } + + final (bytes: audioBytes, response: res) = + await streamTrack(sourcedTrack!, request.headers); + + return Response( + res.statusCode!, + body: audioBytes, + 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 new file mode 100644 index 00000000..d10815bf --- /dev/null +++ b/lib/provider/server/server.dart @@ -0,0 +1,55 @@ +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.dart b/lib/provider/server/sourced_track.dart new file mode 100644 index 00000000..f733f9d6 --- /dev/null +++ b/lib/provider/server/sourced_track.dart @@ -0,0 +1,49 @@ +import 'package:collection/collection.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; + +class SourcedTrackNotifier + extends FamilyAsyncNotifier { + @override + build(media) async { + final track = media?.track; + if (track == null || track is LocalTrack) { + return null; + } + + ref.listen( + audioPlayerProvider.select((value) => value.tracks), + (old, next) { + if (next.isEmpty || next.none((element) => element.id == track.id)) { + ref.invalidateSelf(); + } + }, + ); + + final sourcedTrack = + await SourcedTrack.fetchFromTrack(track: track, ref: ref); + + return sourcedTrack; + } + + Future refreshStreamingUrl() async { + if (arg == null) { + return null; + } + + return await update((prev) async { + return await SourcedTrack.fetchFromTrack( + track: state.value!, + ref: ref, + ); + }); + } +} + +final sourcedTrackProvider = AsyncNotifierProviderFamily( + () => SourcedTrackNotifier(), +); diff --git a/lib/provider/skip_segments/skip_segments.dart b/lib/provider/skip_segments/skip_segments.dart new file mode 100644 index 00000000..005797f4 --- /dev/null +++ b/lib/provider/skip_segments/skip_segments.dart @@ -0,0 +1,112 @@ +import 'package:spotube/models/database/database.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_sourced_track.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 track = ref.watch(activeSourcedTrackProvider); + if (track == null) return null; + + final skipNonMusic = ref.watch( + userPreferencesProvider.select( + (s) { + final isPipedYTMusicMode = s.audioSource == AudioSource.piped && + s.searchMode == SearchMode.youtubeMusic; + + return s.skipNonMusic && !isPipedYTMusicMode; + }, + ), + ); + + if (!skipNonMusic) { + return SourcedSegments( + segments: [], + source: track.sourceInfo.id, + ); + } + + final segments = await getAndCacheSkipSegments(track.sourceInfo.id, ref); + + return SourcedSegments( + source: track.sourceInfo.id, + segments: segments, + ); + }, +); diff --git a/lib/provider/sleep_timer_provider.dart b/lib/provider/sleep_timer_provider.dart index 32678ac7..53386e49 100644 --- a/lib/provider/sleep_timer_provider.dart +++ b/lib/provider/sleep_timer_provider.dart @@ -8,13 +8,6 @@ class SleepTimerNotifier extends StateNotifier { Timer? _timer; - static final provider = StateNotifierProvider( - (ref) => SleepTimerNotifier(), - ); - - static AlwaysAliveRefreshable get notifier => - provider.notifier; - void setSleepTimer(Duration duration) { state = duration; @@ -29,3 +22,7 @@ class SleepTimerNotifier extends StateNotifier { _timer?.cancel(); } } + +final sleepTimerProvider = StateNotifierProvider( + (ref) => SleepTimerNotifier(), +); diff --git a/lib/provider/spotify/album/favorite.dart b/lib/provider/spotify/album/favorite.dart new file mode 100644 index 00000000..157ab225 --- /dev/null +++ b/lib/provider/spotify/album/favorite.dart @@ -0,0 +1,91 @@ +part of '../spotify.dart'; + +class FavoriteAlbumState extends PaginatedState { + FavoriteAlbumState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + FavoriteAlbumState copyWith({items, offset, limit, hasMore}) { + return FavoriteAlbumState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class FavoriteAlbumNotifier + extends PaginatedAsyncNotifier { + @override + Future> fetch(int offset, int limit) async { + return await spotify + .invoke( + (api) => api.me.savedAlbums().getPage(limit, offset), + ) + .then( + (value) => value.items?.toList() ?? [], + ); + } + + @override + build() async { + ref.watch(spotifyProvider); + final items = await fetch(0, 20); + return FavoriteAlbumState( + items: items, + offset: 0, + limit: 20, + hasMore: items.length == 20, + ); + } + + Future addFavorites(List ids) async { + if (state.value == null) return; + + state = await AsyncValue.guard(() async { + await spotify.invoke((api) => api.me.saveAlbums(ids)); + final albums = await spotify.invoke( + (api) => api.albums.list(ids), + ); + + return state.value!.copyWith( + items: [ + ...state.value!.items, + ...albums, + ], + ); + }); + + for (final id in ids) { + ref.invalidate(albumsIsSavedProvider(id)); + } + } + + Future removeFavorites(List ids) async { + if (state.value == null) return; + + state = await AsyncValue.guard(() async { + await spotify.invoke((api) => api.me.removeAlbums(ids)); + + return state.value!.copyWith( + items: state.value!.items + .where((element) => !ids.contains(element.id)) + .toList(), + ); + }); + + for (final id in ids) { + ref.invalidate(albumsIsSavedProvider(id)); + } + } +} + +final favoriteAlbumsProvider = + AsyncNotifierProvider( + () => FavoriteAlbumNotifier(), +); diff --git a/lib/provider/spotify/album/is_saved.dart b/lib/provider/spotify/album/is_saved.dart new file mode 100644 index 00000000..aa48dfa0 --- /dev/null +++ b/lib/provider/spotify/album/is_saved.dart @@ -0,0 +1,12 @@ +part of '../spotify.dart'; + +final albumsIsSavedProvider = FutureProvider.autoDispose.family( + (ref, albumId) async { + final spotify = ref.watch(spotifyProvider); + return spotify.invoke( + (api) => api.me.containsSavedAlbums([albumId]).then( + (value) => value[albumId] ?? false, + ), + ); + }, +); diff --git a/lib/provider/spotify/album/releases.dart b/lib/provider/spotify/album/releases.dart new file mode 100644 index 00000000..25bb46b4 --- /dev/null +++ b/lib/provider/spotify/album/releases.dart @@ -0,0 +1,87 @@ +part of '../spotify.dart'; + +class AlbumReleasesState extends PaginatedState { + AlbumReleasesState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + AlbumReleasesState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return AlbumReleasesState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class AlbumReleasesNotifier + extends PaginatedAsyncNotifier { + AlbumReleasesNotifier() : super(); + + @override + fetch(int offset, int limit) async { + final market = ref.read(userPreferencesProvider).market; + + final albums = await spotify.invoke( + (api) => api.browse.newReleases(country: market).getPage(limit, offset), + ); + + return albums.items?.map((album) => album.toAlbum()).toList() ?? []; + } + + @override + build() async { + ref.watch(spotifyProvider); + ref.watch( + userPreferencesProvider.select((s) => s.market), + ); + ref.watch(allFollowedArtistsProvider); + + final albums = await fetch(0, 20); + + return AlbumReleasesState( + items: albums, + offset: 0, + limit: 20, + hasMore: albums.length == 20, + ); + } +} + +final albumReleasesProvider = + AsyncNotifierProvider( + () => AlbumReleasesNotifier(), +); + +final userArtistAlbumReleasesProvider = Provider>((ref) { + final newReleases = ref.watch(albumReleasesProvider); + final userArtistsQuery = ref.watch(allFollowedArtistsProvider); + + if (newReleases.isLoading || userArtistsQuery.isLoading) { + return const []; + } + + final userArtists = + userArtistsQuery.asData?.value.map((s) => s.id!).toList() ?? const []; + + final allReleases = newReleases.asData?.value.items; + final userArtistReleases = allReleases?.where((album) { + return album.artists?.any((artist) => userArtists.contains(artist.id!)) == + true; + }).toList(); + + if (userArtistReleases?.isEmpty == true) { + return allReleases?.toList() ?? []; + } + return userArtistReleases ?? []; +}); diff --git a/lib/provider/spotify/album/tracks.dart b/lib/provider/spotify/album/tracks.dart new file mode 100644 index 00000000..13c48886 --- /dev/null +++ b/lib/provider/spotify/album/tracks.dart @@ -0,0 +1,63 @@ +part of '../spotify.dart'; + +class AlbumTracksState extends PaginatedState { + AlbumTracksState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + AlbumTracksState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return AlbumTracksState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class AlbumTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier { + AlbumTracksNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + final tracks = await spotify.invoke( + (api) => api.albums.tracks(arg.id!).getPage(limit, offset), + ); + final items = await tracks.items!.asTracks(arg, ref); + + return ( + items: items, + hasMore: !tracks.isLast, + nextOffset: tracks.nextOffset, + ); + } + + @override + build(arg) async { + ref.cacheFor(); + + ref.watch(spotifyProvider); + final (:items, :nextOffset, :hasMore) = await fetch(arg, 0, 20); + return AlbumTracksState( + items: items, + offset: nextOffset, + limit: 20, + hasMore: hasMore, + ); + } +} + +final albumTracksProvider = AutoDisposeAsyncNotifierProviderFamily< + AlbumTracksNotifier, AlbumTracksState, AlbumSimple>( + () => AlbumTracksNotifier(), +); diff --git a/lib/provider/spotify/artist/albums.dart b/lib/provider/spotify/artist/albums.dart new file mode 100644 index 00000000..7852738a --- /dev/null +++ b/lib/provider/spotify/artist/albums.dart @@ -0,0 +1,68 @@ +part of '../spotify.dart'; + +class ArtistAlbumsState extends PaginatedState { + ArtistAlbumsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + ArtistAlbumsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return ArtistAlbumsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class ArtistAlbumsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< + Album, ArtistAlbumsState, String> { + ArtistAlbumsNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + final market = ref.read(userPreferencesProvider).market; + final albums = await spotify.invoke( + (api) => api.artists.albums(arg, country: market).getPage(limit, offset), + ); + + final items = albums.items?.toList() ?? []; + + return ( + items: items, + hasMore: !albums.isLast, + nextOffset: albums.nextOffset, + ); + } + + @override + build(arg) async { + ref.cacheFor(); + + ref.watch(spotifyProvider); + ref.watch( + userPreferencesProvider.select((s) => s.market), + ); + final (:items, :hasMore, :nextOffset) = await fetch(arg, 0, 20); + return ArtistAlbumsState( + items: items, + offset: nextOffset, + limit: 20, + hasMore: hasMore, + ); + } +} + +final artistAlbumsProvider = AutoDisposeAsyncNotifierProviderFamily< + ArtistAlbumsNotifier, ArtistAlbumsState, String>( + () => ArtistAlbumsNotifier(), +); diff --git a/lib/provider/spotify/artist/artist.dart b/lib/provider/spotify/artist/artist.dart new file mode 100644 index 00000000..dfee03e9 --- /dev/null +++ b/lib/provider/spotify/artist/artist.dart @@ -0,0 +1,10 @@ +part of '../spotify.dart'; + +final artistProvider = + FutureProvider.autoDispose.family((ref, String artistId) { + ref.cacheFor(); + + final spotify = ref.watch(spotifyProvider); + + return spotify.invoke((api) => api.artists.get(artistId)); +}); diff --git a/lib/provider/spotify/artist/following.dart b/lib/provider/spotify/artist/following.dart new file mode 100644 index 00000000..3a3795b7 --- /dev/null +++ b/lib/provider/spotify/artist/following.dart @@ -0,0 +1,152 @@ +part of '../spotify.dart'; + +class FollowedArtistsState extends CursorPaginatedState { + FollowedArtistsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + FollowedArtistsState copyWith({ + List? items, + String? offset, + int? limit, + bool? hasMore, + }) { + return FollowedArtistsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class FollowedArtistsNotifier + extends CursorPaginatedAsyncNotifier { + final Dio dio; + FollowedArtistsNotifier() + : dio = Dio(), + super(); + + @override + fetch(offset, limit) async { + final artists = await spotify.invoke( + (api) => api.me.following(FollowingType.artist).getPage( + limit, + offset ?? '', + ), + ); + + return (artists.items?.toList() ?? [], artists.after); + } + + @override + build() async { + ref.watch(spotifyProvider); + final (artists, nextCursor) = await fetch(null, 50); + return FollowedArtistsState( + items: artists, + offset: nextCursor, + limit: 50, + hasMore: artists.length == 50, + ); + } + + Future _followArtists(List artistIds) async { + try { + final creds = await spotify.invoke( + (api) => api.getCredentials(), + ); + + await dio.post( + "https://api-partner.spotify.com/pathfinder/v1/query", + data: { + "variables": { + "uris": artistIds.map((id) => "spotify:artist:$id").toList() + }, + "operationName": "addToLibrary", + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": + "a3c1ff58e6a36fec5fe1e3a193dc95d9071d96b9ba53c5ba9c1494fb1ee73915" + } + } + }, + options: Options( + headers: { + "accept": "application/json", + 'app-platform': 'WebPlayer', + 'authorization': 'Bearer ${creds.accessToken}', + 'content-type': 'application/json;charset=UTF-8', + }, + responseType: ResponseType.json, + ), + ); + } on DioException catch (e, stack) { + AppLogger.reportError(e, stack); + } + } + + Future saveArtists(List artistIds) async { + if (state.value == null) return; + // await spotify.me.follow(FollowingType.artist, artistIds); + await _followArtists(artistIds); + + state = await AsyncValue.guard(() async { + final artists = await spotify.invoke( + (api) => api.artists.list(artistIds), + ); + + return state.value!.copyWith( + items: [ + ...state.value!.items, + ...artists, + ], + ); + }); + + for (final id in artistIds) { + ref.invalidate(artistIsFollowingProvider(id)); + } + } + + Future removeArtists(List artistIds) async { + if (state.value == null) return; + await spotify.invoke( + (api) => api.me.unfollow(FollowingType.artist, artistIds), + ); + + state = await AsyncValue.guard(() async { + final artists = state.value!.items.where((artist) { + return !artistIds.contains(artist.id); + }).toList(); + + return state.value!.copyWith( + items: artists, + ); + }); + + for (final id in artistIds) { + ref.invalidate(artistIsFollowingProvider(id)); + } + } +} + +final followedArtistsProvider = + AsyncNotifierProvider( + () => FollowedArtistsNotifier(), +); + +final allFollowedArtistsProvider = FutureProvider>( + (ref) async { + final spotify = ref.watch(spotifyProvider); + final artists = await spotify.invoke( + (api) => api.me.following(FollowingType.artist).all(), + ); + return artists.toList(); + }, +); diff --git a/lib/provider/spotify/artist/is_following.dart b/lib/provider/spotify/artist/is_following.dart new file mode 100644 index 00000000..fb519518 --- /dev/null +++ b/lib/provider/spotify/artist/is_following.dart @@ -0,0 +1,12 @@ +part of '../spotify.dart'; + +final artistIsFollowingProvider = FutureProvider.family( + (ref, String artistId) async { + final spotify = ref.watch(spotifyProvider); + return spotify.invoke( + (api) => api.me.checkFollowing(FollowingType.artist, [artistId]).then( + (value) => value[artistId] ?? false, + ), + ); + }, +); diff --git a/lib/provider/spotify/artist/related.dart b/lib/provider/spotify/artist/related.dart new file mode 100644 index 00000000..7246fa11 --- /dev/null +++ b/lib/provider/spotify/artist/related.dart @@ -0,0 +1,13 @@ +part of '../spotify.dart'; + +final relatedArtistsProvider = FutureProvider.autoDispose + .family, String>((ref, artistId) async { + ref.cacheFor(); + + final spotify = ref.watch(spotifyProvider); + final artists = await spotify.invoke( + (api) => api.artists.relatedArtists(artistId), + ); + + return artists.toList(); +}); diff --git a/lib/provider/spotify/artist/top_tracks.dart b/lib/provider/spotify/artist/top_tracks.dart new file mode 100644 index 00000000..51321b21 --- /dev/null +++ b/lib/provider/spotify/artist/top_tracks.dart @@ -0,0 +1,16 @@ +part of '../spotify.dart'; + +final artistTopTracksProvider = + FutureProvider.autoDispose.family, String>( + (ref, artistId) async { + ref.cacheFor(); + + final spotify = ref.watch(spotifyProvider); + final market = ref.watch(userPreferencesProvider.select((s) => s.market)); + final tracks = await spotify.invoke( + (api) => api.artists.topTracks(artistId, market), + ); + + return tracks.toList(); + }, +); diff --git a/lib/provider/spotify/artist/wikipedia.dart b/lib/provider/spotify/artist/wikipedia.dart new file mode 100644 index 00000000..b2e2e6dc --- /dev/null +++ b/lib/provider/spotify/artist/wikipedia.dart @@ -0,0 +1,12 @@ +part of '../spotify.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/spotify/category/categories.dart b/lib/provider/spotify/category/categories.dart new file mode 100644 index 00000000..67476f34 --- /dev/null +++ b/lib/provider/spotify/category/categories.dart @@ -0,0 +1,21 @@ +part of '../spotify.dart'; + +final categoriesProvider = FutureProvider( + (ref) async { + final spotify = ref.watch(spotifyProvider); + final market = ref.watch(userPreferencesProvider.select((s) => s.market)); + final locale = ref.watch(userPreferencesProvider.select((s) => s.locale)); + final categories = await spotify.invoke( + (api) => api.categories + .list( + country: market, + locale: Intl.canonicalizedLocale( + locale.toString(), + ), + ) + .all(), + ); + + return categories.toList()..shuffle(); + }, +); diff --git a/lib/provider/spotify/category/genres.dart b/lib/provider/spotify/category/genres.dart new file mode 100644 index 00000000..b4b75b7b --- /dev/null +++ b/lib/provider/spotify/category/genres.dart @@ -0,0 +1,6 @@ +part of '../spotify.dart'; + +final categoryGenresProvider = FutureProvider>((ref) async { + final customSpotify = ref.watch(customSpotifyEndpointProvider); + return await customSpotify.listGenreSeeds(); +}); diff --git a/lib/provider/spotify/category/playlists.dart b/lib/provider/spotify/category/playlists.dart new file mode 100644 index 00000000..2afd8d97 --- /dev/null +++ b/lib/provider/spotify/category/playlists.dart @@ -0,0 +1,73 @@ +part of '../spotify.dart'; + +class CategoryPlaylistsState extends PaginatedState { + CategoryPlaylistsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + CategoryPlaylistsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return CategoryPlaylistsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class CategoryPlaylistsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< + PlaylistSimple, CategoryPlaylistsState, String> { + CategoryPlaylistsNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + final preferences = ref.read(userPreferencesProvider); + final playlists = await Pages( + spotify.api, + "v1/browse/categories/$arg/playlists?country=${preferences.market.name}&locale=${preferences.locale}", + (json) => json == null ? null : PlaylistSimple.fromJson(json), + 'playlists', + (json) => PlaylistsFeatured.fromJson(json), + ).getPage(limit, offset); + + final items = playlists.items?.nonNulls.toList() ?? []; + + return ( + items: items, + hasMore: !playlists.isLast, + nextOffset: playlists.nextOffset, + ); + } + + @override + build(arg) async { + ref.cacheFor(); + + ref.watch(spotifyProvider); + ref.watch(userPreferencesProvider.select((s) => s.locale)); + ref.watch(userPreferencesProvider.select((s) => s.market)); + + final (:items, :hasMore, :nextOffset) = await fetch(arg, 0, 8); + + return CategoryPlaylistsState( + items: items, + offset: nextOffset, + limit: 8, + hasMore: hasMore, + ); + } +} + +final categoryPlaylistsProvider = AutoDisposeAsyncNotifierProviderFamily< + CategoryPlaylistsNotifier, CategoryPlaylistsState, String>( + () => CategoryPlaylistsNotifier(), +); diff --git a/lib/provider/spotify/lyrics/synced.dart b/lib/provider/spotify/lyrics/synced.dart new file mode 100644 index 00000000..ff2a73f1 --- /dev/null +++ b/lib/provider/spotify/lyrics/synced.dart @@ -0,0 +1,194 @@ +part of '../spotify.dart'; + +class SyncedLyricsNotifier extends FamilyAsyncNotifier { + Track get _track => arg!; + + Future getSpotifyLyrics(String? token) async { + final res = await globalDio.getUri( + Uri.parse( + "https://spclient.wg.spotify.com/color-lyrics/v2/track/${_track.id}?format=json&market=from_token", + ), + options: Options( + headers: { + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36", + "App-platform": "WebPlayer", + "authorization": "Bearer $token" + }, + responseType: ResponseType.json, + validateStatus: (status) => true, + ), + ); + + if (res.statusCode != 200) { + return SubtitleSimple( + lyrics: [], + name: _track.name!, + uri: res.realUri, + rating: 0, + provider: "Spotify", + ); + } + final linesRaw = + Map.castFrom(res.data)["lyrics"] + ?["lines"] as List?; + + final lines = linesRaw?.map((line) { + return LyricSlice( + time: Duration(milliseconds: int.parse(line["startTimeMs"])), + text: line["words"] as String, + ); + }).toList() ?? + []; + + return SubtitleSimple( + lyrics: lines, + name: _track.name!, + uri: res.realUri, + rating: 100, + provider: "Spotify", + ); + } + + /// 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, + "duration": _track.duration?.inSeconds.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); + final spotify = ref.watch(spotifyProvider); + final auth = await ref.watch(authenticationProvider.future); + + 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; + + final token = await spotify.invoke((api) => api.getCredentials()); + + if ((lyrics == null || lyrics.lyrics.isEmpty) && auth != null) { + lyrics = await getSpotifyLyrics(token.accessToken); + } + + 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, Track? 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/spotify/playlist/favorite.dart b/lib/provider/spotify/playlist/favorite.dart new file mode 100644 index 00000000..4df888ce --- /dev/null +++ b/lib/provider/spotify/playlist/favorite.dart @@ -0,0 +1,146 @@ +part of '../spotify.dart'; + +class FavoritePlaylistsState extends PaginatedState { + FavoritePlaylistsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + FavoritePlaylistsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return FavoritePlaylistsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class FavoritePlaylistsNotifier + extends PaginatedAsyncNotifier { + FavoritePlaylistsNotifier() : super(); + + @override + fetch(int offset, int limit) async { + final playlists = await spotify.invoke( + (api) => api.playlists.me.getPage( + limit, + offset, + ), + ); + + return playlists.items?.toList() ?? []; + } + + @override + build() async { + ref.watch(spotifyProvider); + final playlists = await fetch(0, 20); + + return FavoritePlaylistsState( + items: playlists, + offset: 0, + limit: 20, + hasMore: playlists.length == 20, + ); + } + + void updatePlaylist(PlaylistSimple 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(PlaylistSimple playlist) async { + await update((state) async { + await spotify.invoke( + (api) => api.playlists.followPlaylist(playlist.id!), + ); + return state.copyWith( + items: [...state.items, playlist], + ); + }); + + ref.invalidate(isFavoritePlaylistProvider(playlist.id!)); + } + + Future removeFavorite(PlaylistSimple playlist) async { + await update((state) async { + await spotify.invoke( + (api) => api.playlists.unfollowPlaylist(playlist.id!), + ); + return state.copyWith( + items: state.items.where((e) => e.id != playlist.id).toList(), + ); + }); + + ref.invalidate(isFavoritePlaylistProvider(playlist.id!)); + } + + Future addTracks(String playlistId, List trackIds) async { + if (state.value == null) return; + + final spotify = ref.read(spotifyProvider); + + await spotify.invoke( + (api) => api.playlists.addTracks( + trackIds.map((id) => 'spotify:track:$id').toList(), + playlistId, + ), + ); + + ref.invalidate(playlistTracksProvider(playlistId)); + } + + Future removeTracks(String playlistId, List trackIds) async { + if (state.value == null) return; + + final spotify = ref.read(spotifyProvider); + + await spotify.invoke( + (api) => api.playlists.removeTracks( + trackIds.map((id) => 'spotify:track:$id').toList(), + playlistId, + ), + ); + + ref.invalidate(playlistTracksProvider(playlistId)); + } +} + +final favoritePlaylistsProvider = + AsyncNotifierProvider( + () => FavoritePlaylistsNotifier(), +); + +final isFavoritePlaylistProvider = FutureProvider.family( + (ref, id) async { + final spotify = ref.watch(spotifyProvider); + final me = ref.watch(meProvider); + + if (me.value == null) { + return false; + } + + final follows = await spotify + .invoke((api) => api.playlists.followedByUsers(id, [me.value!.id!])); + + return follows[me.value!.id!] ?? false; + }, +); diff --git a/lib/provider/spotify/playlist/featured.dart b/lib/provider/spotify/playlist/featured.dart new file mode 100644 index 00000000..9f751909 --- /dev/null +++ b/lib/provider/spotify/playlist/featured.dart @@ -0,0 +1,57 @@ +part of '../spotify.dart'; + +class FeaturedPlaylistsState extends PaginatedState { + FeaturedPlaylistsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + FeaturedPlaylistsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return FeaturedPlaylistsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class FeaturedPlaylistsNotifier + extends PaginatedAsyncNotifier { + FeaturedPlaylistsNotifier() : super(); + + @override + fetch(int offset, int limit) async { + final playlists = await spotify.invoke( + (api) => api.playlists.featured.getPage(limit, offset), + ); + + return playlists.items?.toList() ?? []; + } + + @override + build() async { + ref.watch(spotifyProvider); + final playlists = await fetch(0, 20); + + return FeaturedPlaylistsState( + items: playlists, + offset: 0, + limit: 20, + hasMore: playlists.length == 20, + ); + } +} + +final featuredPlaylistsProvider = + AsyncNotifierProvider( + () => FeaturedPlaylistsNotifier(), +); diff --git a/lib/provider/spotify/playlist/generate.dart b/lib/provider/spotify/playlist/generate.dart new file mode 100644 index 00000000..b2250df6 --- /dev/null +++ b/lib/provider/spotify/playlist/generate.dart @@ -0,0 +1,44 @@ +part of '../spotify.dart'; + +final generatePlaylistProvider = FutureProvider.autoDispose + .family, GeneratePlaylistProviderInput>( + (ref, input) async { + final spotify = ref.watch(spotifyProvider); + final market = ref.watch( + userPreferencesProvider.select((s) => s.market), + ); + + final recommendation = await spotify.invoke( + (api) => api.recommendations + .get( + limit: input.limit, + seedArtists: input.seedArtists?.toList(), + seedGenres: input.seedGenres?.toList(), + seedTracks: input.seedTracks?.toList(), + market: market, + max: (input.max?.toJson()?..removeWhere((key, value) => value == null)) + ?.cast(), + min: (input.min?.toJson()?..removeWhere((key, value) => value == null)) + ?.cast(), + target: (input.target?.toJson() + ?..removeWhere((key, value) => value == null)) + ?.cast(), + ) + .catchError((e, stackTrace) { + AppLogger.reportError(e, stackTrace); + return Recommendations(); + }), + ); + + if (recommendation.tracks?.isEmpty ?? true) { + return []; + } + + final tracks = await spotify.invoke( + (api) => + api.tracks.list(recommendation.tracks!.map((e) => e.id!).toList()), + ); + + return tracks.toList(); + }, +); diff --git a/lib/provider/spotify/playlist/liked.dart b/lib/provider/spotify/playlist/liked.dart new file mode 100644 index 00000000..99c75719 --- /dev/null +++ b/lib/provider/spotify/playlist/liked.dart @@ -0,0 +1,39 @@ +part of '../spotify.dart'; + +class LikedTracksNotifier extends AsyncNotifier> { + @override + FutureOr> build() async { + final spotify = ref.watch(spotifyProvider); + final savedTracked = await spotify.invoke( + (api) => api.tracks.me.saved.all(), + ); + + return savedTracked.map((e) => e.track!).toList(); + } + + Future toggleFavorite(Track track) async { + if (state.value == null) return; + final spotify = ref.read(spotifyProvider); + + await update((tracks) async { + final isLiked = tracks.map((e) => e.id).contains(track.id); + + if (isLiked) { + await spotify.invoke( + (api) => api.tracks.me.removeOne(track.id!), + ); + return tracks.where((e) => e.id != track.id).toList(); + } else { + await spotify.invoke( + (api) => api.tracks.me.saveOne(track.id!), + ); + return [track, ...tracks]; + } + }); + } +} + +final likedTracksProvider = + AsyncNotifierProvider>( + () => LikedTracksNotifier(), +); diff --git a/lib/provider/spotify/playlist/playlist.dart b/lib/provider/spotify/playlist/playlist.dart new file mode 100644 index 00000000..34d1fe8e --- /dev/null +++ b/lib/provider/spotify/playlist/playlist.dart @@ -0,0 +1,173 @@ +part of '../spotify.dart'; + +typedef PlaylistInput = ({ + String playlistName, + bool? public, + bool? collaborative, + String? description, + String? base64Image, +}); + +class PlaylistNotifier extends FamilyAsyncNotifier { + @override + FutureOr build(String arg) { + final spotify = ref.watch(spotifyProvider); + return spotify.invoke( + (api) => api.playlists.get(arg), + ); + } + + Future create(PlaylistInput input, [ValueChanged? onError]) async { + if (state is AsyncLoading) return; + state = const AsyncLoading(); + + final spotify = ref.read(spotifyProvider); + final me = ref.read(meProvider); + + if (me.value == null) return; + + state = await AsyncValue.guard(() async { + try { + final playlist = await spotify.invoke( + (api) => api.playlists.createPlaylist( + me.value!.id!, + input.playlistName, + collaborative: input.collaborative, + description: input.description, + public: input.public, + ), + ); + + if (input.base64Image != null) { + await spotify.invoke( + (api) => api.playlists.updatePlaylistImage( + playlist.id!, + input.base64Image!, + ), + ); + } + + return playlist; + } catch (e) { + onError?.call(e); + rethrow; + } + }); + + ref.invalidate(favoritePlaylistsProvider); + } + + Future modify(PlaylistInput input, [ValueChanged? onError]) async { + if (state.value == null) return; + + final spotify = ref.read(spotifyProvider); + + await update((state) async { + try { + await spotify.invoke( + (api) => api.playlists.updatePlaylist( + state.id!, + input.playlistName, + collaborative: input.collaborative, + description: input.description, + public: input.public, + ), + ); + + if (input.base64Image != null) { + await spotify.invoke( + (api) => api.playlists.updatePlaylistImage( + state.id!, + input.base64Image!, + ), + ); + + final playlist = await spotify.invoke( + (api) => api.playlists.get(state.id!), + ); + + ref.read(favoritePlaylistsProvider.notifier).updatePlaylist(playlist); + return playlist; + } + + final playlist = Playlist.fromJson( + { + ...state.toJson(), + 'name': input.playlistName, + 'collaborative': input.collaborative, + 'description': input.description, + 'public': input.public, + }, + ); + + ref.read(favoritePlaylistsProvider.notifier).updatePlaylist(playlist); + + return playlist; + } catch (e, stack) { + onError?.call(e); + AppLogger.reportError(e, stack); + rethrow; + } + }); + } + + Future addTracks(List trackIds, [ValueChanged? onError]) async { + try { + if (state.value == null) return; + + final spotify = ref.read(spotifyProvider); + + await spotify.invoke( + (api) => api.playlists.addTracks( + trackIds.map((id) => "spotify:track:$id").toList(), + state.value!.id!, + ), + ); + } catch (e, stack) { + onError?.call(e); + AppLogger.reportError(e, stack); + rethrow; + } + } +} + +final playlistProvider = + AsyncNotifierProvider.family( + () => PlaylistNotifier(), +); + +final _blendModes = BlendMode.values + .where((e) => switch (e) { + BlendMode.clear || + BlendMode.src || + BlendMode.srcATop || + BlendMode.srcIn || + BlendMode.srcOut || + BlendMode.srcOver || + BlendMode.dstOut || + BlendMode.xor => + false, + _ => true + }) + .toList(); + +typedef PlaylistImageInfo = ({ + Color color, + BlendMode colorBlendMode, + String src, + Alignment placement, +}); + +final playlistImageProvider = Provider.family( + (ref, playlistId) { + final random = Random(); + + return ( + color: Colors.primaries[random.nextInt(Colors.primaries.length)], + colorBlendMode: _blendModes[random.nextInt(_blendModes.length)], + src: Assets + .patterns.values[random.nextInt(Assets.patterns.values.length)].path, + placement: random.nextBool() ? Alignment.topLeft : Alignment.bottomLeft, + ); + }, +); diff --git a/lib/provider/spotify/playlist/tracks.dart b/lib/provider/spotify/playlist/tracks.dart new file mode 100644 index 00000000..1dbb83be --- /dev/null +++ b/lib/provider/spotify/playlist/tracks.dart @@ -0,0 +1,70 @@ +part of '../spotify.dart'; + +class PlaylistTracksState extends PaginatedState { + PlaylistTracksState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + PlaylistTracksState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return PlaylistTracksState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class PlaylistTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< + Track, PlaylistTracksState, String> { + PlaylistTracksNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + final tracks = await spotify.invoke( + (api) => api.playlists.getTracksByPlaylistId(arg).getPage(limit, offset), + ); + + /// Filter out tracks with null id because some personal playlists + /// may contain local tracks that are not available in the Spotify catalog + final items = tracks.items + ?.where((track) => track.id != null && track.type == "track") + .toList() ?? + []; + + return ( + items: items, + hasMore: !tracks.isLast, + nextOffset: tracks.nextOffset, + ); + } + + @override + build(arg) async { + ref.cacheFor(); + + ref.watch(spotifyProvider); + final (items: tracks, :hasMore, :nextOffset) = await fetch(arg, 0, 20); + + return PlaylistTracksState( + items: tracks, + offset: nextOffset, + limit: 20, + hasMore: hasMore, + ); + } +} + +final playlistTracksProvider = AutoDisposeAsyncNotifierProviderFamily< + PlaylistTracksNotifier, PlaylistTracksState, String>( + () => PlaylistTracksNotifier(), +); diff --git a/lib/provider/spotify/search/search.dart b/lib/provider/spotify/search/search.dart new file mode 100644 index 00000000..828cc382 --- /dev/null +++ b/lib/provider/spotify/search/search.dart @@ -0,0 +1,90 @@ +part of '../spotify.dart'; + +final searchTermStateProvider = StateProvider.autoDispose( + (ref) { + ref.cacheFor(const Duration(minutes: 2)); + return ""; + }, +); + +class SearchState extends PaginatedState { + SearchState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + SearchState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return SearchState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class SearchNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier, SearchType> { + SearchNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + if (state.value == null) { + return ( + items: [], + hasMore: false, + nextOffset: 0, + ); + } + final results = await spotify.invoke( + (api) => api.search + .get( + ref.read(searchTermStateProvider), + types: [arg], + market: ref.read(userPreferencesProvider).market, + ) + .getPage(limit, offset), + ); + + final items = results.expand((e) => e.items ?? []).toList().cast(); + + return ( + items: items, + hasMore: items.length == limit, + nextOffset: offset + limit, + ); + } + + @override + build(arg) async { + ref.cacheFor(const Duration(minutes: 2)); + + ref.watch(searchTermStateProvider); + ref.watch(spotifyProvider); + ref.watch( + userPreferencesProvider.select((value) => value.market), + ); + + final (:items, :hasMore, :nextOffset) = await fetch(arg, 0, 10); + + return SearchState( + items: items, + offset: nextOffset, + limit: 10, + hasMore: hasMore, + ); + } +} + +final searchProvider = AsyncNotifierProvider.autoDispose + .family( + () => SearchNotifier(), +); diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart new file mode 100644 index 00000000..a0753fcb --- /dev/null +++ b/lib/provider/spotify/spotify.dart @@ -0,0 +1,133 @@ +library spotify; + +import 'dart:async'; +import 'dart:math'; + +import 'package:drift/drift.dart'; +import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/collections/env.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/services/logger/logger.dart'; +import 'package:collection/collection.dart'; +import 'package:dio/dio.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:intl/intl.dart'; +import 'package:lrc/lrc.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:spotify/spotify.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +// ignore: depend_on_referenced_packages, implementation_imports +import 'package:riverpod/src/async_notifier.dart'; +import 'package:spotube/extensions/album_simple.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/lyrics.dart'; +import 'package:spotube/models/spotify/recommendation_seeds.dart'; +import 'package:spotube/models/spotify_friends.dart'; +import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/dio/dio.dart'; +import 'package:spotube/services/wikipedia/wikipedia.dart'; +import 'package:spotube/utils/primitive_utils.dart'; + +import 'package:wikipedia_api/wikipedia_api.dart'; + +part 'album/favorite.dart'; +part 'album/tracks.dart'; +part 'album/releases.dart'; +part 'album/is_saved.dart'; + +part 'artist/artist.dart'; +part 'artist/is_following.dart'; +part 'artist/following.dart'; +part 'artist/top_tracks.dart'; +part 'artist/albums.dart'; +part 'artist/wikipedia.dart'; +part 'artist/related.dart'; + +part 'category/genres.dart'; +part 'category/categories.dart'; +part 'category/playlists.dart'; + +part 'lyrics/synced.dart'; + +part 'playlist/favorite.dart'; +part 'playlist/playlist.dart'; +part 'playlist/liked.dart'; +part 'playlist/tracks.dart'; +part 'playlist/featured.dart'; +part 'playlist/generate.dart'; + +part 'search/search.dart'; + +part 'user/me.dart'; +part 'user/friends.dart'; + +part 'tracks/track.dart'; + +part 'views/view.dart'; + +part 'utils/mixin.dart'; +part 'utils/state.dart'; +part 'utils/provider.dart'; +part 'utils/async.dart'; + +part 'utils/provider/paginated.dart'; +part 'utils/provider/cursor.dart'; +part 'utils/provider/paginated_family.dart'; +part 'utils/provider/cursor_family.dart'; + +class SpotifyApiWrapper { + final SpotifyApi api; + + final Ref ref; + SpotifyApiWrapper( + this.ref, + this.api, + ); + + bool _isRefreshing = false; + + FutureOr invoke( + FutureOr Function(SpotifyApi api) fn, + ) async { + try { + return await fn(api); + } catch (e) { + if (((e is AuthorizationException && e.error == 'invalid_token') || + e is ExpirationException) && + !_isRefreshing) { + _isRefreshing = true; + await ref.read(authenticationProvider.notifier).refreshCredentials(); + + _isRefreshing = false; + return await fn(api); + } + rethrow; + } + } +} + +final spotifyProvider = Provider( + (ref) { + final authState = ref.watch(authenticationProvider); + final anonCred = PrimitiveUtils.getRandomElement(Env.spotifySecrets); + + final wrapper = SpotifyApiWrapper( + ref, + authState.asData?.value == null + ? SpotifyApi( + SpotifyApiCredentials( + anonCred["clientId"], + anonCred["clientSecret"], + ), + ) + : SpotifyApi.withAccessToken( + authState.asData!.value!.accessToken.value, + ), + ); + + return wrapper; + }, +); diff --git a/lib/provider/spotify/tracks/track.dart b/lib/provider/spotify/tracks/track.dart new file mode 100644 index 00000000..9863aa25 --- /dev/null +++ b/lib/provider/spotify/tracks/track.dart @@ -0,0 +1,10 @@ +part of '../spotify.dart'; + +final trackProvider = + FutureProvider.autoDispose.family((ref, id) async { + ref.cacheFor(); + + final spotify = ref.watch(spotifyProvider); + + return spotify.invoke((api) => api.tracks.get(id)); +}); diff --git a/lib/provider/spotify/user/friends.dart b/lib/provider/spotify/user/friends.dart new file mode 100644 index 00000000..b9cc0f46 --- /dev/null +++ b/lib/provider/spotify/user/friends.dart @@ -0,0 +1,7 @@ +part of '../spotify.dart'; + +final friendsProvider = FutureProvider((ref) async { + final customSpotify = ref.watch(customSpotifyEndpointProvider); + + return customSpotify.getFriendActivity(); +}); diff --git a/lib/provider/spotify/user/me.dart b/lib/provider/spotify/user/me.dart new file mode 100644 index 00000000..09f5fc2d --- /dev/null +++ b/lib/provider/spotify/user/me.dart @@ -0,0 +1,6 @@ +part of '../spotify.dart'; + +final meProvider = FutureProvider((ref) async { + final spotify = ref.watch(spotifyProvider); + return spotify.invoke((api) => api.me.get()); +}); diff --git a/lib/provider/spotify/utils/async.dart b/lib/provider/spotify/utils/async.dart new file mode 100644 index 00000000..1040d682 --- /dev/null +++ b/lib/provider/spotify/utils/async.dart @@ -0,0 +1,5 @@ +part of '../spotify.dart'; + +extension PaginationExtension on AsyncValue { + bool get isLoadingNextPage => this is AsyncData && this is AsyncLoadingNext; +} diff --git a/lib/provider/spotify/utils/json_cast.dart b/lib/provider/spotify/utils/json_cast.dart new file mode 100644 index 00000000..30700971 --- /dev/null +++ b/lib/provider/spotify/utils/json_cast.dart @@ -0,0 +1,21 @@ +Map castNestedJson(Map map) { + return Map.castFrom( + map.map((key, value) { + if (value is Map) { + return MapEntry( + key, + castNestedJson(value), + ); + } else if (value is Iterable) { + return MapEntry( + key, + value.map((e) { + if (e is Map) return castNestedJson(e); + return e; + }).toList(), + ); + } + return MapEntry(key, value); + }), + ); +} diff --git a/lib/provider/spotify/utils/mixin.dart b/lib/provider/spotify/utils/mixin.dart new file mode 100644 index 00000000..60788814 --- /dev/null +++ b/lib/provider/spotify/utils/mixin.dart @@ -0,0 +1,24 @@ +part of '../spotify.dart'; + +// ignore: invalid_use_of_internal_member +mixin SpotifyMixin on AsyncNotifierBase { + SpotifyApiWrapper get spotify => ref.read(spotifyProvider); +} + +extension on AutoDisposeAsyncNotifierProviderRef { + // When invoked keeps your provider alive for [duration] + void cacheFor([Duration duration = const Duration(minutes: 5)]) { + final link = keepAlive(); + final timer = Timer(duration, () => link.close()); + onDispose(() => timer.cancel()); + } +} + +extension on AutoDisposeRef { + // When invoked keeps your provider alive for [duration] + void cacheFor([Duration duration = const Duration(minutes: 5)]) { + final link = keepAlive(); + final timer = Timer(duration, () => link.close()); + onDispose(() => timer.cancel()); + } +} diff --git a/lib/provider/spotify/utils/provider.dart b/lib/provider/spotify/utils/provider.dart new file mode 100644 index 00000000..50458c3a --- /dev/null +++ b/lib/provider/spotify/utils/provider.dart @@ -0,0 +1,6 @@ +part of '../spotify.dart'; + +// ignore: subtype_of_sealed_class +class AsyncLoadingNext extends AsyncData { + const AsyncLoadingNext(super.value); +} diff --git a/lib/provider/spotify/utils/provider/cursor.dart b/lib/provider/spotify/utils/provider/cursor.dart new file mode 100644 index 00000000..c241827e --- /dev/null +++ b/lib/provider/spotify/utils/provider/cursor.dart @@ -0,0 +1,56 @@ +part of '../../spotify.dart'; + +mixin CursorPaginatedAsyncNotifierMixin> + // ignore: invalid_use_of_internal_member + on AsyncNotifierBase { + Future<(List items, String nextCursor)> fetch(String? offset, int limit); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch(state.value!.offset, state.value!.limit); + return state.value!.copyWith( + hasMore: items.$1.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items.$1, + ], + offset: items.$2, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch(state.offset, state.limit); + + hasMore = items.$1.length == state.limit; + return state.copyWith( + items: [...state.items, ...items.$1], + offset: items.$2, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} + +abstract class CursorPaginatedAsyncNotifier> extends AsyncNotifier + with CursorPaginatedAsyncNotifierMixin, SpotifyMixin {} + +abstract class AutoDisposeCursorPaginatedAsyncNotifier> extends AutoDisposeAsyncNotifier + with CursorPaginatedAsyncNotifierMixin, SpotifyMixin {} diff --git a/lib/provider/spotify/utils/provider/cursor_family.dart b/lib/provider/spotify/utils/provider/cursor_family.dart new file mode 100644 index 00000000..ea8577de --- /dev/null +++ b/lib/provider/spotify/utils/provider/cursor_family.dart @@ -0,0 +1,113 @@ +part of '../../spotify.dart'; + +abstract class FamilyCursorPaginatedAsyncNotifier< + K, + T extends CursorPaginatedState, + A> extends FamilyAsyncNotifier with SpotifyMixin { + Future<(List items, String nextCursor)> fetch( + A arg, + String? offset, + int limit, + ); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch(arg, state.value!.offset, state.value!.limit); + return state.value!.copyWith( + hasMore: items.$1.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items.$1, + ], + offset: items.$2, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch( + arg, + state.offset, + state.limit, + ); + + hasMore = items.$1.length == state.limit; + return state.copyWith( + items: [...state.items, ...items.$1], + offset: items.$2, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} + +abstract class AutoDisposeFamilyCursorPaginatedAsyncNotifier< + K, + T extends CursorPaginatedState, + A> extends AutoDisposeFamilyAsyncNotifier with SpotifyMixin { + Future<(List items, String nextCursor)> fetch( + A arg, + String? offset, + int limit, + ); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch(arg, state.value!.offset, state.value!.limit); + return state.value!.copyWith( + hasMore: items.$1.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items.$1, + ], + offset: items.$2, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch( + arg, + state.offset, + state.limit, + ); + + hasMore = items.$1.length == state.limit; + return state.copyWith( + items: [...state.items, ...items.$1], + offset: items.$2, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} diff --git a/lib/provider/spotify/utils/provider/paginated.dart b/lib/provider/spotify/utils/provider/paginated.dart new file mode 100644 index 00000000..30b66e67 --- /dev/null +++ b/lib/provider/spotify/utils/provider/paginated.dart @@ -0,0 +1,63 @@ +part of '../../spotify.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; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch( + state.value!.offset + state.value!.limit, + state.value!.limit, + ); + return state.value!.copyWith( + hasMore: items.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items, + ], + offset: state.value!.offset + state.value!.limit, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch( + state.offset + state.limit, + state.limit, + ); + + hasMore = items.length == state.limit; + return state.copyWith( + items: [...state.items, ...items], + offset: state.offset + state.limit, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} + +abstract class PaginatedAsyncNotifier> + extends AsyncNotifier + with PaginatedAsyncNotifierMixin, SpotifyMixin {} + +abstract class AutoDisposePaginatedAsyncNotifier> + extends AutoDisposeAsyncNotifier + with PaginatedAsyncNotifierMixin, SpotifyMixin {} diff --git a/lib/provider/spotify/utils/provider/paginated_family.dart b/lib/provider/spotify/utils/provider/paginated_family.dart new file mode 100644 index 00000000..c08c8673 --- /dev/null +++ b/lib/provider/spotify/utils/provider/paginated_family.dart @@ -0,0 +1,120 @@ +part of '../../spotify.dart'; + +typedef PseudoPaginatedProps = ({ + List items, + int nextOffset, + bool hasMore, +}); + +abstract class FamilyPaginatedAsyncNotifier< + K, + T extends BasePaginatedState, + A> extends FamilyAsyncNotifier with SpotifyMixin { + Future> fetch(A arg, int offset, int limit); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final (:items, :hasMore, :nextOffset) = await fetch( + arg, + state.value!.offset, + state.value!.limit, + ); + return state.value!.copyWith( + hasMore: hasMore, + items: [ + ...state.value!.items, + ...items, + ], + offset: nextOffset, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final res = await fetch( + arg, + state.offset, + state.limit, + ); + + hasMore = res.hasMore; + return state.copyWith( + items: [...state.items, ...res.items], + offset: res.nextOffset, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} + +abstract class AutoDisposeFamilyPaginatedAsyncNotifier< + K, + T extends BasePaginatedState, + A> extends AutoDisposeFamilyAsyncNotifier with SpotifyMixin { + Future> fetch(A arg, int offset, int limit); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final (:items, :hasMore, :nextOffset) = await fetch( + arg, + state.value!.offset, + state.value!.limit, + ); + + return state.value!.copyWith( + hasMore: hasMore, + items: [ + ...state.value!.items, + ...items, + ], + offset: nextOffset, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final res = await fetch( + arg, + state.offset, + state.limit, + ); + + hasMore = res.hasMore; + return state.copyWith( + items: [...state.items, ...res.items], + offset: res.nextOffset, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} diff --git a/lib/provider/spotify/utils/state.dart b/lib/provider/spotify/utils/state.dart new file mode 100644 index 00000000..4b79ac7d --- /dev/null +++ b/lib/provider/spotify/utils/state.dart @@ -0,0 +1,56 @@ +part of '../spotify.dart'; + +abstract class BasePaginatedState { + final List items; + final Cursor offset; + final int limit; + final bool hasMore; + + BasePaginatedState({ + required this.items, + required this.offset, + required this.limit, + required this.hasMore, + }); + + BasePaginatedState copyWith({ + List? items, + Cursor? offset, + int? limit, + bool? hasMore, + }); +} + +abstract class PaginatedState extends BasePaginatedState { + PaginatedState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + PaginatedState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }); +} + +abstract class CursorPaginatedState extends BasePaginatedState { + CursorPaginatedState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + CursorPaginatedState copyWith({ + List? items, + String? offset, + int? limit, + bool? hasMore, + }); +} diff --git a/lib/provider/spotify/views/home.dart b/lib/provider/spotify/views/home.dart new file mode 100644 index 00000000..87c049f9 --- /dev/null +++ b/lib/provider/spotify/views/home.dart @@ -0,0 +1,20 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; + +final homeViewProvider = FutureProvider((ref) async { + final country = ref.watch( + userPreferencesProvider.select((s) => s.market), + ); + final spTCookie = ref.watch( + authenticationProvider.select((s) => s.asData?.value?.getCookie("sp_t")), + ); + + final spotify = ref.watch(customSpotifyEndpointProvider); + + return spotify.getHomeFeed( + country: country, + spTCookie: spTCookie, + ); +}); diff --git a/lib/provider/spotify/views/home_section.dart b/lib/provider/spotify/views/home_section.dart new file mode 100644 index 00000000..13f547e1 --- /dev/null +++ b/lib/provider/spotify/views/home_section.dart @@ -0,0 +1,24 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/models/spotify/home_feed.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; + +final homeSectionViewProvider = + FutureProvider.family( + (ref, sectionUri) async { + final country = ref.watch( + userPreferencesProvider.select((s) => s.market), + ); + final spTCookie = ref.watch( + authenticationProvider.select((s) => s.asData?.value?.getCookie("sp_t")), + ); + + final spotify = ref.watch(customSpotifyEndpointProvider); + + return spotify.getHomeFeedSection( + sectionUri, + country: country, + spTCookie: spTCookie, + ); +}); diff --git a/lib/provider/spotify/views/view.dart b/lib/provider/spotify/views/view.dart new file mode 100644 index 00000000..ff565feb --- /dev/null +++ b/lib/provider/spotify/views/view.dart @@ -0,0 +1,19 @@ +part of '../spotify.dart'; + +final viewProvider = FutureProvider.family, String>( + (ref, viewName) async { + final customSpotify = ref.watch(customSpotifyEndpointProvider); + final market = ref.watch( + userPreferencesProvider.select((s) => s.market), + ); + final locale = ref.watch( + userPreferencesProvider.select((s) => s.locale), + ); + + return customSpotify.getView( + viewName, + market: market, + locale: Intl.canonicalizedLocale(locale.toString()), + ); + }, +); diff --git a/lib/provider/spotify_provider.dart b/lib/provider/spotify_provider.dart deleted file mode 100644 index 2675a9f7..00000000 --- a/lib/provider/spotify_provider.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/env.dart'; - -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/utils/primitive_utils.dart'; - -final spotifyProvider = Provider((ref) { - final authState = ref.watch(AuthenticationNotifier.provider); - final anonCred = PrimitiveUtils.getRandomElement(Env.spotifySecrets); - - if (authState == null) { - return SpotifyApi( - SpotifyApiCredentials( - anonCred["clientId"], - anonCred["clientSecret"], - ), - ); - } - - return SpotifyApi.withAccessToken(authState.accessToken); -}); diff --git a/lib/provider/tray_manager/tray_manager.dart b/lib/provider/tray_manager/tray_manager.dart new file mode 100644 index 00000000..9cc4becc --- /dev/null +++ b/lib/provider/tray_manager/tray_manager.dart @@ -0,0 +1,79 @@ +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/spotube-logo.ico' + : kIsFlatpak + ? 'com.github.KRTirtho.Spotube' + : 'assets/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 new file mode 100644 index 00000000..42a3f948 --- /dev/null +++ b/lib/provider/tray_manager/tray_menu.dart @@ -0,0 +1,108 @@ +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 index 875f36cc..30ee8b3f 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -1,173 +1,259 @@ -import 'dart:async'; +import 'package:drift/drift.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:path_provider/path_provider.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:spotify/spotify.dart'; -import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; -import 'package:spotube/provider/palette_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/models/database/database.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/services/sourced_track/enums.dart'; - -import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:path/path.dart' as path; +import 'package:window_manager/window_manager.dart'; +import 'package:open_file/open_file.dart'; -class UserPreferencesNotifier extends PersistedStateNotifier { - final Ref ref; +typedef UserPreferences = PreferencesTableData; - UserPreferencesNotifier(this.ref) - : super(UserPreferences.withDefaults(), "preferences"); +class UserPreferencesNotifier extends Notifier { + @override + build() { + final db = ref.watch(databaseProvider); - void reset() { - state = UserPreferences.withDefaults(); - } + (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()), + ), + ); + } - void setStreamMusicCodec(SourceCodecs codec) { - state = state.copyWith(streamMusicCodec: codec); - } + state = await (db.select(db.preferencesTable) + ..where((tbl) => tbl.id.equals(0))) + .getSingle(); - void setDownloadMusicCodec(SourceCodecs codec) { - state = state.copyWith(downloadMusicCodec: codec); - } + final subscription = (db.select(db.preferencesTable) + ..where((tbl) => tbl.id.equals(0))) + .watchSingle() + .listen((event) async { + try { + state = event; - void setThemeMode(ThemeMode mode) { - state = state.copyWith(themeMode: mode); - } + if (kIsDesktop) { + await windowManager.setTitleBarStyle( + state.systemTitleBar + ? TitleBarStyle.normal + : TitleBarStyle.hidden, + ); + } - void setRecommendationMarket(Market country) { - state = state.copyWith(recommendationMarket: country); - } + await audioPlayer.setAudioNormalization(state.normalizeAudio); + } catch (e, stack) { + AppLogger.reportError(e, stack); + } + }); - void setAccentColorScheme(SpotubeColor color) { - state = state.copyWith(accentColorScheme: color); - } + ref.onDispose(() { + subscription.cancel(); + }); + }); - void setAlbumColorSync(bool sync) { - state = state.copyWith(albumColorSync: sync); - - if (!sync) { - ref.read(paletteProvider.notifier).state = null; - } else { - ref.read(ProxyPlaylistNotifier.notifier).updatePalette(); - } - } - - void setCheckUpdate(bool check) { - state = state.copyWith(checkUpdate: check); - } - - void setAudioQuality(SourceQualities quality) { - state = state.copyWith(audioQuality: quality); - } - - void setDownloadLocation(String downloadDir) { - if (downloadDir.isEmpty) return; - state = state.copyWith(downloadLocation: downloadDir); - } - - void setLayoutMode(LayoutMode mode) { - state = state.copyWith(layoutMode: mode); - } - - void setCloseBehavior(CloseBehavior behavior) { - state = state.copyWith(closeBehavior: behavior); - } - - void setShowSystemTrayIcon(bool show) { - state = state.copyWith(showSystemTrayIcon: show); - } - - void setLocale(Locale locale) { - state = state.copyWith(locale: locale); - } - - void setPipedInstance(String instance) { - state = state.copyWith(pipedInstance: instance); - } - - void setSearchMode(SearchMode mode) { - state = state.copyWith(searchMode: mode); - } - - void setSkipNonMusic(bool skip) { - state = state.copyWith(skipNonMusic: skip); - } - - void setAudioSource(AudioSource type) { - state = state.copyWith(audioSource: type); - } - - void setSystemTitleBar(bool isSystemTitleBar) { - state = state.copyWith(systemTitleBar: isSystemTitleBar); - if (DesktopTools.platform.isDesktop) { - DesktopTools.window.setTitleBarStyle( - isSystemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, - ); - } - } - - void setDiscordPresence(bool discordPresence) { - state = state.copyWith(discordPresence: discordPresence); - } - - void setAmoledDarkTheme(bool isAmoled) { - state = state.copyWith(amoledDarkTheme: isAmoled); - } - - void setNormalizeAudio(bool normalize) { - state = state.copyWith(normalizeAudio: normalize); - audioPlayer.setAudioNormalization(normalize); - } - - void setEndlessPlayback(bool endless) { - state = state.copyWith(endlessPlayback: endless); + return PreferencesTable.defaults(); } Future _getDefaultDownloadDirectory() async { if (kIsAndroid) return "/storage/emulated/0/Download/Spotube"; if (kIsMacOS) { - return path.join((await getLibraryDirectory()).path, "Caches"); + return join((await paths.getLibraryDirectory()).path, "Caches"); } - return getDownloadsDirectory().then((dir) { - return path.join(dir!.path, "Spotube"); + return paths.getDownloadsDirectory().then((dir) { + return join(dir!.path, "Spotube"); }); } - @override - FutureOr onInit() async { - if (state.downloadLocation.isEmpty) { - state = state.copyWith( - downloadLocation: await _getDefaultDownloadDirectory(), - ); - } + Future setData(PreferencesTableCompanion data) async { + final db = ref.read(databaseProvider); - if (DesktopTools.platform.isDesktop) { - await DesktopTools.window.setTitleBarStyle( - state.systemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, - ); - } + final query = db.update(db.preferencesTable)..where((t) => t.id.equals(0)); - await audioPlayer.setAudioNormalization(state.normalizeAudio); + await query.write(data); } - @override - FutureOr fromJson(Map json) { - return UserPreferences.fromJson(json); + Future reset() async { + final db = ref.read(databaseProvider); + + final query = db.update(db.preferencesTable); + + await query.replace(PreferencesTableCompanion.insert(id: const Value(0))); } - @override - Map toJson() { - return state.toJson(); + 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 setStreamMusicCodec(SourceCodecs codec) { + setData(PreferencesTableCompanion(streamMusicCodec: Value(codec))); + } + + void setDownloadMusicCodec(SourceCodecs codec) { + setData(PreferencesTableCompanion(downloadMusicCodec: Value(codec))); + } + + 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 setAudioQuality(SourceQualities quality) { + setData(PreferencesTableCompanion(audioQuality: Value(quality))); + } + + 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 setPipedInstance(String instance) { + setData(PreferencesTableCompanion(pipedInstance: Value(instance))); + } + + void setInvidiousInstance(String instance) { + setData(PreferencesTableCompanion(invidiousInstance: Value(instance))); + } + + void setSearchMode(SearchMode mode) { + setData(PreferencesTableCompanion(searchMode: Value(mode))); + } + + void setSkipNonMusic(bool skip) { + setData(PreferencesTableCompanion(skipNonMusic: Value(skip))); + } + + void setAudioSource(AudioSource type) { + setData(PreferencesTableCompanion(audioSource: Value(type))); + } + + 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 = - StateNotifierProvider( - (ref) => UserPreferencesNotifier(ref), + NotifierProvider( + () => UserPreferencesNotifier(), ); diff --git a/lib/provider/user_preferences/user_preferences_state.dart b/lib/provider/user_preferences/user_preferences_state.dart deleted file mode 100644 index cf6c0597..00000000 --- a/lib/provider/user_preferences/user_preferences_state.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; -import 'package:spotube/services/sourced_track/enums.dart'; - -part 'user_preferences_state.g.dart'; -part 'user_preferences_state.freezed.dart'; - -@JsonEnum() -enum LayoutMode { - compact, - extended, - adaptive, -} - -@JsonEnum() -enum CloseBehavior { - minimizeToTray, - close, -} - -@JsonEnum() -enum AudioSource { - youtube, - piped, - jiosaavn; - - String get label => name[0].toUpperCase() + name.substring(1); -} - -@JsonEnum() -enum MusicCodec { - m4a._("M4a (Best for downloaded music)"), - weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"); - - final String label; - const MusicCodec._(this.label); -} - -@JsonEnum() -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); - } -} - -@freezed -class UserPreferences with _$UserPreferences { - const factory UserPreferences({ - @Default(SourceQualities.high) SourceQualities audioQuality, - @Default(true) bool albumColorSync, - @Default(false) bool amoledDarkTheme, - @Default(true) bool checkUpdate, - @Default(false) bool normalizeAudio, - @Default(true) bool showSystemTrayIcon, - @Default(false) bool skipNonMusic, - @Default(false) bool systemTitleBar, - @Default(CloseBehavior.minimizeToTray) CloseBehavior closeBehavior, - @Default(SpotubeColor(0xFF2196F3, name: "Blue")) - @JsonKey( - fromJson: UserPreferences._accentColorSchemeFromJson, - toJson: UserPreferences._accentColorSchemeToJson, - readValue: UserPreferences._accentColorSchemeReadValue, - ) - SpotubeColor accentColorScheme, - @Default(LayoutMode.adaptive) LayoutMode layoutMode, - @Default(Locale("system", "system")) - @JsonKey( - fromJson: UserPreferences._localeFromJson, - toJson: UserPreferences._localeToJson, - readValue: UserPreferences._localeReadValue, - ) - Locale locale, - @Default(Market.US) Market recommendationMarket, - @Default(SearchMode.youtube) SearchMode searchMode, - @Default("") String downloadLocation, - @Default("https://pipedapi.kavin.rocks") String pipedInstance, - @Default(ThemeMode.system) ThemeMode themeMode, - @Default(AudioSource.youtube) AudioSource audioSource, - @Default(SourceCodecs.weba) SourceCodecs streamMusicCodec, - @Default(SourceCodecs.m4a) SourceCodecs downloadMusicCodec, - @Default(true) bool discordPresence, - @Default(true) bool endlessPlayback, - }) = _UserPreferences; - factory UserPreferences.fromJson(Map json) => - _$UserPreferencesFromJson(json); - - factory UserPreferences.withDefaults() => UserPreferences.fromJson({}); - - static SpotubeColor _accentColorSchemeFromJson(Map json) { - return SpotubeColor.fromString(json["color"]); - } - - static Map? _accentColorSchemeReadValue( - Map json, String key) { - if (json[key] is String) { - return {"color": json[key]}; - } - - return json[key] as Map?; - } - - static Map _accentColorSchemeToJson(SpotubeColor color) { - return {"color": color.toString()}; - } - - static Locale _localeFromJson(Map json) { - return Locale(json["languageCode"], json["countryCode"]); - } - - static Map _localeToJson(Locale locale) { - return { - "languageCode": locale.languageCode, - "countryCode": locale.countryCode, - }; - } - - static Map? _localeReadValue( - Map json, String key) { - if (json[key] is String) { - final map = jsonDecode(json[key]); - return { - "languageCode": map["lc"], - "countryCode": map["cc"], - }; - } - - return json[key] as Map?; - } -} diff --git a/lib/provider/user_preferences/user_preferences_state.freezed.dart b/lib/provider/user_preferences/user_preferences_state.freezed.dart deleted file mode 100644 index 4d08d1a9..00000000 --- a/lib/provider/user_preferences/user_preferences_state.freezed.dart +++ /dev/null @@ -1,697 +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 'user_preferences_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#custom-getters-and-methods'); - -UserPreferences _$UserPreferencesFromJson(Map json) { - return _UserPreferences.fromJson(json); -} - -/// @nodoc -mixin _$UserPreferences { - SourceQualities get audioQuality => throw _privateConstructorUsedError; - bool get albumColorSync => throw _privateConstructorUsedError; - bool get amoledDarkTheme => throw _privateConstructorUsedError; - bool get checkUpdate => throw _privateConstructorUsedError; - bool get normalizeAudio => throw _privateConstructorUsedError; - bool get showSystemTrayIcon => throw _privateConstructorUsedError; - bool get skipNonMusic => throw _privateConstructorUsedError; - bool get systemTitleBar => throw _privateConstructorUsedError; - CloseBehavior get closeBehavior => throw _privateConstructorUsedError; - @JsonKey( - fromJson: UserPreferences._accentColorSchemeFromJson, - toJson: UserPreferences._accentColorSchemeToJson, - readValue: UserPreferences._accentColorSchemeReadValue) - SpotubeColor get accentColorScheme => throw _privateConstructorUsedError; - LayoutMode get layoutMode => throw _privateConstructorUsedError; - @JsonKey( - fromJson: UserPreferences._localeFromJson, - toJson: UserPreferences._localeToJson, - readValue: UserPreferences._localeReadValue) - Locale get locale => throw _privateConstructorUsedError; - Market get recommendationMarket => throw _privateConstructorUsedError; - SearchMode get searchMode => throw _privateConstructorUsedError; - String get downloadLocation => throw _privateConstructorUsedError; - String get pipedInstance => throw _privateConstructorUsedError; - ThemeMode get themeMode => throw _privateConstructorUsedError; - AudioSource get audioSource => throw _privateConstructorUsedError; - SourceCodecs get streamMusicCodec => throw _privateConstructorUsedError; - SourceCodecs get downloadMusicCodec => throw _privateConstructorUsedError; - bool get discordPresence => throw _privateConstructorUsedError; - bool get endlessPlayback => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $UserPreferencesCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $UserPreferencesCopyWith<$Res> { - factory $UserPreferencesCopyWith( - UserPreferences value, $Res Function(UserPreferences) then) = - _$UserPreferencesCopyWithImpl<$Res, UserPreferences>; - @useResult - $Res call( - {SourceQualities audioQuality, - bool albumColorSync, - bool amoledDarkTheme, - bool checkUpdate, - bool normalizeAudio, - bool showSystemTrayIcon, - bool skipNonMusic, - bool systemTitleBar, - CloseBehavior closeBehavior, - @JsonKey( - fromJson: UserPreferences._accentColorSchemeFromJson, - toJson: UserPreferences._accentColorSchemeToJson, - readValue: UserPreferences._accentColorSchemeReadValue) - SpotubeColor accentColorScheme, - LayoutMode layoutMode, - @JsonKey( - fromJson: UserPreferences._localeFromJson, - toJson: UserPreferences._localeToJson, - readValue: UserPreferences._localeReadValue) - Locale locale, - Market recommendationMarket, - SearchMode searchMode, - String downloadLocation, - String pipedInstance, - ThemeMode themeMode, - AudioSource audioSource, - SourceCodecs streamMusicCodec, - SourceCodecs downloadMusicCodec, - bool discordPresence, - bool endlessPlayback}); -} - -/// @nodoc -class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> - implements $UserPreferencesCopyWith<$Res> { - _$UserPreferencesCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? audioQuality = null, - Object? albumColorSync = null, - Object? amoledDarkTheme = null, - Object? checkUpdate = null, - Object? normalizeAudio = null, - Object? showSystemTrayIcon = null, - Object? skipNonMusic = null, - Object? systemTitleBar = null, - Object? closeBehavior = null, - Object? accentColorScheme = null, - Object? layoutMode = null, - Object? locale = null, - Object? recommendationMarket = null, - Object? searchMode = null, - Object? downloadLocation = null, - Object? pipedInstance = null, - Object? themeMode = null, - Object? audioSource = null, - Object? streamMusicCodec = null, - Object? downloadMusicCodec = null, - Object? discordPresence = null, - Object? endlessPlayback = null, - }) { - return _then(_value.copyWith( - audioQuality: null == audioQuality - ? _value.audioQuality - : audioQuality // ignore: cast_nullable_to_non_nullable - as SourceQualities, - albumColorSync: null == albumColorSync - ? _value.albumColorSync - : albumColorSync // ignore: cast_nullable_to_non_nullable - as bool, - amoledDarkTheme: null == amoledDarkTheme - ? _value.amoledDarkTheme - : amoledDarkTheme // ignore: cast_nullable_to_non_nullable - as bool, - checkUpdate: null == checkUpdate - ? _value.checkUpdate - : checkUpdate // ignore: cast_nullable_to_non_nullable - as bool, - normalizeAudio: null == normalizeAudio - ? _value.normalizeAudio - : normalizeAudio // ignore: cast_nullable_to_non_nullable - as bool, - showSystemTrayIcon: null == showSystemTrayIcon - ? _value.showSystemTrayIcon - : showSystemTrayIcon // ignore: cast_nullable_to_non_nullable - as bool, - skipNonMusic: null == skipNonMusic - ? _value.skipNonMusic - : skipNonMusic // ignore: cast_nullable_to_non_nullable - as bool, - systemTitleBar: null == systemTitleBar - ? _value.systemTitleBar - : systemTitleBar // ignore: cast_nullable_to_non_nullable - as bool, - closeBehavior: null == closeBehavior - ? _value.closeBehavior - : closeBehavior // ignore: cast_nullable_to_non_nullable - as CloseBehavior, - accentColorScheme: null == accentColorScheme - ? _value.accentColorScheme - : accentColorScheme // ignore: cast_nullable_to_non_nullable - as SpotubeColor, - layoutMode: null == layoutMode - ? _value.layoutMode - : layoutMode // ignore: cast_nullable_to_non_nullable - as LayoutMode, - locale: null == locale - ? _value.locale - : locale // ignore: cast_nullable_to_non_nullable - as Locale, - recommendationMarket: null == recommendationMarket - ? _value.recommendationMarket - : recommendationMarket // ignore: cast_nullable_to_non_nullable - as Market, - searchMode: null == searchMode - ? _value.searchMode - : searchMode // ignore: cast_nullable_to_non_nullable - as SearchMode, - downloadLocation: null == downloadLocation - ? _value.downloadLocation - : downloadLocation // ignore: cast_nullable_to_non_nullable - as String, - pipedInstance: null == pipedInstance - ? _value.pipedInstance - : pipedInstance // ignore: cast_nullable_to_non_nullable - as String, - themeMode: null == themeMode - ? _value.themeMode - : themeMode // ignore: cast_nullable_to_non_nullable - as ThemeMode, - audioSource: null == audioSource - ? _value.audioSource - : audioSource // ignore: cast_nullable_to_non_nullable - as AudioSource, - streamMusicCodec: null == streamMusicCodec - ? _value.streamMusicCodec - : streamMusicCodec // ignore: cast_nullable_to_non_nullable - as SourceCodecs, - downloadMusicCodec: null == downloadMusicCodec - ? _value.downloadMusicCodec - : downloadMusicCodec // ignore: cast_nullable_to_non_nullable - as SourceCodecs, - discordPresence: null == discordPresence - ? _value.discordPresence - : discordPresence // ignore: cast_nullable_to_non_nullable - as bool, - endlessPlayback: null == endlessPlayback - ? _value.endlessPlayback - : endlessPlayback // ignore: cast_nullable_to_non_nullable - as bool, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$UserPreferencesImplCopyWith<$Res> - implements $UserPreferencesCopyWith<$Res> { - factory _$$UserPreferencesImplCopyWith(_$UserPreferencesImpl value, - $Res Function(_$UserPreferencesImpl) then) = - __$$UserPreferencesImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {SourceQualities audioQuality, - bool albumColorSync, - bool amoledDarkTheme, - bool checkUpdate, - bool normalizeAudio, - bool showSystemTrayIcon, - bool skipNonMusic, - bool systemTitleBar, - CloseBehavior closeBehavior, - @JsonKey( - fromJson: UserPreferences._accentColorSchemeFromJson, - toJson: UserPreferences._accentColorSchemeToJson, - readValue: UserPreferences._accentColorSchemeReadValue) - SpotubeColor accentColorScheme, - LayoutMode layoutMode, - @JsonKey( - fromJson: UserPreferences._localeFromJson, - toJson: UserPreferences._localeToJson, - readValue: UserPreferences._localeReadValue) - Locale locale, - Market recommendationMarket, - SearchMode searchMode, - String downloadLocation, - String pipedInstance, - ThemeMode themeMode, - AudioSource audioSource, - SourceCodecs streamMusicCodec, - SourceCodecs downloadMusicCodec, - bool discordPresence, - bool endlessPlayback}); -} - -/// @nodoc -class __$$UserPreferencesImplCopyWithImpl<$Res> - extends _$UserPreferencesCopyWithImpl<$Res, _$UserPreferencesImpl> - implements _$$UserPreferencesImplCopyWith<$Res> { - __$$UserPreferencesImplCopyWithImpl( - _$UserPreferencesImpl _value, $Res Function(_$UserPreferencesImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? audioQuality = null, - Object? albumColorSync = null, - Object? amoledDarkTheme = null, - Object? checkUpdate = null, - Object? normalizeAudio = null, - Object? showSystemTrayIcon = null, - Object? skipNonMusic = null, - Object? systemTitleBar = null, - Object? closeBehavior = null, - Object? accentColorScheme = null, - Object? layoutMode = null, - Object? locale = null, - Object? recommendationMarket = null, - Object? searchMode = null, - Object? downloadLocation = null, - Object? pipedInstance = null, - Object? themeMode = null, - Object? audioSource = null, - Object? streamMusicCodec = null, - Object? downloadMusicCodec = null, - Object? discordPresence = null, - Object? endlessPlayback = null, - }) { - return _then(_$UserPreferencesImpl( - audioQuality: null == audioQuality - ? _value.audioQuality - : audioQuality // ignore: cast_nullable_to_non_nullable - as SourceQualities, - albumColorSync: null == albumColorSync - ? _value.albumColorSync - : albumColorSync // ignore: cast_nullable_to_non_nullable - as bool, - amoledDarkTheme: null == amoledDarkTheme - ? _value.amoledDarkTheme - : amoledDarkTheme // ignore: cast_nullable_to_non_nullable - as bool, - checkUpdate: null == checkUpdate - ? _value.checkUpdate - : checkUpdate // ignore: cast_nullable_to_non_nullable - as bool, - normalizeAudio: null == normalizeAudio - ? _value.normalizeAudio - : normalizeAudio // ignore: cast_nullable_to_non_nullable - as bool, - showSystemTrayIcon: null == showSystemTrayIcon - ? _value.showSystemTrayIcon - : showSystemTrayIcon // ignore: cast_nullable_to_non_nullable - as bool, - skipNonMusic: null == skipNonMusic - ? _value.skipNonMusic - : skipNonMusic // ignore: cast_nullable_to_non_nullable - as bool, - systemTitleBar: null == systemTitleBar - ? _value.systemTitleBar - : systemTitleBar // ignore: cast_nullable_to_non_nullable - as bool, - closeBehavior: null == closeBehavior - ? _value.closeBehavior - : closeBehavior // ignore: cast_nullable_to_non_nullable - as CloseBehavior, - accentColorScheme: null == accentColorScheme - ? _value.accentColorScheme - : accentColorScheme // ignore: cast_nullable_to_non_nullable - as SpotubeColor, - layoutMode: null == layoutMode - ? _value.layoutMode - : layoutMode // ignore: cast_nullable_to_non_nullable - as LayoutMode, - locale: null == locale - ? _value.locale - : locale // ignore: cast_nullable_to_non_nullable - as Locale, - recommendationMarket: null == recommendationMarket - ? _value.recommendationMarket - : recommendationMarket // ignore: cast_nullable_to_non_nullable - as Market, - searchMode: null == searchMode - ? _value.searchMode - : searchMode // ignore: cast_nullable_to_non_nullable - as SearchMode, - downloadLocation: null == downloadLocation - ? _value.downloadLocation - : downloadLocation // ignore: cast_nullable_to_non_nullable - as String, - pipedInstance: null == pipedInstance - ? _value.pipedInstance - : pipedInstance // ignore: cast_nullable_to_non_nullable - as String, - themeMode: null == themeMode - ? _value.themeMode - : themeMode // ignore: cast_nullable_to_non_nullable - as ThemeMode, - audioSource: null == audioSource - ? _value.audioSource - : audioSource // ignore: cast_nullable_to_non_nullable - as AudioSource, - streamMusicCodec: null == streamMusicCodec - ? _value.streamMusicCodec - : streamMusicCodec // ignore: cast_nullable_to_non_nullable - as SourceCodecs, - downloadMusicCodec: null == downloadMusicCodec - ? _value.downloadMusicCodec - : downloadMusicCodec // ignore: cast_nullable_to_non_nullable - as SourceCodecs, - discordPresence: null == discordPresence - ? _value.discordPresence - : discordPresence // ignore: cast_nullable_to_non_nullable - as bool, - endlessPlayback: null == endlessPlayback - ? _value.endlessPlayback - : endlessPlayback // ignore: cast_nullable_to_non_nullable - as bool, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$UserPreferencesImpl implements _UserPreferences { - const _$UserPreferencesImpl( - {this.audioQuality = SourceQualities.high, - this.albumColorSync = true, - this.amoledDarkTheme = false, - this.checkUpdate = true, - this.normalizeAudio = false, - this.showSystemTrayIcon = true, - this.skipNonMusic = false, - this.systemTitleBar = false, - this.closeBehavior = CloseBehavior.minimizeToTray, - @JsonKey( - fromJson: UserPreferences._accentColorSchemeFromJson, - toJson: UserPreferences._accentColorSchemeToJson, - readValue: UserPreferences._accentColorSchemeReadValue) - this.accentColorScheme = const SpotubeColor(0xFF2196F3, name: "Blue"), - this.layoutMode = LayoutMode.adaptive, - @JsonKey( - fromJson: UserPreferences._localeFromJson, - toJson: UserPreferences._localeToJson, - readValue: UserPreferences._localeReadValue) - this.locale = const Locale("system", "system"), - this.recommendationMarket = Market.US, - this.searchMode = SearchMode.youtube, - this.downloadLocation = "", - this.pipedInstance = "https://pipedapi.kavin.rocks", - this.themeMode = ThemeMode.system, - this.audioSource = AudioSource.youtube, - this.streamMusicCodec = SourceCodecs.weba, - this.downloadMusicCodec = SourceCodecs.m4a, - this.discordPresence = true, - this.endlessPlayback = true}); - - factory _$UserPreferencesImpl.fromJson(Map json) => - _$$UserPreferencesImplFromJson(json); - - @override - @JsonKey() - final SourceQualities audioQuality; - @override - @JsonKey() - final bool albumColorSync; - @override - @JsonKey() - final bool amoledDarkTheme; - @override - @JsonKey() - final bool checkUpdate; - @override - @JsonKey() - final bool normalizeAudio; - @override - @JsonKey() - final bool showSystemTrayIcon; - @override - @JsonKey() - final bool skipNonMusic; - @override - @JsonKey() - final bool systemTitleBar; - @override - @JsonKey() - final CloseBehavior closeBehavior; - @override - @JsonKey( - fromJson: UserPreferences._accentColorSchemeFromJson, - toJson: UserPreferences._accentColorSchemeToJson, - readValue: UserPreferences._accentColorSchemeReadValue) - final SpotubeColor accentColorScheme; - @override - @JsonKey() - final LayoutMode layoutMode; - @override - @JsonKey( - fromJson: UserPreferences._localeFromJson, - toJson: UserPreferences._localeToJson, - readValue: UserPreferences._localeReadValue) - final Locale locale; - @override - @JsonKey() - final Market recommendationMarket; - @override - @JsonKey() - final SearchMode searchMode; - @override - @JsonKey() - final String downloadLocation; - @override - @JsonKey() - final String pipedInstance; - @override - @JsonKey() - final ThemeMode themeMode; - @override - @JsonKey() - final AudioSource audioSource; - @override - @JsonKey() - final SourceCodecs streamMusicCodec; - @override - @JsonKey() - final SourceCodecs downloadMusicCodec; - @override - @JsonKey() - final bool discordPresence; - @override - @JsonKey() - final bool endlessPlayback; - - @override - String toString() { - return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$UserPreferencesImpl && - (identical(other.audioQuality, audioQuality) || - other.audioQuality == audioQuality) && - (identical(other.albumColorSync, albumColorSync) || - other.albumColorSync == albumColorSync) && - (identical(other.amoledDarkTheme, amoledDarkTheme) || - other.amoledDarkTheme == amoledDarkTheme) && - (identical(other.checkUpdate, checkUpdate) || - other.checkUpdate == checkUpdate) && - (identical(other.normalizeAudio, normalizeAudio) || - other.normalizeAudio == normalizeAudio) && - (identical(other.showSystemTrayIcon, showSystemTrayIcon) || - other.showSystemTrayIcon == showSystemTrayIcon) && - (identical(other.skipNonMusic, skipNonMusic) || - other.skipNonMusic == skipNonMusic) && - (identical(other.systemTitleBar, systemTitleBar) || - other.systemTitleBar == systemTitleBar) && - (identical(other.closeBehavior, closeBehavior) || - other.closeBehavior == closeBehavior) && - (identical(other.accentColorScheme, accentColorScheme) || - other.accentColorScheme == accentColorScheme) && - (identical(other.layoutMode, layoutMode) || - other.layoutMode == layoutMode) && - (identical(other.locale, locale) || other.locale == locale) && - (identical(other.recommendationMarket, recommendationMarket) || - other.recommendationMarket == recommendationMarket) && - (identical(other.searchMode, searchMode) || - other.searchMode == searchMode) && - (identical(other.downloadLocation, downloadLocation) || - other.downloadLocation == downloadLocation) && - (identical(other.pipedInstance, pipedInstance) || - other.pipedInstance == pipedInstance) && - (identical(other.themeMode, themeMode) || - other.themeMode == themeMode) && - (identical(other.audioSource, audioSource) || - other.audioSource == audioSource) && - (identical(other.streamMusicCodec, streamMusicCodec) || - other.streamMusicCodec == streamMusicCodec) && - (identical(other.downloadMusicCodec, downloadMusicCodec) || - other.downloadMusicCodec == downloadMusicCodec) && - (identical(other.discordPresence, discordPresence) || - other.discordPresence == discordPresence) && - (identical(other.endlessPlayback, endlessPlayback) || - other.endlessPlayback == endlessPlayback)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hashAll([ - runtimeType, - audioQuality, - albumColorSync, - amoledDarkTheme, - checkUpdate, - normalizeAudio, - showSystemTrayIcon, - skipNonMusic, - systemTitleBar, - closeBehavior, - accentColorScheme, - layoutMode, - locale, - recommendationMarket, - searchMode, - downloadLocation, - pipedInstance, - themeMode, - audioSource, - streamMusicCodec, - downloadMusicCodec, - discordPresence, - endlessPlayback - ]); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$UserPreferencesImplCopyWith<_$UserPreferencesImpl> get copyWith => - __$$UserPreferencesImplCopyWithImpl<_$UserPreferencesImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$UserPreferencesImplToJson( - this, - ); - } -} - -abstract class _UserPreferences implements UserPreferences { - const factory _UserPreferences( - {final SourceQualities audioQuality, - final bool albumColorSync, - final bool amoledDarkTheme, - final bool checkUpdate, - final bool normalizeAudio, - final bool showSystemTrayIcon, - final bool skipNonMusic, - final bool systemTitleBar, - final CloseBehavior closeBehavior, - @JsonKey( - fromJson: UserPreferences._accentColorSchemeFromJson, - toJson: UserPreferences._accentColorSchemeToJson, - readValue: UserPreferences._accentColorSchemeReadValue) - final SpotubeColor accentColorScheme, - final LayoutMode layoutMode, - @JsonKey( - fromJson: UserPreferences._localeFromJson, - toJson: UserPreferences._localeToJson, - readValue: UserPreferences._localeReadValue) - final Locale locale, - final Market recommendationMarket, - final SearchMode searchMode, - final String downloadLocation, - final String pipedInstance, - final ThemeMode themeMode, - final AudioSource audioSource, - final SourceCodecs streamMusicCodec, - final SourceCodecs downloadMusicCodec, - final bool discordPresence, - final bool endlessPlayback}) = _$UserPreferencesImpl; - - factory _UserPreferences.fromJson(Map json) = - _$UserPreferencesImpl.fromJson; - - @override - SourceQualities get audioQuality; - @override - bool get albumColorSync; - @override - bool get amoledDarkTheme; - @override - bool get checkUpdate; - @override - bool get normalizeAudio; - @override - bool get showSystemTrayIcon; - @override - bool get skipNonMusic; - @override - bool get systemTitleBar; - @override - CloseBehavior get closeBehavior; - @override - @JsonKey( - fromJson: UserPreferences._accentColorSchemeFromJson, - toJson: UserPreferences._accentColorSchemeToJson, - readValue: UserPreferences._accentColorSchemeReadValue) - SpotubeColor get accentColorScheme; - @override - LayoutMode get layoutMode; - @override - @JsonKey( - fromJson: UserPreferences._localeFromJson, - toJson: UserPreferences._localeToJson, - readValue: UserPreferences._localeReadValue) - Locale get locale; - @override - Market get recommendationMarket; - @override - SearchMode get searchMode; - @override - String get downloadLocation; - @override - String get pipedInstance; - @override - ThemeMode get themeMode; - @override - AudioSource get audioSource; - @override - SourceCodecs get streamMusicCodec; - @override - SourceCodecs get downloadMusicCodec; - @override - bool get discordPresence; - @override - bool get endlessPlayback; - @override - @JsonKey(ignore: true) - _$$UserPreferencesImplCopyWith<_$UserPreferencesImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/lib/provider/user_preferences/user_preferences_state.g.dart b/lib/provider/user_preferences/user_preferences_state.g.dart deleted file mode 100644 index ce488247..00000000 --- a/lib/provider/user_preferences/user_preferences_state.g.dart +++ /dev/null @@ -1,382 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'user_preferences_state.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -_$UserPreferencesImpl _$$UserPreferencesImplFromJson( - Map json) => - _$UserPreferencesImpl( - audioQuality: - $enumDecodeNullable(_$SourceQualitiesEnumMap, json['audioQuality']) ?? - SourceQualities.high, - albumColorSync: json['albumColorSync'] as bool? ?? true, - amoledDarkTheme: json['amoledDarkTheme'] as bool? ?? false, - checkUpdate: json['checkUpdate'] as bool? ?? true, - normalizeAudio: json['normalizeAudio'] as bool? ?? false, - showSystemTrayIcon: json['showSystemTrayIcon'] as bool? ?? true, - skipNonMusic: json['skipNonMusic'] as bool? ?? false, - systemTitleBar: json['systemTitleBar'] as bool? ?? false, - closeBehavior: - $enumDecodeNullable(_$CloseBehaviorEnumMap, json['closeBehavior']) ?? - CloseBehavior.minimizeToTray, - accentColorScheme: UserPreferences._accentColorSchemeReadValue( - json, 'accentColorScheme') == - null - ? const SpotubeColor(0xFF2196F3, name: "Blue") - : UserPreferences._accentColorSchemeFromJson( - UserPreferences._accentColorSchemeReadValue( - json, 'accentColorScheme') as Map), - layoutMode: - $enumDecodeNullable(_$LayoutModeEnumMap, json['layoutMode']) ?? - LayoutMode.adaptive, - locale: UserPreferences._localeReadValue(json, 'locale') == null - ? const Locale("system", "system") - : UserPreferences._localeFromJson( - UserPreferences._localeReadValue(json, 'locale') - as Map), - recommendationMarket: - $enumDecodeNullable(_$MarketEnumMap, json['recommendationMarket']) ?? - Market.US, - searchMode: - $enumDecodeNullable(_$SearchModeEnumMap, json['searchMode']) ?? - SearchMode.youtube, - downloadLocation: json['downloadLocation'] as String? ?? "", - pipedInstance: - json['pipedInstance'] as String? ?? "https://pipedapi.kavin.rocks", - themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']) ?? - ThemeMode.system, - audioSource: - $enumDecodeNullable(_$AudioSourceEnumMap, json['audioSource']) ?? - AudioSource.youtube, - streamMusicCodec: $enumDecodeNullable( - _$SourceCodecsEnumMap, json['streamMusicCodec']) ?? - SourceCodecs.weba, - downloadMusicCodec: $enumDecodeNullable( - _$SourceCodecsEnumMap, json['downloadMusicCodec']) ?? - SourceCodecs.m4a, - discordPresence: json['discordPresence'] as bool? ?? true, - endlessPlayback: json['endlessPlayback'] as bool? ?? true, - ); - -Map _$$UserPreferencesImplToJson( - _$UserPreferencesImpl instance) => - { - 'audioQuality': _$SourceQualitiesEnumMap[instance.audioQuality]!, - 'albumColorSync': instance.albumColorSync, - 'amoledDarkTheme': instance.amoledDarkTheme, - 'checkUpdate': instance.checkUpdate, - 'normalizeAudio': instance.normalizeAudio, - 'showSystemTrayIcon': instance.showSystemTrayIcon, - 'skipNonMusic': instance.skipNonMusic, - 'systemTitleBar': instance.systemTitleBar, - 'closeBehavior': _$CloseBehaviorEnumMap[instance.closeBehavior]!, - 'accentColorScheme': - UserPreferences._accentColorSchemeToJson(instance.accentColorScheme), - 'layoutMode': _$LayoutModeEnumMap[instance.layoutMode]!, - 'locale': UserPreferences._localeToJson(instance.locale), - 'recommendationMarket': _$MarketEnumMap[instance.recommendationMarket]!, - 'searchMode': _$SearchModeEnumMap[instance.searchMode]!, - 'downloadLocation': instance.downloadLocation, - 'pipedInstance': instance.pipedInstance, - 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, - 'audioSource': _$AudioSourceEnumMap[instance.audioSource]!, - 'streamMusicCodec': _$SourceCodecsEnumMap[instance.streamMusicCodec]!, - 'downloadMusicCodec': _$SourceCodecsEnumMap[instance.downloadMusicCodec]!, - 'discordPresence': instance.discordPresence, - 'endlessPlayback': instance.endlessPlayback, - }; - -const _$SourceQualitiesEnumMap = { - SourceQualities.high: 'high', - SourceQualities.medium: 'medium', - SourceQualities.low: 'low', -}; - -const _$CloseBehaviorEnumMap = { - CloseBehavior.minimizeToTray: 'minimizeToTray', - CloseBehavior.close: 'close', -}; - -const _$LayoutModeEnumMap = { - LayoutMode.compact: 'compact', - LayoutMode.extended: 'extended', - LayoutMode.adaptive: 'adaptive', -}; - -const _$MarketEnumMap = { - Market.AD: 'AD', - Market.AE: 'AE', - Market.AF: 'AF', - Market.AG: 'AG', - Market.AI: 'AI', - Market.AL: 'AL', - Market.AM: 'AM', - Market.AO: 'AO', - Market.AQ: 'AQ', - Market.AR: 'AR', - Market.AS: 'AS', - Market.AT: 'AT', - Market.AU: 'AU', - Market.AW: 'AW', - Market.AX: 'AX', - Market.AZ: 'AZ', - Market.BA: 'BA', - Market.BB: 'BB', - Market.BD: 'BD', - Market.BE: 'BE', - Market.BF: 'BF', - Market.BG: 'BG', - Market.BH: 'BH', - Market.BI: 'BI', - Market.BJ: 'BJ', - Market.BL: 'BL', - Market.BM: 'BM', - Market.BN: 'BN', - Market.BO: 'BO', - Market.BQ: 'BQ', - Market.BR: 'BR', - Market.BS: 'BS', - Market.BT: 'BT', - Market.BV: 'BV', - Market.BW: 'BW', - Market.BY: 'BY', - Market.BZ: 'BZ', - Market.CA: 'CA', - Market.CC: 'CC', - Market.CD: 'CD', - Market.CF: 'CF', - Market.CG: 'CG', - Market.CH: 'CH', - Market.CI: 'CI', - Market.CK: 'CK', - Market.CL: 'CL', - Market.CM: 'CM', - Market.CN: 'CN', - Market.CO: 'CO', - Market.CR: 'CR', - Market.CU: 'CU', - Market.CV: 'CV', - Market.CW: 'CW', - Market.CX: 'CX', - Market.CY: 'CY', - Market.CZ: 'CZ', - Market.DE: 'DE', - Market.DJ: 'DJ', - Market.DK: 'DK', - Market.DM: 'DM', - Market.DO: 'DO', - Market.DZ: 'DZ', - Market.EC: 'EC', - Market.EE: 'EE', - Market.EG: 'EG', - Market.EH: 'EH', - Market.ER: 'ER', - Market.ES: 'ES', - Market.ET: 'ET', - Market.FI: 'FI', - Market.FJ: 'FJ', - Market.FK: 'FK', - Market.FM: 'FM', - Market.FO: 'FO', - Market.FR: 'FR', - Market.GA: 'GA', - Market.GB: 'GB', - Market.GD: 'GD', - Market.GE: 'GE', - Market.GF: 'GF', - Market.GG: 'GG', - Market.GH: 'GH', - Market.GI: 'GI', - Market.GL: 'GL', - Market.GM: 'GM', - Market.GN: 'GN', - Market.GP: 'GP', - Market.GQ: 'GQ', - Market.GR: 'GR', - Market.GS: 'GS', - Market.GT: 'GT', - Market.GU: 'GU', - Market.GW: 'GW', - Market.GY: 'GY', - Market.HK: 'HK', - Market.HM: 'HM', - Market.HN: 'HN', - Market.HR: 'HR', - Market.HT: 'HT', - Market.HU: 'HU', - Market.ID: 'ID', - Market.IE: 'IE', - Market.IL: 'IL', - Market.IM: 'IM', - Market.IN: 'IN', - Market.IO: 'IO', - Market.IQ: 'IQ', - Market.IR: 'IR', - Market.IS: 'IS', - Market.IT: 'IT', - Market.JE: 'JE', - Market.JM: 'JM', - Market.JO: 'JO', - Market.JP: 'JP', - Market.KE: 'KE', - Market.KG: 'KG', - Market.KH: 'KH', - Market.KI: 'KI', - Market.KM: 'KM', - Market.KN: 'KN', - Market.KP: 'KP', - Market.KR: 'KR', - Market.KW: 'KW', - Market.KY: 'KY', - Market.KZ: 'KZ', - Market.LA: 'LA', - Market.LB: 'LB', - Market.LC: 'LC', - Market.LI: 'LI', - Market.LK: 'LK', - Market.LR: 'LR', - Market.LS: 'LS', - Market.LT: 'LT', - Market.LU: 'LU', - Market.LV: 'LV', - Market.LY: 'LY', - Market.MA: 'MA', - Market.MC: 'MC', - Market.MD: 'MD', - Market.ME: 'ME', - Market.MF: 'MF', - Market.MG: 'MG', - Market.MH: 'MH', - Market.MK: 'MK', - Market.ML: 'ML', - Market.MM: 'MM', - Market.MN: 'MN', - Market.MO: 'MO', - Market.MP: 'MP', - Market.MQ: 'MQ', - Market.MR: 'MR', - Market.MS: 'MS', - Market.MT: 'MT', - Market.MU: 'MU', - Market.MV: 'MV', - Market.MW: 'MW', - Market.MX: 'MX', - Market.MY: 'MY', - Market.MZ: 'MZ', - Market.NA: 'NA', - Market.NC: 'NC', - Market.NE: 'NE', - Market.NF: 'NF', - Market.NG: 'NG', - Market.NI: 'NI', - Market.NL: 'NL', - Market.NO: 'NO', - Market.NP: 'NP', - Market.NR: 'NR', - Market.NU: 'NU', - Market.NZ: 'NZ', - Market.OM: 'OM', - Market.PA: 'PA', - Market.PE: 'PE', - Market.PF: 'PF', - Market.PG: 'PG', - Market.PH: 'PH', - Market.PK: 'PK', - Market.PL: 'PL', - Market.PM: 'PM', - Market.PN: 'PN', - Market.PR: 'PR', - Market.PS: 'PS', - Market.PT: 'PT', - Market.PW: 'PW', - Market.PY: 'PY', - Market.QA: 'QA', - Market.RE: 'RE', - Market.RO: 'RO', - Market.RS: 'RS', - Market.RU: 'RU', - Market.RW: 'RW', - Market.SA: 'SA', - Market.SB: 'SB', - Market.SC: 'SC', - Market.SD: 'SD', - Market.SE: 'SE', - Market.SG: 'SG', - Market.SH: 'SH', - Market.SI: 'SI', - Market.SJ: 'SJ', - Market.SK: 'SK', - Market.SL: 'SL', - Market.SM: 'SM', - Market.SN: 'SN', - Market.SO: 'SO', - Market.SR: 'SR', - Market.SS: 'SS', - Market.ST: 'ST', - Market.SV: 'SV', - Market.SX: 'SX', - Market.SY: 'SY', - Market.SZ: 'SZ', - Market.TC: 'TC', - Market.TD: 'TD', - Market.TF: 'TF', - Market.TG: 'TG', - Market.TH: 'TH', - Market.TJ: 'TJ', - Market.TK: 'TK', - Market.TL: 'TL', - Market.TM: 'TM', - Market.TN: 'TN', - Market.TO: 'TO', - Market.TR: 'TR', - Market.TT: 'TT', - Market.TV: 'TV', - Market.TW: 'TW', - Market.TZ: 'TZ', - Market.UA: 'UA', - Market.UG: 'UG', - Market.UM: 'UM', - Market.US: 'US', - Market.UY: 'UY', - Market.UZ: 'UZ', - Market.VA: 'VA', - Market.VC: 'VC', - Market.VE: 'VE', - Market.VG: 'VG', - Market.VI: 'VI', - Market.VN: 'VN', - Market.VU: 'VU', - Market.WF: 'WF', - Market.WS: 'WS', - Market.XK: 'XK', - Market.YE: 'YE', - Market.YT: 'YT', - Market.ZA: 'ZA', - Market.ZM: 'ZM', - Market.ZW: 'ZW', -}; - -const _$SearchModeEnumMap = { - SearchMode.youtube: 'youtube', - SearchMode.youtubeMusic: 'youtubeMusic', -}; - -const _$ThemeModeEnumMap = { - ThemeMode.system: 'system', - ThemeMode.light: 'light', - ThemeMode.dark: 'dark', -}; - -const _$AudioSourceEnumMap = { - AudioSource.youtube: 'youtube', - AudioSource.piped: 'piped', - AudioSource.jiosaavn: 'jiosaavn', -}; - -const _$SourceCodecsEnumMap = { - SourceCodecs.m4a: 'm4a', - SourceCodecs.weba: 'weba', -}; diff --git a/lib/provider/volume_provider.dart b/lib/provider/volume_provider.dart index 464b5e42..64bcfe1a 100644 --- a/lib/provider/volume_provider.dart +++ b/lib/provider/volume_provider.dart @@ -2,31 +2,23 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/utils/persisted_state_notifier.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; -class VolumeProvider extends PersistedStateNotifier { - VolumeProvider() : super(1, 'volume'); +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); - } - - @override - FutureOr onInit() async { - await audioPlayer.setVolume(state); - } - - @override - FutureOr fromJson(Map json) { - return json['volume'] as double? ?? 0.0; - } - - @override - Map toJson() { - return {'volume': state}; + KVStoreService.setVolume(volume); } } final volumeProvider = - StateNotifierProvider((ref) => VolumeProvider()); + NotifierProvider(() => VolumeProvider()); diff --git a/lib/provider/youtube_engine/youtube_engine.dart b/lib/provider/youtube_engine/youtube_engine.dart new file mode 100644 index 00000000..0aa37db5 --- /dev/null +++ b/lib/provider/youtube_engine/youtube_engine.dart @@ -0,0 +1,22 @@ +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 index b3957964..4febecdf 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -1,147 +1,163 @@ -import 'package:catcher_2/catcher_2.dart'; -import 'package:spotube/services/audio_player/mk_state_player.dart'; -// import 'package:just_audio/just_audio.dart' as ja; +import 'dart:io'; + +import 'package:media_kit/media_kit.dart' hide Track; +import 'package:spotube/services/logger/logger.dart'; +import 'package:flutter/foundation.dart'; +import 'package:spotify/spotify.dart' hide Playlist; +import 'package:spotube/models/local_track.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/loop_mode.dart'; import 'package:spotube/services/audio_player/playback_state.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:spotube/utils/platform.dart'; part 'audio_players_streams_mixin.dart'; part 'audio_player_impl.dart'; +class SpotubeMedia extends mk.Media { + final Track track; + + static int serverPort = 0; + + SpotubeMedia( + this.track, { + Map? extras, + super.httpHeaders, + }) : super( + track is LocalTrack + ? track.path + : "http://${kIsWindows ? "localhost" : InternetAddress.anyIPv4.address}:$serverPort/stream/${track.id}", + extras: { + ...?extras, + "track": switch (track) { + LocalTrack() => track.toJson(), + SourcedTrack() => track.toJson(), + _ => track.toJson(), + }, + }, + ); + + @override + String get uri { + return switch (track) { + /// [super.uri] must be used instead of [track.path] to prevent wrong + /// path format exceptions in Windows causing [extras] to be null + LocalTrack() => super.uri, + _ => + "http://${kIsWindows ? "localhost" : InternetAddress.anyIPv4.address}:" + "$serverPort/stream/${track.id}", + }; + } + + factory SpotubeMedia.fromMedia(mk.Media media) { + final track = media.uri.startsWith("http") + ? Track.fromJson(media.extras?["track"]) + : LocalTrack.fromJson(media.extras?["track"]); + return SpotubeMedia( + track, + extras: media.extras, + httpHeaders: media.httpHeaders, + ); + } + + // @override + // operator ==(Object other) { + // if (other is! SpotubeMedia) return false; + + // final isLocal = track is LocalTrack && other.track is LocalTrack; + // return isLocal + // ? (other.track as LocalTrack).path == (track as LocalTrack).path + // : other.track.id == track.id; + // } + + // @override + // int get hashCode => track is LocalTrack + // ? (track as LocalTrack).path.hashCode + // : track.id.hashCode; +} + abstract class AudioPlayerInterface { - final MkPlayerWithState _mkPlayer; - // final ja.AudioPlayer? _justAudio; + final CustomPlayer _mkPlayer; AudioPlayerInterface() - : _mkPlayer = MkPlayerWithState( + : _mkPlayer = CustomPlayer( configuration: const mk.PlayerConfiguration( title: "Spotube", + logLevel: kDebugMode ? mk.MPVLogLevel.info : mk.MPVLogLevel.error, ), - ) - // _justAudio = !_mkSupportedPlatform ? ja.AudioPlayer() : null - { + ) { _mkPlayer.stream.error.listen((event) { - Catcher2.reportCheckedError(event, StackTrace.current); + AppLogger.reportError(event, StackTrace.current); }); } /// Whether the current platform supports the audioplayers plugin static const bool _mkSupportedPlatform = true; - // DesktopTools.platform.isWindows || DesktopTools.platform.isLinux; bool get mkSupportedPlatform => _mkSupportedPlatform; - Future get duration async { + Duration get duration { return _mkPlayer.state.duration; - // if (mkSupportedPlatform) { - // } else { - // return _justAudio!.duration; - // } } - Future get position async { + Playlist get playlist { + return _mkPlayer.state.playlist; + } + + Duration get position { return _mkPlayer.state.position; - // if (mkSupportedPlatform) { - // } else { - // return _justAudio!.position; - // } } - Future get bufferedPosition async { - if (mkSupportedPlatform) { - // audioplayers doesn't have the capability to get buffered position - return null; - } else { - return null; - } + 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.playlist.medias.isNotEmpty; - // if (mkSupportedPlatform) { - // return _mkPlayer.playlist.medias.isNotEmpty; - // } else { - // return _justAudio!.audioSource != null; - // } + return _mkPlayer.state.playlist.medias.isNotEmpty; } // states bool get isPlaying { return _mkPlayer.state.playing; - // if (mkSupportedPlatform) { - // return _mkPlayer.state.playing; - // } else { - // return _justAudio!.playing; - // } } bool get isPaused { return !_mkPlayer.state.playing; - // if (mkSupportedPlatform) { - // return !_mkPlayer.state.playing; - // } else { - // return !isPlaying; - // } } bool get isStopped { return !hasSource; - // if (mkSupportedPlatform) { - // return !hasSource; - // } else { - // return _justAudio!.processingState == ja.ProcessingState.idle; - // } } Future get isCompleted async { return _mkPlayer.state.completed; - // if (mkSupportedPlatform) { - // return _mkPlayer.state.completed; - // } else { - // return _justAudio!.processingState == ja.ProcessingState.completed; - // } } - Future get isShuffled async { + bool get isShuffled { return _mkPlayer.shuffled; - // if (mkSupportedPlatform) { - // return _mkPlayer.shuffled; - // } else { - // return _justAudio!.shuffleModeEnabled; - // } } - PlaybackLoopMode get loopMode { - return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.loopMode); - // if (mkSupportedPlatform) { - // return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.loopMode); - // } else { - // return PlaybackLoopMode.fromLoopMode(_justAudio!.loopMode); - // } + 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; - // if (mkSupportedPlatform) { - // return _mkPlayer.state.volume / 100; - // } else { - // return _justAudio!.volume; - // } } bool get isBuffering { - return false; - // if (mkSupportedPlatform) { - // // audioplayers doesn't have the capability to get buffering state - // return false; - // } else { - // return _justAudio!.processingState == ja.ProcessingState.buffering || - // _justAudio!.processingState == ja.ProcessingState.loading; - // } + return _mkPlayer.state.buffering; } } diff --git a/lib/services/audio_player/audio_player_impl.dart b/lib/services/audio_player/audio_player_impl.dart index 2af94dd7..82c8c906 100644 --- a/lib/services/audio_player/audio_player_impl.dart +++ b/lib/services/audio_player/audio_player_impl.dart @@ -4,316 +4,128 @@ final audioPlayer = SpotubeAudioPlayer(); class SpotubeAudioPlayer extends AudioPlayerInterface with SpotubeAudioPlayersStreams { - Object _resolveUrlType(String url) { - // if (mkSupportedPlatform) { - return mk.Media(url); - // } else { - // if (url.startsWith("https")) { - // return ja.AudioSource.uri(Uri.parse(url)); - // } else { - // return ja.AudioSource.file(url); - // } - // } - } - - Future preload(String url) async { - throw UnimplementedError(); - // final urlType = _resolveUrlType(url); - // if (mkSupportedPlatform && urlType is ap.Source) { - // // audioplayers doesn't have the capability to preload - // return; - // } else { - // return; - // } - } - - Future play(String url) async { - final urlType = _resolveUrlType(url); - // if (mkSupportedPlatform && urlType is mk.Media) { - await _mkPlayer.open(urlType as mk.Media, play: true); - // } else { - // if (_justAudio?.audioSource is ja.ProgressiveAudioSource && - // (_justAudio?.audioSource as ja.ProgressiveAudioSource) - // .uri - // .toString() == - // url) { - // await _justAudio?.play(); - // } else { - // await _justAudio?.stop(); - // await _justAudio?.setAudioSource( - // urlType as ja.AudioSource, - // preload: true, - // ); - // await _justAudio?.play(); - // } - // } - } - Future pause() async { await _mkPlayer.pause(); - // await _justAudio?.pause(); } Future resume() async { await _mkPlayer.play(); - // await _justAudio?.play(); } Future stop() async { await _mkPlayer.stop(); - // await _justAudio?.stop(); - // await _justAudio?.setShuffleModeEnabled(false); - // await _justAudio?.setLoopMode(ja.LoopMode.off); } Future seek(Duration position) async { await _mkPlayer.seek(position); - // await _justAudio?.seek(position); } /// Volume is between 0 and 1 Future setVolume(double volume) async { assert(volume >= 0 && volume <= 1); await _mkPlayer.setVolume(volume * 100); - // await _justAudio?.setVolume(volume); } Future setSpeed(double speed) async { await _mkPlayer.setRate(speed); - // await _justAudio?.setSpeed(speed); + } + + Future setAudioDevice(mk.AudioDevice device) async { + await _mkPlayer.setAudioDevice(device); } Future dispose() async { await _mkPlayer.dispose(); - // await _justAudio?.dispose(); } // Playlist related Future openPlaylist( - List tracks, { + List tracks, { bool autoPlay = true, int initialIndex = 0, }) async { assert(tracks.isNotEmpty); assert(initialIndex <= tracks.length - 1); - // if (mkSupportedPlatform) { await _mkPlayer.open( - mk.Playlist( - tracks.map(mk.Media.new).toList(), - index: initialIndex, - ), + mk.Playlist(tracks, index: initialIndex), play: autoPlay, ); - // } else { - // await _justAudio!.setAudioSource( - // ja.ConcatenatingAudioSource( - // useLazyPreparation: true, - // children: - // tracks.map((e) => ja.AudioSource.uri(Uri.parse(e))).toList(), - // ), - // preload: true, - // initialIndex: initialIndex, - // ); - // if (autoPlay) { - // await _justAudio!.play(); - // } - // } - } - - // TODO: Make sure audio player soruces are also - // TODO: changed when preferences sources are changed - List resolveTracksForSource(List tracks) { - return tracks.where((e) => sources.contains(e.url)).toList(); - } - - bool tracksExistsInPlaylist(List tracks) { - return resolveTracksForSource(tracks).length == tracks.length; } List get sources { - // if (mkSupportedPlatform) { - return _mkPlayer.playlist.medias.map((e) => e.uri).toList(); - // } else { - // return _justAudio!.sequenceState?.effectiveSequence - // .map((e) => (e as ja.UriAudioSource).uri.toString()) - // .toList() ?? - // []; - // } + return _mkPlayer.state.playlist.medias.map((e) => e.uri).toList(); } String? get currentSource { - // if (mkSupportedPlatform) { - if (_mkPlayer.playlist.index == -1) return null; - return _mkPlayer.playlist.medias - .elementAtOrNull(_mkPlayer.playlist.index) + if (_mkPlayer.state.playlist.index == -1) return null; + return _mkPlayer.state.playlist.medias + .elementAtOrNull(_mkPlayer.state.playlist.index) ?.uri; - // } else { - // return (_justAudio?.sequenceState?.effectiveSequence - // .elementAtOrNull(_justAudio!.sequenceState!.currentIndex) - // as ja.UriAudioSource?) - // ?.uri - // .toString(); - // } } String? get nextSource { - // if (mkSupportedPlatform) { - - if (loopMode == PlaybackLoopMode.all && - _mkPlayer.playlist.index == _mkPlayer.playlist.medias.length - 1) { + if (loopMode == PlaylistMode.loop && + _mkPlayer.state.playlist.index == + _mkPlayer.state.playlist.medias.length - 1) { return sources.first; } - return _mkPlayer.playlist.medias - .elementAtOrNull(_mkPlayer.playlist.index + 1) + return _mkPlayer.state.playlist.medias + .elementAtOrNull(_mkPlayer.state.playlist.index + 1) ?.uri; - // } else { - // return (_justAudio?.sequenceState?.effectiveSequence - // .elementAtOrNull(_justAudio!.sequenceState!.currentIndex + 1) - // as ja.UriAudioSource?) - // ?.uri - // .toString(); - // } } String? get previousSource { - if (loopMode == PlaybackLoopMode.all && _mkPlayer.playlist.index == 0) { + if (loopMode == PlaylistMode.loop && _mkPlayer.state.playlist.index == 0) { return sources.last; } - // if (mkSupportedPlatform) { - return _mkPlayer.playlist.medias - .elementAtOrNull(_mkPlayer.playlist.index - 1) + return _mkPlayer.state.playlist.medias + .elementAtOrNull(_mkPlayer.state.playlist.index - 1) ?.uri; - // } else { - // return (_justAudio?.sequenceState?.effectiveSequence - // .elementAtOrNull(_justAudio!.sequenceState!.currentIndex - 1) - // as ja.UriAudioSource?) - // ?.uri - // .toString(); - // } } + int get currentIndex => _mkPlayer.state.playlist.index; + Future skipToNext() async { - // if (mkSupportedPlatform) { await _mkPlayer.next(); - // } else { - // await _justAudio!.seekToNext(); - // } } Future skipToPrevious() async { - // if (mkSupportedPlatform) { await _mkPlayer.previous(); - // } else { - // await _justAudio!.seekToPrevious(); - // } } Future jumpTo(int index) async { - // if (mkSupportedPlatform) { await _mkPlayer.jump(index); - // } else { - // await _justAudio!.seek(Duration.zero, index: index); - // } } - Future addTrack(String url) async { - final urlType = _resolveUrlType(url); - // if (mkSupportedPlatform && urlType is mk.Media) { - await _mkPlayer.add(urlType as mk.Media); - // } else { - // await (_justAudio!.audioSource as ja.ConcatenatingAudioSource) - // .add(urlType as ja.AudioSource); - // } + Future addTrack(mk.Media media) async { + await _mkPlayer.add(media); } - Future addTrackAt(String url, int index) async { - final urlType = _resolveUrlType(url); - // if (mkSupportedPlatform && urlType is mk.Media) { - await _mkPlayer.insert(index, urlType as mk.Media); - // } else { - // await (_justAudio!.audioSource as ja.ConcatenatingAudioSource) - // .insert(index, urlType as ja.AudioSource); - // } + Future addTrackAt(mk.Media media, int index) async { + await _mkPlayer.insert(index, media); } Future removeTrack(int index) async { - // if (mkSupportedPlatform) { await _mkPlayer.remove(index); - // } else { - // await (_justAudio!.audioSource as ja.ConcatenatingAudioSource) - // .removeAt(index); - // } } Future moveTrack(int from, int to) async { - // if (mkSupportedPlatform) { await _mkPlayer.move(from, to); - // } else { - // await (_justAudio!.audioSource as ja.ConcatenatingAudioSource) - // .move(from, to); - // } - } - - Future replaceSource( - String oldSource, - String newSource, { - bool exclusive = false, - }) async { - final oldSourceIndex = sources.indexOf(oldSource); - if (oldSourceIndex == -1) return; - - // if (mkSupportedPlatform) { - _mkPlayer.replace(oldSource, newSource); - // } else { - // final playlist = _justAudio!.audioSource as ja.ConcatenatingAudioSource; - - // print('oldSource: $oldSource'); - // print('newSource: $newSource'); - // final oldSourceIndexInPlaylist = - // _justAudio?.sequenceState?.effectiveSequence.indexWhere( - // (e) => (e as ja.UriAudioSource).uri.toString() == oldSource, - // ); - - // print('oldSourceIndexInPlaylist: $oldSourceIndexInPlaylist'); - - // // ignores non existing source - // if (oldSourceIndexInPlaylist == null || oldSourceIndexInPlaylist == -1) { - // return; - // } - - // await playlist.removeAt(oldSourceIndexInPlaylist); - // await playlist.insert( - // oldSourceIndexInPlaylist, - // ja.AudioSource.uri(Uri.parse(newSource)), - // ); - // } } Future clearPlaylist() async { - // if (mkSupportedPlatform) { _mkPlayer.stop(); - // } else { - // await (_justAudio!.audioSource as ja.ConcatenatingAudioSource).clear(); - // } } Future setShuffle(bool shuffle) async { - // if (mkSupportedPlatform) { await _mkPlayer.setShuffle(shuffle); - // } else { - // await _justAudio!.setShuffleModeEnabled(shuffle); - // } } - Future setLoopMode(PlaybackLoopMode loop) async { - // if (mkSupportedPlatform) { - await _mkPlayer.setPlaylistMode(loop.toPlaylistMode()); - // } else { - // await _justAudio!.setLoopMode(loop.toLoopMode()); - // } + Future setLoopMode(PlaylistMode loop) async { + await _mkPlayer.setPlaylistMode(loop); } Future setAudioNormalization(bool normalize) async { diff --git a/lib/services/audio_player/audio_players_streams_mixin.dart b/lib/services/audio_player/audio_players_streams_mixin.dart index a736dc1c..32405910 100644 --- a/lib/services/audio_player/audio_players_streams_mixin.dart +++ b/lib/services/audio_player/audio_players_streams_mixin.dart @@ -45,12 +45,9 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { Stream percentCompletedStream(double percent) { return positionStream .asyncMap( - (position) async => (await duration)?.inSeconds == 0 + (position) async => duration == Duration.zero ? 0 - : (position.inSeconds / - ((await duration)?.inSeconds ?? 100) * - 100) - .toInt(), + : (position.inSeconds / duration.inSeconds * 100).toInt(), ) .where((event) => event >= percent); } @@ -71,12 +68,12 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { // } } - Stream get loopModeStream { + Stream get loopModeStream { // if (mkSupportedPlatform) { - return _mkPlayer.loopModeStream.map(PlaybackLoopMode.fromPlaylistMode); + return _mkPlayer.stream.playlistMode; // } else { // return _justAudio!.loopModeStream - // .map(PlaybackLoopMode.fromLoopMode) + // .map(PlaylistMode.fromLoopMode) // ; // } } @@ -127,7 +124,7 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { // if (mkSupportedPlatform) { return _mkPlayer.indexChangeStream .map((event) { - return _mkPlayer.playlist.medias.elementAtOrNull(event)?.uri; + return _mkPlayer.state.playlist.medias.elementAtOrNull(event)?.uri; }) .where((event) => event != null) .cast(); @@ -140,4 +137,16 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { // .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.map((s) { + return s; + }); } diff --git a/lib/services/audio_player/custom_player.dart b/lib/services/audio_player/custom_player.dart new file mode 100644 index 00000000..f0dc8f13 --- /dev/null +++ b/lib/services/audio_player/custom_player.dart @@ -0,0 +1,147 @@ +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; + final StreamController _shuffleStream; + + late final List _subscriptions; + + bool _shuffled; + int _androidAudioSessionId = 0; + String _packageName = ""; + AndroidAudioManager? _androidAudioManager; + + CustomPlayer({super.configuration}) + : _playerStateStream = StreamController.broadcast(), + _shuffleStream = StreamController.broadcast(), + _shuffled = false { + 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 => _shuffled; + + Stream get playerStateStream => _playerStateStream.stream; + Stream get shuffleStream => _shuffleStream.stream; + 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 { + _shuffled = shuffle; + await super.setShuffle(shuffle); + _shuffleStream.add(shuffle); + await Future.delayed(const Duration(milliseconds: 100)); + if (shuffle) { + await move(state.playlist.index, 0); + } + } + + @override + Future stop() async { + await super.stop(); + + _shuffled = false; + _playerStateStream.add(AudioPlaybackState.stopped); + _shuffleStream.add(false); + } + + @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 { + await add(media); + await move(state.playlist.medias.length, index); + } + + 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', ''); + } + } +} diff --git a/lib/services/audio_player/loop_mode.dart b/lib/services/audio_player/loop_mode.dart deleted file mode 100644 index 78da43ba..00000000 --- a/lib/services/audio_player/loop_mode.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'package:audio_service/audio_service.dart'; -import 'package:media_kit/media_kit.dart'; - -/// An unified loop mode for both [LoopMode] and [PlaylistMode] -enum PlaybackLoopMode { - all, - one, - none; - - // static PlaybackLoopMode fromLoopMode(LoopMode loopMode) { - // switch (loopMode) { - // case LoopMode.all: - // return PlaybackLoopMode.all; - // case LoopMode.one: - // return PlaybackLoopMode.one; - // case LoopMode.off: - // return PlaybackLoopMode.none; - // } - // } - - // LoopMode toLoopMode() { - // switch (this) { - // case PlaybackLoopMode.all: - // return LoopMode.all; - // case PlaybackLoopMode.one: - // return LoopMode.one; - // case PlaybackLoopMode.none: - // return LoopMode.off; - // } - // } - - static PlaybackLoopMode fromPlaylistMode(PlaylistMode mode) { - switch (mode) { - case PlaylistMode.single: - return PlaybackLoopMode.one; - case PlaylistMode.loop: - return PlaybackLoopMode.all; - case PlaylistMode.none: - return PlaybackLoopMode.none; - } - } - - PlaylistMode toPlaylistMode() { - switch (this) { - case PlaybackLoopMode.all: - return PlaylistMode.loop; - case PlaybackLoopMode.one: - return PlaylistMode.single; - case PlaybackLoopMode.none: - return PlaylistMode.none; - } - } - - static PlaybackLoopMode fromAudioServiceRepeatMode( - AudioServiceRepeatMode mode) { - switch (mode) { - case AudioServiceRepeatMode.all: - case AudioServiceRepeatMode.group: - return PlaybackLoopMode.all; - case AudioServiceRepeatMode.one: - return PlaybackLoopMode.one; - case AudioServiceRepeatMode.none: - return PlaybackLoopMode.none; - } - } - - AudioServiceRepeatMode toAudioServiceRepeatMode() { - switch (this) { - case PlaybackLoopMode.all: - return AudioServiceRepeatMode.all; - case PlaybackLoopMode.one: - return AudioServiceRepeatMode.one; - case PlaybackLoopMode.none: - return AudioServiceRepeatMode.none; - } - } - - static PlaybackLoopMode fromString(String? value) { - switch (value) { - case 'all': - return PlaybackLoopMode.all; - case 'one': - return PlaybackLoopMode.one; - case 'none': - return PlaybackLoopMode.none; - default: - return PlaybackLoopMode.none; - } - } -} diff --git a/lib/services/audio_player/mk_state_player.dart b/lib/services/audio_player/mk_state_player.dart deleted file mode 100644 index 8b796d66..00000000 --- a/lib/services/audio_player/mk_state_player.dart +++ /dev/null @@ -1,382 +0,0 @@ -import 'dart:async'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; -import 'package:catcher_2/catcher_2.dart'; -import 'package:collection/collection.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'; - -/// MediaKit [Player] by default doesn't have a state stream. -/// This class adds a state stream to the [Player] class. -class MkPlayerWithState extends Player { - final StreamController _playerStateStream; - final StreamController _playlistStream; - final StreamController _shuffleStream; - final StreamController _loopModeStream; - - late final List _subscriptions; - - bool _shuffled; - PlaylistMode _loopMode; - - Playlist? _playlist; - List? _tempMedias; - int _androidAudioSessionId = 0; - String _packageName = ""; - AndroidAudioManager? _androidAudioManager; - - MkPlayerWithState({super.configuration}) - : _playerStateStream = StreamController.broadcast(), - _shuffleStream = StreamController.broadcast(), - _loopModeStream = StreamController.broadcast(), - _playlistStream = StreamController.broadcast(), - _shuffled = false, - _loopMode = PlaylistMode.none { - _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 { - try { - if (!isCompleted) return; - - _playerStateStream.add(AudioPlaybackState.completed); - if (loopMode == PlaylistMode.single) { - await super.open(_playlist!.medias[_playlist!.index], play: true); - } else { - await next(); - await Future.delayed(const Duration(milliseconds: 250), play); - } - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }), - stream.playlist.listen((event) { - if (event.medias.isEmpty) { - _playerStateStream.add(AudioPlaybackState.stopped); - } - }), - stream.error.listen((event) { - Catcher2.reportCheckedError('[MediaKitError] \n$event', null); - }), - ]; - PackageInfo.fromPlatform().then((packageInfo) { - _packageName = packageInfo.packageName; - }); - if (DesktopTools.platform.isAndroid) { - _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 (DesktopTools.platform.isAndroid) { - 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 => _shuffled; - PlaylistMode get loopMode => _loopMode; - Playlist get playlist => _playlist ?? const Playlist([], index: -1); - - Stream get playerStateStream => _playerStateStream.stream; - Stream get shuffleStream => _shuffleStream.stream; - Stream get loopModeStream => _loopModeStream.stream; - Stream get playlistStream => _playlistStream.stream; - Stream get indexChangeStream { - int oldIndex = playlist.index; - return playlistStream.map((event) => event.index).where((newIndex) { - if (newIndex != oldIndex) { - oldIndex = newIndex; - return true; - } - return false; - }); - } - - set playlist(Playlist playlist) { - _playlist = playlist; - _playlistStream.add(playlist); - } - - @override - Future setShuffle(bool shuffle) async { - _shuffled = shuffle; - if (shuffle) { - _tempMedias = _playlist!.medias; - final active = _playlist!.medias[_playlist!.index]; - final newMedias = _playlist!.medias.toList() - ..shuffle() - ..remove(active) - ..insert(0, active); - playlist = _playlist!.copyWith( - medias: newMedias, - index: newMedias.indexOf(active), - ); - } else { - if (_tempMedias == null) return; - playlist = _playlist!.copyWith( - medias: _tempMedias!, - index: _tempMedias?.indexOf( - _playlist!.medias[_playlist!.index], - ), - ); - _tempMedias = null; - } - await super.setShuffle(shuffle); - _shuffleStream.add(shuffle); - } - - @override - Future setPlaylistMode(PlaylistMode playlistMode) async { - _loopMode = playlistMode; - await super.setPlaylistMode(playlistMode); - _loopModeStream.add(playlistMode); - } - - @override - Future stop() async { - await super.stop(); - await pause(); - await seek(Duration.zero); - - _loopMode = PlaylistMode.none; - _shuffled = false; - _playlist = null; - _tempMedias = null; - _playerStateStream.add(AudioPlaybackState.stopped); - _shuffleStream.add(false); - } - - @override - Future dispose() async { - for (var element in _subscriptions) { - element.cancel(); - } - await notifyAudioSessionUpdate(false); - return super.dispose(); - } - - @override - Future open( - Playable playable, { - bool play = true, - }) async { - await stop(); - if (playable is Playlist) { - playlist = playable; - super.open(playable.medias[playable.index], play: play); - } - await super.open(playable, play: play); - } - - @override - Future next() async { - if (_playlist == null) { - return; - } - - final isLast = _playlist!.index == _playlist!.medias.length - 1; - - if (isLast) { - switch (loopMode) { - case PlaylistMode.loop: - playlist = _playlist!.copyWith(index: 0); - super.open(_playlist!.medias[_playlist!.index], play: true); - break; - case PlaylistMode.none: - // Fixes auto-repeating the last track - await super.stop(); - break; - default: - } - } else { - playlist = _playlist!.copyWith(index: _playlist!.index + 1); - - return super.open(_playlist!.medias[_playlist!.index], play: true); - } - } - - @override - Future previous() async { - if (_playlist == null || _playlist!.index - 1 < 0) return; - - if (loopMode == PlaylistMode.loop && _playlist!.index == 0) { - playlist = _playlist!.copyWith(index: _playlist!.medias.length - 1); - return super.open(_playlist!.medias[_playlist!.index], play: true); - } else if (_playlist!.index != 0) { - playlist = _playlist!.copyWith(index: _playlist!.index - 1); - return super.open(_playlist!.medias[_playlist!.index], play: true); - } - } - - @override - Future jump(int index) async { - if (_playlist == null || index < 0 || index >= _playlist!.medias.length) { - return; - } - - playlist = _playlist!.copyWith(index: index); - return super.open(_playlist!.medias[index], play: true); - } - - @override - Future move(int from, int to) async { - if (_playlist == null || - from >= _playlist!.medias.length || - to >= _playlist!.medias.length) return; - - final active = _playlist!.medias[_playlist!.index]; - final newPlaylist = _playlist!.copyWith( - medias: _playlist!.medias.mapIndexed((index, element) { - if (index == from) { - return _playlist!.medias[to]; - } else if (index == to) { - return _playlist!.medias[from]; - } - return element; - }).toList(), - ); - playlist = _playlist!.copyWith( - index: newPlaylist.medias.indexOf(active), - medias: newPlaylist.medias, - ); - } - - /// This replaces the old source with a new one - /// - /// If the old source is playing, the new one will play - /// from the beginning - /// - /// This doesn't work when [playlist] is null - void replace(String oldUrl, String newUrl) { - if (_playlist == null) { - return; - } - - final isOldUrlPlaying = _playlist!.medias[_playlist!.index].uri == oldUrl; - - // ends the loop where match is found - // tends to be a bit more efficient than forEach - _playlist!.medias.firstWhereIndexedOrNull((i, media) { - if (media.uri != oldUrl) return false; - if (isOldUrlPlaying) { - pause(); - } - final copyMedias = [..._playlist!.medias]; - copyMedias[i] = Media(newUrl, extras: media.extras); - playlist = _playlist!.copyWith(medias: copyMedias); - if (isOldUrlPlaying) { - super.open( - copyMedias[i], - play: true, - ); - } - - // replace in the _tempMedias if it's not null - if (shuffled && _tempMedias != null) { - final tempIndex = _tempMedias!.indexOf(media); - _tempMedias![tempIndex] = Media(newUrl, extras: media.extras); - } - return true; - }); - } - - @override - Future add(Media media) async { - if (_playlist == null) return; - - playlist = _playlist!.copyWith( - medias: [..._playlist!.medias, media], - ); - - if (shuffled && _tempMedias != null) { - _tempMedias!.add(media); - } - } - - FutureOr insert(int index, Media media) { - if (_playlist == null || - index < 0 || - (_playlist!.medias.length > 1 && - index > _playlist!.medias.length - 1)) { - return null; - } - - final newMedias = _playlist!.medias.toList()..insert(index, media); - - playlist = _playlist!.copyWith( - medias: newMedias, - index: newMedias.indexOf(_playlist!.medias[_playlist!.index]), - ); - - if (shuffled && _tempMedias != null) { - _tempMedias!.insert(index, media); - } - } - - /// Doesn't work when active media is the one to be removed - @override - Future remove(int index) async { - if (_playlist == null || - index < 0 || - index > _playlist!.medias.length - 1 || - _playlist!.index == index) { - return; - } - - final targetItem = _playlist!.medias.elementAtOrNull(index); - if (targetItem == null) return; - - if (shuffled && _tempMedias != null) { - _tempMedias!.remove(targetItem); - } - - final newMedias = _playlist!.medias.toList()..removeAt(index); - - playlist = _playlist!.copyWith( - medias: newMedias, - index: newMedias.indexOf(_playlist!.medias[_playlist!.index]), - ); - } - - NativePlayer get nativePlayer => platform as NativePlayer; - - 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', ''); - } - } -} diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index a6ecac3f..060a7f41 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -1,43 +1,52 @@ import 'package:audio_service/audio_service.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/collections/env.dart'; +import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/extensions/image.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/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/utils/platform.dart'; -class AudioServices { +class AudioServices with WidgetsBindingObserver { final MobileAudioService? mobile; final WindowsAudioService? smtc; - AudioServices(this.mobile, this.smtc); + AudioServices(this.mobile, this.smtc) { + WidgetsBinding.instance.addObserver(this); + } static Future create( Ref ref, - ProxyPlaylistNotifier playback, + AudioPlayerNotifier playback, ) async { - final mobile = DesktopTools.platform.isMobile || - DesktopTools.platform.isMacOS || - DesktopTools.platform.isLinux + final mobile = kIsMobile || kIsMacOS || kIsLinux ? await AudioService.init( builder: () => MobileAudioService(playback), - config: const AudioServiceConfig( - androidNotificationChannelId: 'com.krtirtho.Spotube', + config: AudioServiceConfig( + androidNotificationChannelId: switch (( + kIsLinux, + Env.releaseChannel + )) { + (true, _) => "spotube", + (_, ReleaseChannel.stable) => "com.krtirtho.Spotube", + (_, ReleaseChannel.nightly) => "com.krtirtho.Spotube.nightly", + }, androidNotificationChannelName: 'Spotube', - androidNotificationOngoing: true, + androidNotificationOngoing: false, + androidStopForegroundOnPause: false, + androidNotificationIcon: "drawable/ic_launcher_monochrome", + androidNotificationChannelDescription: "Spotube Media Controls", ), ) : null; - final smtc = DesktopTools.platform.isWindows - ? WindowsAudioService(ref, playback) - : null; + final smtc = kIsWindows ? WindowsAudioService(ref, playback) : null; - return AudioServices( - mobile, - smtc, - ); + return AudioServices(mobile, smtc); } Future addTrack(Track track) async { @@ -46,14 +55,15 @@ class AudioServices { id: track.id!, album: track.album?.name ?? "", title: track.name!, - artist: TypeConversionUtils.artists_X_String(track.artists ?? []), + artist: (track.artists)?.asString() ?? "", duration: track is SourcedTrack ? track.sourceInfo.duration : Duration(milliseconds: track.durationMs ?? 0), - artUri: Uri.parse(TypeConversionUtils.image_X_UrlString( - track.album?.images ?? [], - placeholder: ImagePlaceholder.albumArt, - )), + artUri: Uri.parse( + (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + ), playable: true, )); } @@ -66,7 +76,20 @@ class AudioServices { 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/linux_audio_service.dart b/lib/services/audio_services/linux_audio_service.dart deleted file mode 100644 index 436627e6..00000000 --- a/lib/services/audio_services/linux_audio_service.dart +++ /dev/null @@ -1,737 +0,0 @@ -import 'dart:io'; - -import 'package:dbus/dbus.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/audio_player/loop_mode.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -final dbus = DBusClient.session(); - -class _MprisMediaPlayer2 extends DBusObject { - /// Creates a new object to expose on [path]. - _MprisMediaPlayer2() : super(DBusObjectPath('/org/mpris/MediaPlayer2')) { - dbus.registerObject(this); - } - - void dispose() { - dbus.unregisterObject(this); - } - - /// Gets value of property org.mpris.MediaPlayer2.CanQuit - Future getCanQuit() async { - return DBusMethodSuccessResponse([const DBusBoolean(true)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Fullscreen - Future getFullscreen() async { - return DBusMethodSuccessResponse([const DBusBoolean(false)]); - } - - /// Sets property org.mpris.MediaPlayer2.Fullscreen - Future setFullscreen(bool value) async { - return DBusMethodSuccessResponse(); - } - - /// Gets value of property org.mpris.MediaPlayer2.CanSetFullscreen - Future getCanSetFullscreen() async { - return DBusMethodSuccessResponse([const DBusBoolean(false)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.CanRaise - Future getCanRaise() async { - return DBusMethodSuccessResponse([const DBusBoolean(false)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.HasTrackList - Future getHasTrackList() async { - return DBusMethodSuccessResponse([const DBusBoolean(false)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Identity - Future getIdentity() async { - return DBusMethodSuccessResponse([const DBusString("Spotube")]); - } - - /// Gets value of property org.mpris.MediaPlayer2.DesktopEntry - Future getDesktopEntry() async { - return DBusMethodSuccessResponse( - [const DBusString("/usr/share/application/spotube")], - ); - } - - /// Gets value of property org.mpris.MediaPlayer2.SupportedUriSchemes - Future getSupportedUriSchemes() async { - return DBusMethodSuccessResponse([ - DBusArray.string(["http"]) - ]); - } - - /// Gets value of property org.mpris.MediaPlayer2.SupportedMimeTypes - Future getSupportedMimeTypes() async { - return DBusMethodSuccessResponse([ - DBusArray.string(["audio/mpeg"]) - ]); - } - - /// Implementation of org.mpris.MediaPlayer2.Raise() - Future doRaise() async { - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Quit() - Future doQuit() async { - exit(0); - } - - @override - List introspect() { - return [ - DBusIntrospectInterface('org.mpris.MediaPlayer2', methods: [ - DBusIntrospectMethod('Raise'), - DBusIntrospectMethod('Quit') - ], properties: [ - DBusIntrospectProperty('CanQuit', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('Fullscreen', DBusSignature('b'), - access: DBusPropertyAccess.readwrite), - DBusIntrospectProperty('CanSetFullscreen', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanRaise', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('HasTrackList', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('Identity', DBusSignature('s'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('DesktopEntry', DBusSignature('s'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('SupportedUriSchemes', DBusSignature('as'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('SupportedMimeTypes', DBusSignature('as'), - access: DBusPropertyAccess.read) - ]) - ]; - } - - @override - Future handleMethodCall(DBusMethodCall methodCall) async { - if (methodCall.interface == 'org.mpris.MediaPlayer2') { - if (methodCall.name == 'Raise') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doRaise(); - } else if (methodCall.name == 'Quit') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doQuit(); - } else { - return DBusMethodErrorResponse.unknownMethod(); - } - } else { - return DBusMethodErrorResponse.unknownInterface(); - } - } - - @override - Future getProperty(String interface, String name) async { - if (interface == 'org.mpris.MediaPlayer2') { - if (name == 'CanQuit') { - return getCanQuit(); - } else if (name == 'Fullscreen') { - return getFullscreen(); - } else if (name == 'CanSetFullscreen') { - return getCanSetFullscreen(); - } else if (name == 'CanRaise') { - return getCanRaise(); - } else if (name == 'HasTrackList') { - return getHasTrackList(); - } else if (name == 'Identity') { - return getIdentity(); - } else if (name == 'DesktopEntry') { - return getDesktopEntry(); - } else if (name == 'SupportedUriSchemes') { - return getSupportedUriSchemes(); - } else if (name == 'SupportedMimeTypes') { - return getSupportedMimeTypes(); - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } - - @override - Future setProperty( - String interface, String name, DBusValue value) async { - if (interface == 'org.mpris.MediaPlayer2') { - if (name == 'CanQuit') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'Fullscreen') { - if (value.signature != DBusSignature('b')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return setFullscreen((value as DBusBoolean).value); - } else if (name == 'CanSetFullscreen') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanRaise') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'HasTrackList') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'Identity') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'DesktopEntry') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'SupportedUriSchemes') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'SupportedMimeTypes') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } - - @override - Future getAllProperties(String interface) async { - var properties = {}; - if (interface == 'org.mpris.MediaPlayer2') { - properties['CanQuit'] = (await getCanQuit()).returnValues[0]; - properties['Fullscreen'] = (await getFullscreen()).returnValues[0]; - properties['CanSetFullscreen'] = - (await getCanSetFullscreen()).returnValues[0]; - properties['CanRaise'] = (await getCanRaise()).returnValues[0]; - properties['HasTrackList'] = (await getHasTrackList()).returnValues[0]; - properties['Identity'] = (await getIdentity()).returnValues[0]; - properties['DesktopEntry'] = (await getDesktopEntry()).returnValues[0]; - properties['SupportedUriSchemes'] = - (await getSupportedUriSchemes()).returnValues[0]; - properties['SupportedMimeTypes'] = - (await getSupportedMimeTypes()).returnValues[0]; - } - return DBusMethodSuccessResponse([DBusDict.stringVariant(properties)]); - } -} - -class _MprisMediaPlayer2Player extends DBusObject { - final Ref ref; - final ProxyPlaylistNotifier playlistNotifier; - - /// Creates a new object to expose on [path]. - _MprisMediaPlayer2Player(this.ref, this.playlistNotifier) - : super(DBusObjectPath("/org/mpris/MediaPlayer2")) { - (() async { - final nameStatus = - await dbus.requestName("org.mpris.MediaPlayer2.spotube"); - if (nameStatus == DBusRequestNameReply.exists) { - await dbus.requestName("org.mpris.MediaPlayer2.spotube.instance$pid"); - } - await dbus.registerObject(this); - }()); - } - - ProxyPlaylist get playlist => playlistNotifier.playlist; - - void dispose() { - dbus.unregisterObject(this); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.PlaybackStatus - Future getPlaybackStatus() async { - final status = audioPlayer.isPlaying - ? "Playing" - : playlist.active == null - ? "Stopped" - : "Paused"; - return DBusMethodSuccessResponse([DBusString(status)]); - } - - // TODO: Implement Track Loop - - /// Gets value of property org.mpris.MediaPlayer2.Player.LoopStatus - Future getLoopStatus() async { - final loopMode = switch (await audioPlayer.loopMode) { - PlaybackLoopMode.all => "Playlist", - PlaybackLoopMode.one => "Track", - PlaybackLoopMode.none => "None", - }; - - return DBusMethodSuccessResponse([DBusString(loopMode)]); - } - - /// Sets property org.mpris.MediaPlayer2.Player.LoopStatus - Future setLoopStatus(String value) async { - // playlistNotifier.setIsLoop(value == "Track"); - return DBusMethodSuccessResponse(); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.Rate - Future getRate() async { - return DBusMethodSuccessResponse([const DBusDouble(1)]); - } - - /// Sets property org.mpris.MediaPlayer2.Player.Rate - Future setRate(double value) async { - return DBusMethodSuccessResponse(); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.Shuffle - Future getShuffle() async { - return DBusMethodSuccessResponse( - [DBusBoolean(await audioPlayer.isShuffled)]); - } - - /// Sets property org.mpris.MediaPlayer2.Player.Shuffle - Future setShuffle(bool value) async { - audioPlayer.setShuffle(value); - return DBusMethodSuccessResponse(); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.Metadata - Future getMetadata() async { - if (playlist.activeTrack == null || playlist.isFetching) { - return DBusMethodSuccessResponse([DBusDict.stringVariant({})]); - } - final id = playlist.activeTrack!.id; - - return DBusMethodSuccessResponse([ - DBusDict.stringVariant({ - "mpris:trackid": DBusString("${path.value}/Track/$id"), - "mpris:length": DBusInt32( - (await audioPlayer.duration)?.inMicroseconds ?? 0, - ), - "mpris:artUrl": DBusString( - TypeConversionUtils.image_X_UrlString( - playlist.activeTrack?.album?.images, - placeholder: ImagePlaceholder.albumArt, - ), - ), - "xesam:album": DBusString(playlist.activeTrack!.album!.name!), - "xesam:artist": DBusArray.string( - playlist.activeTrack!.artists!.map((artist) => artist.name!), - ), - "xesam:title": DBusString(playlist.activeTrack!.name!), - "xesam:url": DBusString( - playlist.activeTrack is SourcedTrack - ? (playlist.activeTrack as SourcedTrack).url - : playlist.activeTrack!.previewUrl ?? "", - ), - "xesam:genre": const DBusString("Unknown"), - }), - ]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.Volume - Future getVolume() async { - return DBusMethodSuccessResponse([DBusDouble(audioPlayer.volume)]); - } - - /// Sets property org.mpris.MediaPlayer2.Player.Volume - Future setVolume(double value) async { - await audioPlayer.setVolume(value); - return DBusMethodSuccessResponse(); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.Position - Future getPosition() async { - return DBusMethodSuccessResponse([ - DBusInt64((await audioPlayer.position)?.inMicroseconds ?? 0), - ]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.MinimumRate - Future getMinimumRate() async { - return DBusMethodSuccessResponse([const DBusDouble(1)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.MaximumRate - Future getMaximumRate() async { - return DBusMethodSuccessResponse([const DBusDouble(1)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.CanGoNext - Future getCanGoNext() async { - return DBusMethodSuccessResponse([ - DBusBoolean( - (playlist.tracks.length) > 1, - ) - ]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.CanGoPrevious - Future getCanGoPrevious() async { - return DBusMethodSuccessResponse([ - DBusBoolean( - (playlist.tracks.length) > 1, - ) - ]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.CanPlay - Future getCanPlay() async { - return DBusMethodSuccessResponse([const DBusBoolean(true)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.CanPause - Future getCanPause() async { - return DBusMethodSuccessResponse([const DBusBoolean(true)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.CanSeek - Future getCanSeek() async { - return DBusMethodSuccessResponse([const DBusBoolean(true)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.CanControl - Future getCanControl() async { - return DBusMethodSuccessResponse([const DBusBoolean(true)]); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.Next() - Future doNext() async { - await playlistNotifier.next(); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.Previous() - Future doPrevious() async { - await playlistNotifier.previous(); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.Pause() - Future doPause() async { - await audioPlayer.pause(); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.PlayPause() - Future doPlayPause() async { - audioPlayer.isPlaying - ? await audioPlayer.pause() - : await audioPlayer.resume(); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.Stop() - Future doStop() async { - playlistNotifier.stop(); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.Play() - Future doPlay() async { - await audioPlayer.resume(); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.Seek() - Future doSeek(int offset) async { - await audioPlayer.seek(Duration(microseconds: offset)); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.SetPosition() - Future doSetPosition(String TrackId, int Position) async { - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.OpenUri() - Future doOpenUri(String Uri) async { - return DBusMethodSuccessResponse(); - } - - /// Emits signal org.mpris.MediaPlayer2.Player.Seeked - Future emitSeeked(int position) async { - await emitSignal( - 'org.mpris.MediaPlayer2.Player', - 'Seeked', - [DBusInt64(position)], - ); - } - - Future updateProperties() async { - return emitPropertiesChanged( - "org.mpris.MediaPlayer2.Player", - changedProperties: { - "PlaybackStatus": (await getPlaybackStatus()).returnValues.first, - "LoopStatus": (await getLoopStatus()).returnValues.first, - "Rate": (await getRate()).returnValues.first, - "Shuffle": (await getShuffle()).returnValues.first, - "Metadata": (await getMetadata()).returnValues.first, - "Volume": (await getVolume()).returnValues.first, - "Position": (await getPosition()).returnValues.first, - "MinimumRate": (await getMinimumRate()).returnValues.first, - "MaximumRate": (await getMaximumRate()).returnValues.first, - "CanGoNext": (await getCanGoNext()).returnValues.first, - "CanGoPrevious": (await getCanGoPrevious()).returnValues.first, - "CanPlay": (await getCanPlay()).returnValues.first, - "CanPause": (await getCanPause()).returnValues.first, - "CanSeek": (await getCanSeek()).returnValues.first, - "CanControl": (await getCanControl()).returnValues.first, - }, - ); - } - - @override - List introspect() { - return [ - DBusIntrospectInterface('org.mpris.MediaPlayer2.Player', methods: [ - DBusIntrospectMethod('Next'), - DBusIntrospectMethod('Previous'), - DBusIntrospectMethod('Pause'), - DBusIntrospectMethod('PlayPause'), - DBusIntrospectMethod('Stop'), - DBusIntrospectMethod('Play'), - DBusIntrospectMethod('Seek', args: [ - DBusIntrospectArgument(DBusSignature('x'), DBusArgumentDirection.in_, - name: 'Offset') - ]), - DBusIntrospectMethod('SetPosition', args: [ - DBusIntrospectArgument(DBusSignature('o'), DBusArgumentDirection.in_, - name: 'TrackId'), - DBusIntrospectArgument(DBusSignature('x'), DBusArgumentDirection.in_, - name: 'Position') - ]), - DBusIntrospectMethod('OpenUri', args: [ - DBusIntrospectArgument(DBusSignature('s'), DBusArgumentDirection.in_, - name: 'Uri') - ]) - ], signals: [ - DBusIntrospectSignal('Seeked', args: [ - DBusIntrospectArgument(DBusSignature('x'), DBusArgumentDirection.out, - name: 'Position') - ]) - ], properties: [ - DBusIntrospectProperty('PlaybackStatus', DBusSignature('s'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('LoopStatus', DBusSignature('s'), - access: DBusPropertyAccess.readwrite), - DBusIntrospectProperty('Rate', DBusSignature('d'), - access: DBusPropertyAccess.readwrite), - DBusIntrospectProperty('Shuffle', DBusSignature('b'), - access: DBusPropertyAccess.readwrite), - DBusIntrospectProperty('Metadata', DBusSignature('a{sv}'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('Volume', DBusSignature('d'), - access: DBusPropertyAccess.readwrite), - DBusIntrospectProperty('Position', DBusSignature('x'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('MinimumRate', DBusSignature('d'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('MaximumRate', DBusSignature('d'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanGoNext', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanGoPrevious', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanPlay', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanPause', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanSeek', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanControl', DBusSignature('b'), - access: DBusPropertyAccess.read) - ]) - ]; - } - - @override - Future handleMethodCall(DBusMethodCall methodCall) async { - if (methodCall.interface == 'org.mpris.MediaPlayer2.Player') { - if (methodCall.name == 'Next') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doNext(); - } else if (methodCall.name == 'Previous') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doPrevious(); - } else if (methodCall.name == 'Pause') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doPause(); - } else if (methodCall.name == 'PlayPause') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doPlayPause(); - } else if (methodCall.name == 'Stop') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doStop(); - } else if (methodCall.name == 'Play') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doPlay(); - } else if (methodCall.name == 'Seek') { - if (methodCall.signature != DBusSignature('x')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doSeek((methodCall.values[0] as DBusInt64).value); - } else if (methodCall.name == 'SetPosition') { - if (methodCall.signature != DBusSignature('ox')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doSetPosition((methodCall.values[0] as DBusObjectPath).value, - (methodCall.values[1] as DBusInt64).value); - } else if (methodCall.name == 'OpenUri') { - if (methodCall.signature != DBusSignature('s')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doOpenUri((methodCall.values[0] as DBusString).value); - } else { - return DBusMethodErrorResponse.unknownMethod(); - } - } else { - return DBusMethodErrorResponse.unknownInterface(); - } - } - - @override - Future getProperty(String interface, String name) async { - if (interface == 'org.mpris.MediaPlayer2.Player') { - if (name == 'PlaybackStatus') { - return getPlaybackStatus(); - } else if (name == 'LoopStatus') { - return getLoopStatus(); - } else if (name == 'Rate') { - return getRate(); - } else if (name == 'Shuffle') { - return getShuffle(); - } else if (name == 'Metadata') { - return getMetadata(); - } else if (name == 'Volume') { - return getVolume(); - } else if (name == 'Position') { - return getPosition(); - } else if (name == 'MinimumRate') { - return getMinimumRate(); - } else if (name == 'MaximumRate') { - return getMaximumRate(); - } else if (name == 'CanGoNext') { - return getCanGoNext(); - } else if (name == 'CanGoPrevious') { - return getCanGoPrevious(); - } else if (name == 'CanPlay') { - return getCanPlay(); - } else if (name == 'CanPause') { - return getCanPause(); - } else if (name == 'CanSeek') { - return getCanSeek(); - } else if (name == 'CanControl') { - return getCanControl(); - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } - - @override - Future setProperty( - String interface, String name, DBusValue value) async { - if (interface == 'org.mpris.MediaPlayer2.Player') { - if (name == 'PlaybackStatus') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'LoopStatus') { - if (value.signature != DBusSignature('s')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return setLoopStatus((value as DBusString).value); - } else if (name == 'Rate') { - if (value.signature != DBusSignature('d')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return setRate((value as DBusDouble).value); - } else if (name == 'Shuffle') { - if (value.signature != DBusSignature('b')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return setShuffle((value as DBusBoolean).value); - } else if (name == 'Metadata') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'Volume') { - if (value.signature != DBusSignature('d')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return setVolume((value as DBusDouble).value); - } else if (name == 'Position') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'MinimumRate') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'MaximumRate') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanGoNext') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanGoPrevious') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanPlay') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanPause') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanSeek') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanControl') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } - - @override - Future getAllProperties(String interface) async { - var properties = {}; - if (interface == 'org.mpris.MediaPlayer2.Player') { - properties['PlaybackStatus'] = - (await getPlaybackStatus()).returnValues[0]; - properties['LoopStatus'] = (await getLoopStatus()).returnValues[0]; - properties['Rate'] = (await getRate()).returnValues[0]; - properties['Shuffle'] = (await getShuffle()).returnValues[0]; - properties['Metadata'] = (await getMetadata()).returnValues[0]; - properties['Volume'] = (await getVolume()).returnValues[0]; - properties['Position'] = (await getPosition()).returnValues[0]; - properties['MinimumRate'] = (await getMinimumRate()).returnValues[0]; - properties['MaximumRate'] = (await getMaximumRate()).returnValues[0]; - properties['CanGoNext'] = (await getCanGoNext()).returnValues[0]; - properties['CanGoPrevious'] = (await getCanGoPrevious()).returnValues[0]; - properties['CanPlay'] = (await getCanPlay()).returnValues[0]; - properties['CanPause'] = (await getCanPause()).returnValues[0]; - properties['CanSeek'] = (await getCanSeek()).returnValues[0]; - properties['CanControl'] = (await getCanControl()).returnValues[0]; - } - return DBusMethodSuccessResponse([DBusDict.stringVariant(properties)]); - } -} - -class LinuxAudioService { - _MprisMediaPlayer2 mp2; - _MprisMediaPlayer2Player player; - - LinuxAudioService(Ref ref, ProxyPlaylistNotifier playlistNotifier) - : mp2 = _MprisMediaPlayer2(), - player = _MprisMediaPlayer2Player(ref, playlistNotifier); - - void dispose() { - mp2.dispose(); - player.dispose(); - } -} diff --git a/lib/services/audio_services/mobile_audio_service.dart b/lib/services/audio_services/mobile_audio_service.dart index 833df89c..16a3618e 100644 --- a/lib/services/audio_services/mobile_audio_service.dart +++ b/lib/services/audio_services/mobile_audio_service.dart @@ -1,19 +1,24 @@ import 'dart:async'; +import 'dart:io'; import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.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:spotube/services/audio_player/loop_mode.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 ProxyPlaylistNotifier playlistNotifier; + final AudioPlayerNotifier audioPlayerNotifier; - ProxyPlaylist get playlist => playlistNotifier.state; + // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member + AudioPlayerState get playlist => audioPlayerNotifier.state; - MobileAudioService(this.playlistNotifier) { + MobileAudioService(this.audioPlayerNotifier) { AudioSession.instance.then((s) { session = s; session?.configure(const AudioSessionConfiguration.music()); @@ -55,6 +60,9 @@ class MobileAudioService extends BaseAudioHandler { }); }); audioPlayer.playerStateStream.listen((state) async { + if (state == AudioPlaybackState.playing) { + await session?.setActive(true); + } playbackState.add(await _transformEvent()); }); @@ -90,57 +98,69 @@ class MobileAudioService extends BaseAudioHandler { @override Future setRepeatMode(AudioServiceRepeatMode repeatMode) async { super.setRepeatMode(repeatMode); - audioPlayer.setLoopMode( - PlaybackLoopMode.fromAudioServiceRepeatMode(repeatMode), - ); + audioPlayer.setLoopMode(switch (repeatMode) { + AudioServiceRepeatMode.all || + AudioServiceRepeatMode.group => + PlaylistMode.loop, + AudioServiceRepeatMode.one => PlaylistMode.single, + _ => PlaylistMode.none, + }); } @override Future stop() async { - await playlistNotifier.stop(); + await audioPlayerNotifier.stop(); } @override Future skipToNext() async { - await playlistNotifier.next(); + await audioPlayer.skipToNext(); await super.skipToNext(); } @override Future skipToPrevious() async { - await playlistNotifier.previous(); + await audioPlayer.skipToPrevious(); await super.skipToPrevious(); } @override Future onTaskRemoved() async { - await playlistNotifier.stop(); - return super.onTaskRemoved(); + await audioPlayer.pause(); + if (kIsAndroid) exit(0); } Future _transformEvent() async { - final position = (await audioPlayer.position) ?? Duration.zero; - 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: position, - bufferedPosition: await audioPlayer.bufferedPosition ?? Duration.zero, - shuffleMode: await audioPlayer.isShuffled == true - ? AudioServiceShuffleMode.all - : AudioServiceShuffleMode.none, - repeatMode: (await audioPlayer.loopMode).toAudioServiceRepeatMode(), - processingState: playlist.isFetching == true - ? AudioProcessingState.loading - : AudioProcessingState.ready, - ); + 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/smtc_windows_web.dart b/lib/services/audio_services/smtc_windows_web.dart deleted file mode 100644 index 177f3ac5..00000000 --- a/lib/services/audio_services/smtc_windows_web.dart +++ /dev/null @@ -1,274 +0,0 @@ -class MusicMetadata { - final String? title; - final String? artist; - final String? album; - final String? albumArtist; - final String? thumbnail; - - const MusicMetadata({ - this.title, - this.artist, - this.album, - this.albumArtist, - this.thumbnail, - }); -} - -enum PlaybackStatus { - Closed, - Changing, - Stopped, - Playing, - Paused, -} - -enum PressedButton { - play, - pause, - next, - previous, - fastForward, - rewind, - stop, - record, - channelUp, - channelDown; - - static PressedButton fromString(String button) { - switch (button) { - case 'play': - return PressedButton.play; - case 'pause': - return PressedButton.pause; - case 'next': - return PressedButton.next; - case 'previous': - return PressedButton.previous; - case 'fast_forward': - return PressedButton.fastForward; - case 'rewind': - return PressedButton.rewind; - case 'stop': - return PressedButton.stop; - case 'record': - return PressedButton.record; - case 'channel_up': - return PressedButton.channelUp; - case 'channel_down': - return PressedButton.channelDown; - default: - throw Exception('Unknown button: $button'); - } - } -} - -class SMTCConfig { - final bool playEnabled; - final bool pauseEnabled; - final bool stopEnabled; - final bool nextEnabled; - final bool prevEnabled; - final bool fastForwardEnabled; - final bool rewindEnabled; - - const SMTCConfig({ - required this.playEnabled, - required this.pauseEnabled, - required this.stopEnabled, - required this.nextEnabled, - required this.prevEnabled, - required this.fastForwardEnabled, - required this.rewindEnabled, - }); -} - -enum RepeatMode { - none, - track, - list; - - static RepeatMode fromString(String value) { - switch (value) { - case 'none': - return none; - case 'track': - return track; - case 'list': - return list; - default: - throw Exception('Unknown repeat mode: $value'); - } - } - - String get asString => toString().split('.').last; -} - -class PlaybackTimeline { - final int startTimeMs; - final int endTimeMs; - final int positionMs; - final int? minSeekTimeMs; - final int? maxSeekTimeMs; - - const PlaybackTimeline({ - required this.startTimeMs, - required this.endTimeMs, - required this.positionMs, - this.minSeekTimeMs, - this.maxSeekTimeMs, - }); -} - -class SMTCWindows { - SMTCWindows({ - SMTCConfig? config, - PlaybackTimeline? timeline, - MusicMetadata? metadata, - PlaybackStatus? status, - bool? shuffleEnabled, - RepeatMode? repeatMode, - bool? enabled, - }); - - SMTCConfig get config => throw UnimplementedError(); - PlaybackTimeline get timeline => throw UnimplementedError(); - MusicMetadata get metadata => throw UnimplementedError(); - PlaybackStatus? get status => throw UnimplementedError(); - Stream get buttonPressStream => throw UnimplementedError(); - Stream get shuffleChangeStream => throw UnimplementedError(); - Stream get repeatModeChangeStream => throw UnimplementedError(); - - bool get isPlayEnabled => config.playEnabled; - bool get isPauseEnabled => config.pauseEnabled; - bool get isStopEnabled => config.stopEnabled; - bool get isNextEnabled => config.nextEnabled; - bool get isPrevEnabled => config.prevEnabled; - bool get isFastForwardEnabled => config.fastForwardEnabled; - bool get isRewindEnabled => config.rewindEnabled; - - bool get isShuffleEnabled => throw UnimplementedError(); - RepeatMode get repeatMode => throw UnimplementedError(); - bool get enabled => throw UnimplementedError(); - - Duration? get startTime => Duration(milliseconds: timeline.startTimeMs); - Duration? get endTime => Duration(milliseconds: timeline.endTimeMs); - Duration? get position => Duration(milliseconds: timeline.positionMs); - Duration? get minSeekTime => timeline.minSeekTimeMs == null - ? null - : Duration(milliseconds: timeline.minSeekTimeMs!); - Duration? get maxSeekTime => timeline.maxSeekTimeMs == null - ? null - : Duration(milliseconds: timeline.maxSeekTimeMs!); - - Future updateConfig(SMTCConfig config) { - throw UnimplementedError(); - } - - Future updateTimeline(PlaybackTimeline timeline) { - throw UnimplementedError(); - } - - Future updateMetadata(MusicMetadata metadata) { - throw UnimplementedError(); - } - - Future clearMetadata() { - throw UnimplementedError(); - } - - Future dispose() async { - throw UnimplementedError(); - } - - Future disableSmtc() { - throw UnimplementedError(); - } - - Future enableSmtc() { - throw UnimplementedError(); - } - - Future setPlaybackStatus(PlaybackStatus status) async { - throw UnimplementedError(); - } - - Future setIsPlayEnabled(bool enabled) { - throw UnimplementedError(); - } - - Future setIsPauseEnabled(bool enabled) { - throw UnimplementedError(); - } - - Future setIsStopEnabled(bool enabled) { - throw UnimplementedError(); - } - - Future setIsNextEnabled(bool enabled) { - throw UnimplementedError(); - } - - Future setIsPrevEnabled(bool enabled) { - throw UnimplementedError(); - } - - Future setIsFastForwardEnabled(bool enabled) { - throw UnimplementedError(); - } - - Future setIsRewindEnabled(bool enabled) { - throw UnimplementedError(); - } - - Future setTimeline(PlaybackTimeline timeline) { - return updateTimeline(timeline); - } - - Future setTitle(String title) { - throw UnimplementedError(); - } - - Future setArtist(String artist) { - throw UnimplementedError(); - } - - Future setAlbum(String album) { - throw UnimplementedError(); - } - - Future setAlbumArtist(String albumArtist) { - throw UnimplementedError(); - } - - Future setThumbnail(String thumbnail) { - throw UnimplementedError(); - } - - Future setPosition(Duration position) { - throw UnimplementedError(); - } - - Future setStartTime(Duration startTime) { - throw UnimplementedError(); - } - - Future setEndTime(Duration endTime) { - throw UnimplementedError(); - } - - Future setMaxSeekTime(Duration maxSeekTime) { - throw UnimplementedError(); - } - - Future setMinSeekTime(Duration minSeekTime) { - throw UnimplementedError(); - } - - Future setShuffleEnabled(bool enabled) { - throw UnimplementedError(); - } - - Future setRepeatMode(RepeatMode repeatMode) { - throw UnimplementedError(); - } -} diff --git a/lib/services/audio_services/windows_audio_service.dart b/lib/services/audio_services/windows_audio_service.dart index fde88145..8edc5069 100644 --- a/lib/services/audio_services/windows_audio_service.dart +++ b/lib/services/audio_services/windows_audio_service.dart @@ -3,21 +3,22 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:smtc_windows/smtc_windows.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/extensions/image.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'; -import 'package:spotube/utils/type_conversion_utils.dart'; class WindowsAudioService { final SMTCWindows smtc; final Ref ref; - final ProxyPlaylistNotifier playlistNotifier; + final AudioPlayerNotifier audioPlayerNotifier; final subscriptions = []; - WindowsAudioService(this.ref, this.playlistNotifier) + WindowsAudioService(this.ref, this.audioPlayerNotifier) : smtc = SMTCWindows(enabled: false) { - smtc.setPlaybackStatus(PlaybackStatus.Stopped); + smtc.setPlaybackStatus(PlaybackStatus.stopped); final buttonStream = smtc.buttonPressStream.listen((event) { switch (event) { case PressedButton.play: @@ -27,13 +28,13 @@ class WindowsAudioService { audioPlayer.pause(); break; case PressedButton.next: - playlistNotifier.next(); + audioPlayer.skipToNext(); break; case PressedButton.previous: - playlistNotifier.previous(); + audioPlayer.skipToPrevious(); break; case PressedButton.stop: - playlistNotifier.stop(); + audioPlayerNotifier.stop(); break; default: break; @@ -44,16 +45,16 @@ class WindowsAudioService { audioPlayer.playerStateStream.listen((state) async { switch (state) { case AudioPlaybackState.playing: - await smtc.setPlaybackStatus(PlaybackStatus.Playing); + await smtc.setPlaybackStatus(PlaybackStatus.playing); break; case AudioPlaybackState.paused: - await smtc.setPlaybackStatus(PlaybackStatus.Paused); + await smtc.setPlaybackStatus(PlaybackStatus.paused); break; case AudioPlaybackState.stopped: - await smtc.setPlaybackStatus(PlaybackStatus.Stopped); + await smtc.setPlaybackStatus(PlaybackStatus.stopped); break; case AudioPlaybackState.completed: - await smtc.setPlaybackStatus(PlaybackStatus.Changing); + await smtc.setPlaybackStatus(PlaybackStatus.changing); break; default: break; @@ -80,16 +81,17 @@ class WindowsAudioService { if (!smtc.enabled) { await smtc.enableSmtc(); } - await smtc.updateMetadata(MusicMetadata( - title: track.name!, - albumArtist: track.artists?.firstOrNull?.name ?? "Unknown", - artist: TypeConversionUtils.artists_X_String(track.artists ?? []), - album: track.album?.name ?? "Unknown", - thumbnail: TypeConversionUtils.image_X_UrlString( - track.album?.images ?? [], - placeholder: ImagePlaceholder.albumArt, + await smtc.updateMetadata( + MusicMetadata( + title: track.name!, + albumArtist: track.artists?.firstOrNull?.name ?? "Unknown", + artist: track.artists?.asString() ?? "Unknown", + album: track.album?.name ?? "Unknown", + thumbnail: (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), ), - )); + ); } void dispose() { diff --git a/lib/services/cli/cli.dart b/lib/services/cli/cli.dart index 61af710e..985c0e72 100644 --- a/lib/services/cli/cli.dart +++ b/lib/services/cli/cli.dart @@ -1,9 +1,10 @@ +// 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'; -import 'package:spotube/models/logger.dart'; Future startCLI(List args) async { final parser = ArgParser(); @@ -13,13 +14,6 @@ Future startCLI(List args) async { abbr: 'v', help: 'Verbose mode', defaultsTo: !kReleaseMode, - callback: (verbose) { - if (verbose) { - logEnv['VERBOSE'] = 'true'; - logEnv['DEBUG'] = 'true'; - logEnv['ERROR'] = 'true'; - } - }, ); parser.addFlag( "version", diff --git a/lib/services/connectivity_adapter.dart b/lib/services/connectivity_adapter.dart index c628f2f7..f6b760c8 100644 --- a/lib/services/connectivity_adapter.dart +++ b/lib/services/connectivity_adapter.dart @@ -1,34 +1,45 @@ import 'dart:async'; import 'dart:io'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/widgets.dart'; +import 'package:spotube/services/logger/logger.dart'; -class FlQueryInternetConnectionCheckerAdapter extends ConnectivityAdapter - with WidgetsBindingObserver { +class ConnectionCheckerService with WidgetsBindingObserver { final _connectionStreamController = StreamController.broadcast(); final Dio dio; - FlQueryInternetConnectionCheckerAdapter() - : dio = Dio(), - super() { + static final _instance = ConnectionCheckerService._(); + + static ConnectionCheckerService get instance => _instance; + + ConnectionCheckerService._() : dio = Dio() { Timer? timer; onConnectivityChanged.listen((connected) { - if (!connected && timer == null) { - timer = Timer.periodic(const Duration(seconds: 30), (timer) async { - if (WidgetsBinding.instance.lifecycleState == - AppLifecycleState.paused) { - return; - } - await isConnected; - }); - } else { - timer?.cancel(); - timer = null; + 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 @@ -72,8 +83,9 @@ class FlQueryInternetConnectionCheckerAdapter extends ConnectivityAdapter } return interfaces.any( - (interface) => - vpnNames.any((name) => interface.name.toLowerCase().contains(name)), + (interface) => vpnNames.any( + (name) => interface.name.toLowerCase().contains(name), + ), ); } @@ -100,15 +112,16 @@ class FlQueryInternetConnectionCheckerAdapter extends ConnectivityAdapter await isVpnActive(); // when VPN is active that means we are connected } - @override + bool isConnectedSync = true; + Future get isConnected async { final connected = await _isConnected(); if (connected != isConnectedSync /*previous value*/) { _connectionStreamController.add(connected); } + isConnectedSync = connected; return connected; } - @override Stream get onConnectivityChanged => _connectionStreamController.stream; } diff --git a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart index d1c078a7..c05095b3 100644 --- a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart +++ b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart @@ -1,15 +1,29 @@ import 'dart:convert'; -import 'package:http/http.dart' as http; +import 'package:dio/dio.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/models/spotify/home_feed.dart'; import 'package:spotube/models/spotify_friends.dart'; +import 'package:timezone/timezone.dart' as tz; class CustomSpotifyEndpoints { static const _baseUrl = 'https://api.spotify.com/v1'; final String accessToken; - final http.Client _client; + final Dio _client; - CustomSpotifyEndpoints(this.accessToken) : _client = http.Client(); + CustomSpotifyEndpoints(this.accessToken) + : _client = Dio( + BaseOptions( + baseUrl: _baseUrl, + responseType: ResponseType.json, + headers: { + "content-type": "application/json", + if (accessToken.isNotEmpty) + "authorization": "Bearer $accessToken", + "accept": "application/json", + }, + ), + ); // views API @@ -63,116 +77,146 @@ class CustomSpotifyEndpoints { if (country != null) 'country': country.name, }.entries.map((e) => '${e.key}=${e.value}').join('&'); - final res = await _client.get( + final res = await _client.getUri( Uri.parse('$_baseUrl/views/$view?$queryParams'), - headers: { - "content-type": "application/json", - "authorization": "Bearer $accessToken", - "accept": "application/json", - }, ); if (res.statusCode == 200) { - return jsonDecode(res.body); + return res.data; } else { throw Exception( '[CustomSpotifyEndpoints.getView]: Failed to get view' '\nStatus code: ${res.statusCode}' - '\nBody: ${res.body}', + '\nBody: ${res.data}', ); } } Future> listGenreSeeds() async { - final res = await _client.get( + final res = await _client.getUri( Uri.parse("$_baseUrl/recommendations/available-genre-seeds"), - headers: { - "content-type": "application/json", - if (accessToken.isNotEmpty) "authorization": "Bearer $accessToken", - "accept": "application/json", - }, ); if (res.statusCode == 200) { - final body = jsonDecode(res.body); + final body = res.data; return List.from(body["genres"] ?? []); } else { throw Exception( '[CustomSpotifyEndpoints.listGenreSeeds]: Failed to get genre seeds' '\nStatus code: ${res.statusCode}' - '\nBody: ${res.body}', + '\nBody: ${res.data}', ); } } - void _addList( - Map parameters, String key, Iterable paramList) { - if (paramList.isNotEmpty) { - parameters[key] = paramList.join(','); - } - } - - void _addTunableTrackMap( - Map parameters, Map? tunableTrackMap) { - if (tunableTrackMap != null) { - parameters.addAll(tunableTrackMap.map((k, v) => - MapEntry(k, v is int ? v.toString() : v.toStringAsFixed(2)))); - } - } - - Future> getRecommendations({ - Iterable? seedArtists, - Iterable? seedGenres, - Iterable? seedTracks, - int limit = 20, - Market? market, - Map? max, - Map? min, - Map? target, - }) async { - assert(limit >= 1 && limit <= 100, 'limit should be 1 <= limit <= 100'); - final seedsNum = (seedArtists?.length ?? 0) + - (seedGenres?.length ?? 0) + - (seedTracks?.length ?? 0); - assert( - seedsNum >= 1 && seedsNum <= 5, - 'Up to 5 seed values may be provided in any combination of seed_artists,' - ' seed_tracks and seed_genres.'); - final parameters = {'limit': limit.toString()}; - final _ = { - 'seed_artists': seedArtists, - 'seed_genres': seedGenres, - 'seed_tracks': seedTracks - }.forEach((key, list) => _addList(parameters, key, list!)); - if (market != null) parameters['market'] = market.name; - for (var map in [min, max, target]) { - _addTunableTrackMap(parameters, map); - } - final pathQuery = - "$_baseUrl/recommendations?${parameters.entries.map((e) => '${e.key}=${e.value}').join('&')}"; - final res = await _client.get( - Uri.parse(pathQuery), - headers: { - "content-type": "application/json", - if (accessToken.isNotEmpty) "authorization": "Bearer $accessToken", - "accept": "application/json", - }, - ); - final result = jsonDecode(res.body); - return List.castFrom( - result["tracks"].map((track) => Track.fromJson(track)).toList(), - ); - } - Future getFriendActivity() async { - final res = await _client.get( + final res = await _client.getUri( Uri.parse("https://guc-spclient.spotify.com/presence-view/v1/buddylist"), - headers: { - "content-type": "application/json", - "authorization": "Bearer $accessToken", - "accept": "application/json", - }, ); - return SpotifyFriends.fromJson(jsonDecode(res.body)); + return SpotifyFriends.fromJson(res.data); + } + + Future getHomeFeed({ + required Market country, + String? spTCookie, + }) async { + final headers = { + 'app-platform': 'WebPlayer', + 'authorization': 'Bearer $accessToken', + 'content-type': 'application/json;charset=UTF-8', + 'dnt': '1', + 'origin': 'https://open.spotify.com', + 'referer': 'https://open.spotify.com/' + }; + final response = await _client.getUri( + Uri( + scheme: "https", + host: "api-partner.spotify.com", + path: "/pathfinder/v1/query", + queryParameters: { + "operationName": "home", + "variables": jsonEncode({ + "timeZone": tz.local.name, + "sp_t": spTCookie ?? "", + "country": country.name, + "facet": null, + "sectionItemsLimit": 10 + }), + "extensions": jsonEncode( + { + "persistedQuery": { + "version": 1, + + /// GraphQL persisted Query hash + /// This can change overtime. We've to lookout for it + /// Docs: https://www.apollographql.com/docs/graphos/operations/persisted-queries/ + "sha256Hash": + "eb3fba2d388cf4fc4d696b1757a58584e9538a3b515ea742e9cc9465807340be", + } + }, + ), + }, + ), + options: Options(headers: headers), + ); + + final data = SpotifyHomeFeed.fromJson( + transformHomeFeedJsonMap(response.data), + ); + + return data; + } + + Future getHomeFeedSection( + String sectionUri, { + String? spTCookie, + required Market country, + }) async { + final headers = { + 'app-platform': 'WebPlayer', + 'authorization': 'Bearer $accessToken', + 'content-type': 'application/json;charset=UTF-8', + 'dnt': '1', + 'origin': 'https://open.spotify.com', + 'referer': 'https://open.spotify.com/' + }; + final response = await _client.getUri( + Uri( + scheme: "https", + host: "api-partner.spotify.com", + path: "/pathfinder/v1/query", + queryParameters: { + "operationName": "homeSection", + "variables": jsonEncode({ + "timeZone": tz.local.name, + "sp_t": spTCookie ?? "", + "country": country.name, + "uri": sectionUri + }), + "extensions": jsonEncode( + { + "persistedQuery": { + "version": 1, + + /// GraphQL persisted Query hash + /// This can change overtime. We've to lookout for it + /// Docs: https://www.apollographql.com/docs/graphos/operations/persisted-queries/ + "sha256Hash": + "eb3fba2d388cf4fc4d696b1757a58584e9538a3b515ea742e9cc9465807340be", + } + }, + ), + }, + ), + options: Options(headers: headers), + ); + + final data = SpotifyHomeFeedSection.fromJson( + transformSectionItemJsonMap( + response.data["data"]["homeSections"]["sections"][0], + ), + ); + + return data; } } diff --git a/lib/services/device_info/device_info.dart b/lib/services/device_info/device_info.dart new file mode 100644 index 00000000..87ddd6eb --- /dev/null +++ b/lib/services/device_info/device_info.dart @@ -0,0 +1,34 @@ +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 new file mode 100644 index 00000000..cddf1979 --- /dev/null +++ b/lib/services/dio/dio.dart @@ -0,0 +1,3 @@ +import 'package:dio/dio.dart'; + +final globalDio = Dio(); diff --git a/lib/services/download_manager/chunked_download.dart b/lib/services/download_manager/chunked_download.dart index 9e5e0a98..80a3e78f 100644 --- a/lib/services/download_manager/chunked_download.dart +++ b/lib/services/download_manager/chunked_download.dart @@ -2,9 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; -import 'package:spotube/models/logger.dart'; - -final logger = getLogger("ChunkedDownload"); /// Downloading by spiting as file in chunks extension ChunkDownload on Dio { @@ -69,11 +66,7 @@ extension ChunkDownload on Dio { } await raf.close(); - logger.d("Downloaded file path: ${headFile.path}"); - headFile = await headFile.rename(savePath); - - logger.d("Renamed file path: ${headFile.path}"); } final firstResponse = await downloadChunk( diff --git a/lib/services/download_manager/download_manager.dart b/lib/services/download_manager/download_manager.dart index d7a42430..d2072bd7 100644 --- a/lib/services/download_manager/download_manager.dart +++ b/lib/services/download_manager/download_manager.dart @@ -1,18 +1,18 @@ import 'dart:async'; import 'dart:collection'; import 'dart:io'; -import 'package:catcher_2/catcher_2.dart'; import 'package:collection/collection.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; -import 'package:spotube/models/logger.dart'; + import 'package:spotube/services/download_manager/chunked_download.dart'; import 'package:spotube/services/download_manager/download_request.dart'; import 'package:spotube/services/download_manager/download_status.dart'; import 'package:spotube/services/download_manager/download_task.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/utils/primitive_utils.dart'; export './download_request.dart'; @@ -25,7 +25,6 @@ typedef DownloadStatusEvent = ({ }); class DownloadManager { - final logger = getLogger("DownloadManager"); final Map _cache = {}; final Queue _queue = Queue(); var dio = Dio(); @@ -77,9 +76,10 @@ class DownloadManager { } setStatus(task, DownloadStatus.downloading); - logger.d("[DownloadManager] $url"); final file = File(savePath.toString()); + await Directory(path.dirname(savePath)).create(recursive: true); + final tmpDirPath = await Directory( path.join( (await getTemporaryDirectory()).path, @@ -97,11 +97,8 @@ class DownloadManager { final partialFileExist = await partialFile.exists(); if (fileExist) { - logger.d("[DownloadManager] File Exists"); setStatus(task, DownloadStatus.completed); } else if (partialFileExist) { - logger.d("[DownloadManager] Partial File Exists"); - final partialFileLength = await partialFile.length(); final response = await dio.download( @@ -146,7 +143,7 @@ class DownloadManager { } } } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); + AppLogger.reportError(e, stackTrace); var task = getDownload(url)!; if (task.status.value != DownloadStatus.canceled && @@ -207,7 +204,7 @@ class DownloadManager { // Do nothing return _cache[downloadRequest.url]!; } else { - _queue.remove(_cache[downloadRequest.url]); + _queue.remove(_cache[downloadRequest.url]?.request); } } @@ -223,7 +220,6 @@ class DownloadManager { } Future pauseDownload(String url) async { - logger.d("[DownloadManager] Pause Download"); var task = getDownload(url)!; setStatus(task, DownloadStatus.paused); task.request.cancelToken.cancel(); @@ -232,7 +228,6 @@ class DownloadManager { } Future cancelDownload(String url) async { - logger.d("[DownloadManager] Cancel Download"); var task = getDownload(url)!; setStatus(task, DownloadStatus.canceled); _queue.remove(task.request); @@ -240,7 +235,6 @@ class DownloadManager { } Future resumeDownload(String url) async { - logger.d("[DownloadManager] Resume Download"); var task = getDownload(url)!; setStatus(task, DownloadStatus.downloading); task.request.cancelToken = CancelToken(); @@ -286,21 +280,21 @@ class DownloadManager { } Future pauseBatchDownloads(List urls) async { - urls.forEach((element) { + for (var element in urls) { pauseDownload(element); - }); + } } Future cancelBatchDownloads(List urls) async { - urls.forEach((element) { + for (var element in urls) { cancelDownload(element); - }); + } } Future resumeBatchDownloads(List urls) async { - urls.forEach((element) { + for (var element in urls) { resumeDownload(element); - }); + } } ValueNotifier getBatchDownloadProgress(List urls) { @@ -315,9 +309,9 @@ class DownloadManager { return getDownload(urls.first)?.progress ?? progress; } - var progressMap = Map(); + var progressMap = {}; - urls.forEach((url) { + for (var url in urls) { DownloadTask? task = getDownload(url); if (task != null) { @@ -328,29 +322,27 @@ class DownloadManager { progress.value = progressMap.values.sum / total; } - var progressListener; - progressListener = () { + void progressListener() { progressMap[url] = task.progress.value; progress.value = progressMap.values.sum / total; - }; + } task.progress.addListener(progressListener); - var listener; - listener = () { + void listener() { if (task.status.value.isCompleted) { progressMap[url] = 1.0; progress.value = progressMap.values.sum / total; task.status.removeListener(listener); task.progress.removeListener(progressListener); } - }; + } task.status.addListener(listener); } else { total--; } - }); + } return progress; } @@ -374,8 +366,7 @@ class DownloadManager { } } - var listener; - listener = () { + void listener() { if (task.status.value.isCompleted) { completed++; @@ -384,7 +375,7 @@ class DownloadManager { task.status.removeListener(listener); } } - }; + } task.status.addListener(listener); } else { @@ -406,7 +397,6 @@ class DownloadManager { while (_queue.isNotEmpty && runningTasks < maxConcurrentTasks) { runningTasks++; - logger.d('Concurrent workers: $runningTasks'); var currentRequest = _queue.removeFirst(); await download( diff --git a/lib/services/download_manager/download_task.dart b/lib/services/download_manager/download_task.dart index 5d57a655..d79cf95b 100644 --- a/lib/services/download_manager/download_task.dart +++ b/lib/services/download_manager/download_task.dart @@ -21,13 +21,12 @@ class DownloadTask { completer.complete(status.value); } - var listener; - listener = () { + void listener() { if (status.value.isCompleted) { completer.complete(status.value); status.removeListener(listener); } - }; + } status.addListener(listener); diff --git a/lib/services/kv_store/encrypted_kv_store.dart b/lib/services/kv_store/encrypted_kv_store.dart new file mode 100644 index 00000000..4eca0007 --- /dev/null +++ b/lib/services/kv_store/encrypted_kv_store.dart @@ -0,0 +1,59 @@ +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 index 5845b120..e334322e 100644 --- a/lib/services/kv_store/kv_store.dart +++ b/lib/services/kv_store/kv_store.dart @@ -1,4 +1,10 @@ +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; @@ -17,4 +23,96 @@ abstract class KVStoreService { 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 new file mode 100644 index 00000000..1f15bf92 --- /dev/null +++ b/lib/services/logger/logger.dart @@ -0,0 +1,158 @@ +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/mutations/album.dart b/lib/services/mutations/album.dart deleted file mode 100644 index 144b6a8f..00000000 --- a/lib/services/mutations/album.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/hooks/spotify/use_spotify_mutation.dart'; - -class AlbumMutations { - const AlbumMutations(); - - Mutation toggleFavorite( - WidgetRef ref, - String albumId, { - List? refreshQueries, - List? refreshInfiniteQueries, - MutationOnDataFn? onData, - }) { - return useSpotifyMutation( - "toggle-album-like/$albumId", - (isLiked, spotify) async { - if (isLiked) { - await spotify.me.removeAlbums([albumId]); - } else { - await spotify.me.saveAlbums([albumId]); - } - return !isLiked; - }, - ref: ref, - refreshQueries: refreshQueries, - refreshInfiniteQueries: refreshInfiniteQueries, - onData: onData, - ); - } -} diff --git a/lib/services/mutations/mutations.dart b/lib/services/mutations/mutations.dart deleted file mode 100644 index 28670486..00000000 --- a/lib/services/mutations/mutations.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:spotube/services/mutations/album.dart'; -import 'package:spotube/services/mutations/playlist.dart'; -import 'package:spotube/services/mutations/track.dart'; - -class _UseMutations { - const _UseMutations._(); - final playlist = const PlaylistMutations(); - final album = const AlbumMutations(); - final track = const TrackMutations(); -} - -const useMutations = _UseMutations._(); diff --git a/lib/services/mutations/playlist.dart b/lib/services/mutations/playlist.dart deleted file mode 100644 index f480c565..00000000 --- a/lib/services/mutations/playlist.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_mutation.dart'; -import 'package:spotube/services/queries/queries.dart'; - -typedef PlaylistCRUDVariables = ({ - String playlistName, - bool? public, - bool? collaborative, - String? description, - String? base64Image, -}); - -class PlaylistMutations { - const PlaylistMutations(); - - Mutation toggleFavorite( - WidgetRef ref, - String playlistId, { - List? refreshQueries, - List? refreshInfiniteQueries, - ValueChanged? onData, - }) { - return useSpotifyMutation( - "toggle-playlist-like/$playlistId", - (isLiked, spotify) async { - if (isLiked) { - await spotify.playlists.unfollowPlaylist(playlistId); - } else { - await spotify.playlists.followPlaylist(playlistId); - } - return !isLiked; - }, - ref: ref, - refreshQueries: refreshQueries, - refreshInfiniteQueries: [ - ...?refreshInfiniteQueries, - "current-user-playlists", - ], - onData: (data, recoveryData) { - onData?.call(data); - }, - ); - } - - Mutation removeTrackOf( - WidgetRef ref, - String playlistId, - ) { - return useSpotifyMutation( - "remove-track-from-playlist/$playlistId", - (trackId, spotify) async { - await spotify.playlists.removeTracks([trackId], playlistId); - return true; - }, - ref: ref, - refreshQueries: ["playlist-tracks/$playlistId"], - ); - } - - Mutation create( - WidgetRef ref, { - List? trackIds, - ValueChanged? onError, - ValueChanged? onData, - }) { - final me = useQueries.user.me(ref); - return useSpotifyMutation( - "create-playlist", - (variable, spotify) async { - final playlist = await spotify.playlists.createPlaylist( - me.data!.id!, - variable.playlistName, - collaborative: variable.collaborative, - description: variable.description, - public: variable.public, - ); - - if (variable.base64Image != null) { - await spotify.playlists.updatePlaylistImage( - playlist.id!, - variable.base64Image!, - ); - } - - if (trackIds != null && trackIds.isNotEmpty) { - await spotify.playlists.addTracks( - trackIds.map((id) => "spotify:track:$id").toList(), - playlist.id!, - ); - } - - return playlist; - }, - refreshInfiniteQueries: ["current-user-playlists"], - refreshQueries: ["current-user-all-playlists"], - ref: ref, - onError: (error, recoveryData) { - onError?.call(error); - }, - onData: (data, recoveryData) { - onData?.call(data); - }, - ); - } - - Mutation update( - WidgetRef ref, { - String? playlistId, - ValueChanged? onError, - ValueChanged? onData, - }) { - return useSpotifyMutation( - "update-playlist/$playlistId", - (variable, spotify) async { - if (playlistId == null) return; - await spotify.playlists.updatePlaylist( - playlistId, - variable.playlistName, - collaborative: variable.collaborative, - description: variable.description, - public: variable.public, - ); - if (variable.base64Image != null) { - await spotify.playlists.updatePlaylistImage( - playlistId, - variable.base64Image!, - ); - } - }, - refreshInfiniteQueries: [ - "playlist/$playlistId", - "current-user-playlists", - ], - refreshQueries: ["current-user-all-playlists"], - ref: ref, - onError: (error, recoveryData) { - onError?.call(error); - }, - onData: (data, recoveryData) { - onData?.call(data); - }, - ); - } -} diff --git a/lib/services/mutations/track.dart b/lib/services/mutations/track.dart deleted file mode 100644 index f8208b5e..00000000 --- a/lib/services/mutations/track.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/hooks/spotify/use_spotify_mutation.dart'; - -class TrackMutations { - const TrackMutations(); - - Mutation toggleFavorite( - WidgetRef ref, - String trackId, { - MutationOnMutationFn? onMutate, - MutationOnDataFn? onData, - MutationOnErrorFn? onError, - }) { - return useSpotifyMutation( - 'toggle-track-like/$trackId', - (isLiked, spotify) async { - if (isLiked) { - await spotify.tracks.me.removeOne(trackId); - } else { - await spotify.tracks.me.saveOne(trackId); - } - return !isLiked; - }, - ref: ref, - onData: onData, - onMutate: onMutate, - refreshQueries: ["playlist-tracks/user-liked-tracks"], - onError: onError, - ); - } -} diff --git a/lib/services/queries/album.dart b/lib/services/queries/album.dart deleted file mode 100644 index 0cc10256..00000000 --- a/lib/services/queries/album.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:catcher_2/catcher_2.dart'; -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class AlbumQueries { - const AlbumQueries(); - - InfiniteQuery, dynamic, int> ofMine(WidgetRef ref) { - return useSpotifyInfiniteQuery, dynamic, int>( - "current-user-albums", - (page, spotify) { - return spotify.me.savedAlbums().getPage( - 20, - page * 20, - ); - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) => - (lastPageData.items?.length ?? 0) < 20 || lastPageData.isLast - ? null - : lastPage + 1, - ref: ref, - ); - } - - static final tracksOfJob = InfiniteQueryJob.withVariableKey< - List, - dynamic, - int, - ({ - SpotifyApi spotify, - AlbumSimple album, - })>( - baseQueryKey: "album-tracks", - initialPage: 0, - task: (albumId, page, args) async { - final res = - await args!.spotify.albums.tracks(albumId).getPage(20, page * 20); - return res.items - ?.map((track) => - TypeConversionUtils.simpleTrack_X_Track(track, args.album)) - .toList() ?? - []; - }, - nextPage: (lastPage, lastPageData) { - if (lastPageData.length < 20) { - return null; - } - return lastPage + 1; - }, - ); - - InfiniteQuery, dynamic, int> tracksOf( - WidgetRef ref, - AlbumSimple album, - ) { - final spotify = ref.watch(spotifyProvider); - - return useInfiniteQueryJob( - job: tracksOfJob(album.id!), - args: (spotify: spotify, album: album), - ); - } - - Query isSavedForMe( - WidgetRef ref, - String album, - ) { - return useSpotifyQuery( - "is-saved-for-me/$album", - (spotify) { - return spotify.me - .containsSavedAlbums([album]).then((value) => value[album]); - }, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> newReleases(WidgetRef ref) { - final market = ref - .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); - - return useSpotifyInfiniteQuery, dynamic, int>( - "new-releases", - (pageParam, spotify) async { - try { - final albums = await spotify.browse - .newReleases(country: market) - .getPage(50, pageParam); - - return albums; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - rethrow; - } - }, - ref: ref, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast) { - return null; - } - return lastPageData.nextOffset; - }, - ); - } -} diff --git a/lib/services/queries/artist.dart b/lib/services/queries/artist.dart deleted file mode 100644 index 1b939c82..00000000 --- a/lib/services/queries/artist.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/wikipedia/wikipedia.dart'; -import 'package:wikipedia_api/wikipedia_api.dart'; - -class ArtistQueries { - const ArtistQueries(); - - Query get( - WidgetRef ref, - String artist, - ) { - return useSpotifyQuery( - "artist-profile/$artist", - (spotify) => spotify.artists.get(artist), - ref: ref, - ); - } - - InfiniteQuery, dynamic, String> followedByMe( - WidgetRef ref) { - return useSpotifyInfiniteQuery, dynamic, String>( - "user-following-artists", - (pageParam, spotify) async { - return spotify.me - .following(FollowingType.artist) - .getPage(15, pageParam); - }, - initialPage: "", - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 15) { - return null; - } - return lastPageData.after; - }, - ref: ref, - ); - } - - Query, dynamic> followedByMeAll(WidgetRef ref) { - return useSpotifyQuery( - "user-following-artists-all", - (spotify) async { - CursorPage? page = - await spotify.me.following(FollowingType.artist).getPage(50); - - final following = []; - - if (page.isLast == true) { - return page.items?.toList() ?? []; - } - - following.addAll(page.items ?? []); - while (page?.isLast != true) { - page = await spotify.me - .following(FollowingType.artist) - .getPage(50, page?.after ?? ''); - following.addAll(page.items ?? []); - } - - return following; - }, - ref: ref, - ); - } - - Query doIFollow( - WidgetRef ref, - String artist, - ) { - return useSpotifyQuery( - "user-follows-artists-query/$artist", - (spotify) async { - final result = await spotify.me.checkFollowing( - FollowingType.artist, - [artist], - ); - return result[artist]; - }, - ref: ref, - ); - } - - Query, dynamic> topTracksOf( - WidgetRef ref, - String artist, - ) { - final preferences = ref.watch(userPreferencesProvider); - return useSpotifyQuery, dynamic>( - "artist-top-track-query/$artist", - (spotify) { - return spotify.artists - .topTracks(artist, preferences.recommendationMarket); - }, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> albumsOf( - WidgetRef ref, - String artist, - ) { - return useSpotifyInfiniteQuery, dynamic, int>( - "artist-albums/$artist", - (pageParam, spotify) async { - return spotify.artists.albums(artist).getPage(5, pageParam); - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 5) { - return null; - } - return lastPageData.nextOffset; - }, - ref: ref, - ); - } - - Query, dynamic> relatedArtistsOf( - WidgetRef ref, - String artist, - ) { - return useSpotifyQuery, dynamic>( - "artist-related-artist-query/$artist", - (spotify) { - return spotify.artists.relatedArtists(artist); - }, - ref: ref, - ); - } - - Query wikipediaSummary(ArtistSimple artist) { - return useQuery( - "artist-wikipedia-query/${artist.id}", - () 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/services/queries/category.dart b/lib/services/queries/category.dart deleted file mode 100644 index d520b909..00000000 --- a/lib/services/queries/category.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class CategoryQueries { - const CategoryQueries(); - - Query, dynamic> listAll( - WidgetRef ref, - Market recommendationMarket, - ) { - ref.watch(userPreferencesProvider.select((s) => s.locale)); - final locale = useContext().l10n.localeName; - final query = useSpotifyQuery, dynamic>( - "category-playlists", - (spotify) async { - final categories = await spotify.categories - .list( - country: recommendationMarket, - locale: locale, - ) - .all(); - - return categories.toList()..shuffle(); - }, - ref: ref, - ); - - return query; - } - - InfiniteQuery, dynamic, int> list( - WidgetRef ref, - Market recommendationMarket, - ) { - ref.watch(userPreferencesProvider.select((s) => s.locale)); - final locale = useContext().l10n.localeName; - return useSpotifyInfiniteQuery, dynamic, int>( - "category-playlists", - (pageParam, spotify) async { - final categories = await spotify.categories - .list( - country: recommendationMarket, - locale: locale, - ) - .getPage(8, pageParam); - - return categories; - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 8) { - return null; - } - return lastPageData.nextOffset; - }, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> playlistsOf( - WidgetRef ref, - String category, - ) { - ref.watch(userPreferencesProvider.select((s) => s.locale)); - final market = ref - .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); - final locale = useContext().l10n.localeName; - return useSpotifyInfiniteQuery, dynamic, int>( - "category-playlists/$category", - (pageParam, spotify) async { - final playlists = await Pages( - spotify, - "v1/browse/categories/$category/playlists?country=${market.name}&locale=$locale", - (json) => json == null ? null : PlaylistSimple.fromJson(json), - 'playlists', - (json) => PlaylistsFeatured.fromJson(json), - ).getPage(5, pageParam); - - return playlists; - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 5) { - return null; - } - return lastPageData.nextOffset; - }, - ref: ref, - ); - } - - Query, dynamic> genreSeeds(WidgetRef ref) { - final customSpotify = ref.watch(customSpotifyEndpointProvider); - final query = useQuery, dynamic>( - "genre-seeds", - customSpotify.listGenreSeeds, - ); - - useEffect(() { - return ref.listenManual( - customSpotifyEndpointProvider, - (previous, next) { - if (previous != next) { - query.refresh(); - } - }, - ).close; - }, [query]); - - return query; - } -} diff --git a/lib/services/queries/lyrics.dart b/lib/services/queries/lyrics.dart deleted file mode 100644 index 618f960f..00000000 --- a/lib/services/queries/lyrics.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'dart:convert'; - -import 'package:collection/collection.dart'; -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/map.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/models/lyrics.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:http/http.dart' as http; - -class LyricsQueries { - const LyricsQueries(); - - Query static( - Track? track, - String geniusAccessToken, - ) { - return useQuery( - "genius-lyrics-query/${track?.id}", - () async { - if (track == null) { - return "“Give this player a track to play”\n- S'Challa"; - } - final lyrics = await ServiceUtils.getLyrics( - track.name!, - track.artists?.map((s) => s.name).whereNotNull().toList() ?? [], - apiKey: geniusAccessToken, - optimizeQuery: true, - ); - - if (lyrics == null) throw Exception("Unable find lyrics"); - return lyrics; - }, - ); - } - - Query synced( - Track? track, - ) { - return useQuery( - "synced-lyrics/${track?.id}}", - () async { - if (track == null || track is! SourcedTrack) { - throw "No track currently"; - } - final timedLyrics = await ServiceUtils.getTimedLyrics(track); - if (timedLyrics == null) throw Exception("Unable to find lyrics"); - - return timedLyrics; - }, - ); - } - - /// The Concept behind this method was shamelessly stolen from - /// https://github.com/akashrchandran/spotify-lyrics-api - /// - /// Thanks to [akashrchandran](https://github.com/akashrchandran) for the idea - /// - /// Special thanks to [raptag](https://github.com/raptag) for discovering this - /// jem - - Query spotifySynced(WidgetRef ref, Track? track) { - return useSpotifyQuery( - "spotify-synced-lyrics/${track?.id}}", - (spotify) async { - if (track == null) { - throw "No track currently"; - } - final token = await spotify.getCredentials(); - final res = await http.get( - Uri.parse( - "https://spclient.wg.spotify.com/color-lyrics/v2/track/${track.id}?format=json&market=from_token", - ), - headers: { - "User-Agent": - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36", - "App-platform": "WebPlayer", - "authorization": "Bearer ${token.accessToken}" - }); - - if (res.statusCode != 200) { - throw Exception("Unable to find lyrics"); - } - final linesRaw = Map.castFrom( - jsonDecode(res.body), - )["lyrics"]?["lines"] as List?; - - final lines = linesRaw?.map((line) { - return LyricSlice( - time: Duration(milliseconds: int.parse(line["startTimeMs"])), - text: line["words"] as String, - ); - }).toList() ?? - []; - - return SubtitleSimple( - lyrics: lines, - name: track.name!, - uri: res.request!.url, - rating: 100, - ); - }, - jsonConfig: JsonConfig( - fromJson: (json) => SubtitleSimple.fromJson(json.castKeyDeep()), - toJson: (data) => data.toJson(), - ), - ref: ref, - ); - } -} diff --git a/lib/services/queries/playlist.dart b/lib/services/queries/playlist.dart deleted file mode 100644 index 836f9d72..00000000 --- a/lib/services/queries/playlist.dart +++ /dev/null @@ -1,318 +0,0 @@ -import 'package:catcher_2/catcher_2.dart'; -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/library/playlist_generate/recommendation_attribute_dials.dart'; -import 'package:spotube/extensions/map.dart'; -import 'package:spotube/extensions/track.dart'; -import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; -import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -typedef RecommendationParameters = ({ - RecommendationAttribute acousticness, - RecommendationAttribute danceability, - RecommendationAttribute duration_ms, - RecommendationAttribute energy, - RecommendationAttribute instrumentalness, - RecommendationAttribute key, - RecommendationAttribute liveness, - RecommendationAttribute loudness, - RecommendationAttribute mode, - RecommendationAttribute popularity, - RecommendationAttribute speechiness, - RecommendationAttribute tempo, - RecommendationAttribute time_signature, - RecommendationAttribute valence, -}); - -Map recommendationAttributeToMap(RecommendationAttribute attr) => { - "min": attr.min, - "target": attr.target, - "max": attr.max, - }; - -({Map min, Map target, Map max}) - recommendationParametersToMap(RecommendationParameters params) { - final maxMap = { - if (params.acousticness != zeroValues) - "acousticness": params.acousticness.max, - if (params.danceability != zeroValues) - "danceability": params.danceability.max, - if (params.duration_ms != zeroValues) "duration_ms": params.duration_ms.max, - if (params.energy != zeroValues) "energy": params.energy.max, - if (params.instrumentalness != zeroValues) - "instrumentalness": params.instrumentalness.max, - if (params.key != zeroValues) "key": params.key.max, - if (params.liveness != zeroValues) "liveness": params.liveness.max, - if (params.loudness != zeroValues) "loudness": params.loudness.max, - if (params.mode != zeroValues) "mode": params.mode.max, - if (params.popularity != zeroValues) "popularity": params.popularity.max, - if (params.speechiness != zeroValues) "speechiness": params.speechiness.max, - if (params.tempo != zeroValues) "tempo": params.tempo.max, - if (params.time_signature != zeroValues) - "time_signature": params.time_signature.max, - if (params.valence != zeroValues) "valence": params.valence.max, - }; - final minMap = { - if (params.acousticness != zeroValues) - "acousticness": params.acousticness.min, - if (params.danceability != zeroValues) - "danceability": params.danceability.min, - if (params.duration_ms != zeroValues) "duration_ms": params.duration_ms.min, - if (params.energy != zeroValues) "energy": params.energy.min, - if (params.instrumentalness != zeroValues) - "instrumentalness": params.instrumentalness.min, - if (params.key != zeroValues) "key": params.key.min, - if (params.liveness != zeroValues) "liveness": params.liveness.min, - if (params.loudness != zeroValues) "loudness": params.loudness.min, - if (params.mode != zeroValues) "mode": params.mode.min, - if (params.popularity != zeroValues) "popularity": params.popularity.min, - if (params.speechiness != zeroValues) "speechiness": params.speechiness.min, - if (params.tempo != zeroValues) "tempo": params.tempo.min, - if (params.time_signature != zeroValues) - "time_signature": params.time_signature.min, - if (params.valence != zeroValues) "valence": params.valence.min, - }; - final targetMap = { - if (params.acousticness != zeroValues) - "acousticness": params.acousticness.target, - if (params.danceability != zeroValues) - "danceability": params.danceability.target, - if (params.duration_ms != zeroValues) - "duration_ms": params.duration_ms.target, - if (params.energy != zeroValues) "energy": params.energy.target, - if (params.instrumentalness != zeroValues) - "instrumentalness": params.instrumentalness.target, - if (params.key != zeroValues) "key": params.key.target, - if (params.liveness != zeroValues) "liveness": params.liveness.target, - if (params.loudness != zeroValues) "loudness": params.loudness.target, - if (params.mode != zeroValues) "mode": params.mode.target, - if (params.popularity != zeroValues) "popularity": params.popularity.target, - if (params.speechiness != zeroValues) - "speechiness": params.speechiness.target, - if (params.tempo != zeroValues) "tempo": params.tempo.target, - if (params.time_signature != zeroValues) - "time_signature": params.time_signature.target, - if (params.valence != zeroValues) "valence": params.valence.target, - }; - - return ( - max: maxMap, - min: minMap, - target: targetMap, - ); -} - -class PlaylistQueries { - const PlaylistQueries(); - - Query doesUserFollow( - WidgetRef ref, - String playlistId, - String userId, - ) { - return useSpotifyQuery( - "playlist-is-followed/$playlistId/$userId", - (spotify) async { - final result = - await spotify.playlists.followedByUsers(playlistId, [userId]); - return result[userId] ?? false; - }, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> ofMine(WidgetRef ref) { - return useSpotifyInfiniteQuery, dynamic, int>( - "current-user-playlists", - (page, spotify) async { - final playlists = await spotify.playlists.me.getPage(10, page * 10); - return playlists; - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) => - (lastPageData.items?.length ?? 0) < 10 || lastPageData.isLast - ? null - : lastPage + 1, - ref: ref, - ); - } - - Query, dynamic> ofMineAll(WidgetRef ref) { - return useSpotifyQuery, dynamic>( - "current-user-all-playlists", - (spotify) async { - var page = await spotify.playlists.me.getPage(50); - final playlists = []; - - if (page.isLast == true) { - return page.items?.toList() ?? []; - } - - playlists.addAll(page.items ?? []); - while (!page.isLast) { - page = await spotify.playlists.me.getPage(50, page.nextOffset); - playlists.addAll(page.items ?? []); - } - - return playlists; - }, - ref: ref, - ); - } - - Future> likedTracks(SpotifyApi spotify) async { - final tracks = await spotify.tracks.me.saved.all(); - - return tracks.map((e) => e.track!).toList(); - } - - Query, dynamic> likedTracksQuery(WidgetRef ref) { - final query = useCallback((spotify) => likedTracks(spotify), []); - final context = useContext(); - - return useSpotifyQuery, dynamic>( - "user-liked-tracks", - query, - jsonConfig: JsonConfig( - toJson: (tracks) => { - 'tracks': tracks.map((e) => e.toJson()).toList(), - }, - fromJson: (json) => (json['tracks'] as List) - .map( - (e) => Track.fromJson((e as Map).castKeyDeep()), - ) - .toList(), - ), - refreshConfig: RefreshConfig.withDefaults( - context, - // will never make it stale - staleDuration: const Duration(days: 60), - ), - ref: ref, - ); - } - - Query byId(WidgetRef ref, String id) { - return useSpotifyQuery( - "playlist/$id", - (spotify) async { - return await spotify.playlists.get(id); - }, - ref: ref, - ); - } - - Future> tracksOf( - int pageParam, - SpotifyApi spotify, - String playlistId, - ) async { - try { - final playlists = await spotify.playlists - .getTracksByPlaylistId(playlistId) - .getPage(20, pageParam * 20); - return playlists.items?.toList() ?? []; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - rethrow; - } - } - - int? tracksOfQueryNextPage(int lastPage, List lastPageData) { - if (lastPageData.length < 20) { - return null; - } - return lastPage + 1; - } - - InfiniteQuery, dynamic, int> tracksOfQuery( - WidgetRef ref, - String playlistId, - ) { - return useSpotifyInfiniteQuery, dynamic, int>( - "playlist-tracks/$playlistId", - (page, spotify) => tracksOf(page, spotify, playlistId), - initialPage: 0, - nextPage: tracksOfQueryNextPage, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> featured( - WidgetRef ref, - ) { - return useSpotifyInfiniteQuery, dynamic, int>( - "featured-playlists", - (pageParam, spotify) async { - try { - final playlists = - await spotify.playlists.featured.getPage(5, pageParam); - return playlists; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - rethrow; - } - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 5) { - return null; - } - return lastPageData.nextOffset; - }, - ref: ref, - ); - } - - Query, dynamic> generate( - WidgetRef ref, { - ({List tracks, List artists, List genres})? seeds, - RecommendationParameters? parameters, - int limit = 20, - Market? market, - }) { - final marketOfPreference = ref.watch( - userPreferencesProvider.select((s) => s.recommendationMarket), - ); - final customSpotify = ref.watch(customSpotifyEndpointProvider); - - final parametersMap = - parameters == null ? null : recommendationParametersToMap(parameters); - - final query = useQuery, dynamic>( - "generate-playlist", - () async { - final tracks = await customSpotify.getRecommendations( - limit: limit, - market: market ?? marketOfPreference, - max: parametersMap?.max, - min: parametersMap?.min, - target: parametersMap?.target, - seedArtists: seeds?.artists, - seedGenres: seeds?.genres, - seedTracks: seeds?.tracks, - ); - return tracks; - }, - ); - - useEffect(() { - return ref.listenManual( - customSpotifyEndpointProvider, - (previous, next) { - if (previous != next) { - query.refresh(); - } - }, - ).close; - }, [query]); - - return query; - } -} diff --git a/lib/services/queries/queries.dart b/lib/services/queries/queries.dart deleted file mode 100644 index 30c23268..00000000 --- a/lib/services/queries/queries.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:spotube/services/queries/album.dart'; -import 'package:spotube/services/queries/artist.dart'; -import 'package:spotube/services/queries/category.dart'; -import 'package:spotube/services/queries/lyrics.dart'; -import 'package:spotube/services/queries/playlist.dart'; -import 'package:spotube/services/queries/search.dart'; -import 'package:spotube/services/queries/tracks.dart'; -import 'package:spotube/services/queries/user.dart'; -import 'package:spotube/services/queries/views.dart'; - -class Queries { - const Queries._(); - final album = const AlbumQueries(); - final artist = const ArtistQueries(); - final category = const CategoryQueries(); - final lyrics = const LyricsQueries(); - final playlist = const PlaylistQueries(); - final search = const SearchQueries(); - final user = const UserQueries(); - final views = const ViewsQueries(); - final tracks = const TracksQueries(); -} - -const useQueries = Queries._(); diff --git a/lib/services/queries/search.dart b/lib/services/queries/search.dart deleted file mode 100644 index 3c6ee064..00000000 --- a/lib/services/queries/search.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/spotify_provider.dart'; - -typedef SearchParams = ({ - SpotifyApi spotify, - SearchType searchType, - String query -}); - -class SearchQueries { - const SearchQueries(); - - static final queryJob = - InfiniteQueryJob.withVariableKey, dynamic, int, SearchParams>( - baseQueryKey: "search-query", - task: (variableKey, page, args) => args!.spotify.search.get( - args.query, - types: [args.searchType], - ).getPage(10, page), - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isEmpty) return null; - if ((lastPageData.first.isLast || - (lastPageData.first.items ?? []).length < 10)) { - return null; - } - return lastPageData.first.nextOffset; - }, - enabled: false, - ); - - InfiniteQuery, dynamic, int> query( - WidgetRef ref, - String queryStr, - SearchType searchType, - ) { - final spotify = ref.watch(spotifyProvider); - final query = useInfiniteQueryJob, dynamic, int, SearchParams>( - job: queryJob(searchType.name), - args: (spotify: spotify, searchType: searchType, query: queryStr), - ); - - useEffect(() { - return ref.listenManual( - spotifyProvider, - (previous, next) { - if (previous != next) { - query.refreshAll(); - } - }, - ).close; - }, [query]); - - return query; - } -} diff --git a/lib/services/queries/tracks.dart b/lib/services/queries/tracks.dart deleted file mode 100644 index 52bab984..00000000 --- a/lib/services/queries/tracks.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; - -class TracksQueries { - const TracksQueries(); - - Query track(WidgetRef ref, String id) { - return useSpotifyQuery( - "track/$id", - (spotify) => spotify.tracks.get(id), - ref: ref, - ); - } -} diff --git a/lib/services/queries/user.dart b/lib/services/queries/user.dart deleted file mode 100644 index 82af600f..00000000 --- a/lib/services/queries/user.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/models/spotify_friends.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class UserQueries { - const UserQueries(); - Query me(WidgetRef ref) { - final context = useContext(); - - return useSpotifyQuery( - "current-user", - (spotify) async { - final me = await spotify.me.get(); - if (ref.read(AuthenticationNotifier.provider) == null) return null; - if (me.images == null || me.images?.isEmpty == true) { - me.images = [ - Image() - ..height = 50 - ..width = 50 - ..url = TypeConversionUtils.image_X_UrlString( - me.images, - placeholder: ImagePlaceholder.artist, - ), - ]; - } - return me; - }, - refreshConfig: RefreshConfig.withDefaults( - context, - // will never make it stale - staleDuration: const Duration(days: 60), - ), - ref: ref, - ); - } - - Query friendActivity(WidgetRef ref) { - final customSpotify = ref.read(customSpotifyEndpointProvider); - return useSpotifyQuery( - "friend-activity", - (spotify) { - return customSpotify.getFriendActivity(); - }, - ref: ref, - ); - } -} diff --git a/lib/services/queries/views.dart b/lib/services/queries/views.dart deleted file mode 100644 index 4864ffe1..00000000 --- a/lib/services/queries/views.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class ViewsQueries { - const ViewsQueries(); - - Query?, dynamic> get( - WidgetRef ref, - String view, - ) { - final customSpotify = ref.watch(customSpotifyEndpointProvider); - final auth = ref.watch(AuthenticationNotifier.provider); - final market = ref - .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); - - final locale = useContext().l10n.localeName; - - final query = useQuery?, dynamic>("views/$view", () { - if (auth == null) return null; - return customSpotify.getView( - view, - market: market, - country: market, - locale: locale, - ); - }); - - useEffect(() { - return ref.listenManual( - customSpotifyEndpointProvider, - (previous, next) { - if (previous != next) { - query.refresh(); - } - }, - ).close; - }, [query]); - - return query; - } -} diff --git a/lib/services/song_link/song_link.dart b/lib/services/song_link/song_link.dart index b02f60cb..e3cffa52 100644 --- a/lib/services/song_link/song_link.dart +++ b/lib/services/song_link/song_link.dart @@ -2,7 +2,7 @@ library song_link; import 'dart:convert'; -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:dio/dio.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:html/parser.dart'; @@ -47,7 +47,7 @@ abstract class SongLinkService { return songLinks?.map((link) => SongLink.fromJson(link)).toList() ?? []; } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); + AppLogger.reportError(e, stackTrace); return []; } } diff --git a/lib/services/song_link/song_link.freezed.dart b/lib/services/song_link/song_link.freezed.dart index a8230eeb..c704cde3 100644 --- a/lib/services/song_link/song_link.freezed.dart +++ b/lib/services/song_link/song_link.freezed.dart @@ -12,7 +12,7 @@ part of 'song_link.dart'; 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#custom-getters-and-methods'); + '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'); SongLink _$SongLinkFromJson(Map json) { return _SongLink.fromJson(json); @@ -30,8 +30,12 @@ mixin _$SongLink { String? get nativeAppUriMobile => throw _privateConstructorUsedError; String? get nativeAppUriDesktop => throw _privateConstructorUsedError; + /// Serializes this SongLink to a JSON map. Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + + /// Create a copy of SongLink + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) $SongLinkCopyWith get copyWith => throw _privateConstructorUsedError; } @@ -63,6 +67,8 @@ class _$SongLinkCopyWithImpl<$Res, $Val extends SongLink> // ignore: unused_field final $Res Function($Val) _then; + /// Create a copy of SongLink + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -145,6 +151,8 @@ class __$$SongLinkImplCopyWithImpl<$Res> _$SongLinkImpl _value, $Res Function(_$SongLinkImpl) _then) : super(_value, _then); + /// Create a copy of SongLink + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -261,12 +269,14 @@ class _$SongLinkImpl implements _SongLink { other.nativeAppUriDesktop == nativeAppUriDesktop)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType, displayName, linkId, platform, show, uniqueId, country, url, nativeAppUriMobile, nativeAppUriDesktop); - @JsonKey(ignore: true) + /// Create a copy of SongLink + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @override @pragma('vm:prefer-inline') _$$SongLinkImplCopyWith<_$SongLinkImpl> get copyWith => @@ -313,8 +323,11 @@ abstract class _SongLink implements SongLink { String? get nativeAppUriMobile; @override String? get nativeAppUriDesktop; + + /// Create a copy of SongLink + /// with the given fields replaced by the non-null parameter values. @override - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) _$$SongLinkImplCopyWith<_$SongLinkImpl> get copyWith => throw _privateConstructorUsedError; } diff --git a/lib/services/song_link/song_link.g.dart b/lib/services/song_link/song_link.g.dart index 911849e3..7658a74c 100644 --- a/lib/services/song_link/song_link.g.dart +++ b/lib/services/song_link/song_link.g.dart @@ -6,8 +6,7 @@ part of 'song_link.dart'; // JsonSerializableGenerator // ************************************************************************** -_$SongLinkImpl _$$SongLinkImplFromJson(Map json) => - _$SongLinkImpl( +_$SongLinkImpl _$$SongLinkImplFromJson(Map json) => _$SongLinkImpl( displayName: json['displayName'] as String, linkId: json['linkId'] as String, platform: json['platform'] as String, diff --git a/lib/services/sourced_track/models/source_info.g.dart b/lib/services/sourced_track/models/source_info.g.dart index 1ec9f75f..54671f63 100644 --- a/lib/services/sourced_track/models/source_info.g.dart +++ b/lib/services/sourced_track/models/source_info.g.dart @@ -6,13 +6,13 @@ part of 'source_info.dart'; // JsonSerializableGenerator // ************************************************************************** -SourceInfo _$SourceInfoFromJson(Map json) => SourceInfo( +SourceInfo _$SourceInfoFromJson(Map json) => SourceInfo( id: json['id'] as String, title: json['title'] as String, artist: json['artist'] as String, thumbnail: json['thumbnail'] as String, pageUrl: json['pageUrl'] as String, - duration: Duration(microseconds: json['duration'] as int), + duration: Duration(microseconds: (json['duration'] as num).toInt()), artistUrl: json['artistUrl'] as String, album: json['album'] as String?, ); diff --git a/lib/services/sourced_track/models/source_map.g.dart b/lib/services/sourced_track/models/source_map.g.dart index e1085aa8..a581cc67 100644 --- a/lib/services/sourced_track/models/source_map.g.dart +++ b/lib/services/sourced_track/models/source_map.g.dart @@ -6,8 +6,7 @@ part of 'source_map.dart'; // JsonSerializableGenerator // ************************************************************************** -SourceQualityMap _$SourceQualityMapFromJson(Map json) => - SourceQualityMap( +SourceQualityMap _$SourceQualityMapFromJson(Map json) => SourceQualityMap( high: json['high'] as String, medium: json['medium'] as String, low: json['low'] as String, @@ -20,16 +19,18 @@ Map _$SourceQualityMapToJson(SourceQualityMap instance) => 'low': instance.low, }; -SourceMap _$SourceMapFromJson(Map json) => SourceMap( +SourceMap _$SourceMapFromJson(Map json) => SourceMap( weba: json['weba'] == null ? null - : SourceQualityMap.fromJson(json['weba'] as Map), + : SourceQualityMap.fromJson( + Map.from(json['weba'] as Map)), m4a: json['m4a'] == null ? null - : SourceQualityMap.fromJson(json['m4a'] as Map), + : SourceQualityMap.fromJson( + Map.from(json['m4a'] as Map)), ); Map _$SourceMapToJson(SourceMap instance) => { - 'weba': instance.weba, - 'm4a': instance.m4a, + 'weba': instance.weba?.toJson(), + 'm4a': instance.m4a?.toJson(), }; diff --git a/lib/services/sourced_track/models/video_info.dart b/lib/services/sourced_track/models/video_info.dart index 031a8943..e3452c61 100644 --- a/lib/services/sourced_track/models/video_info.dart +++ b/lib/services/sourced_track/models/video_info.dart @@ -1,5 +1,7 @@ +import 'package:invidious/invidious.dart'; import 'package:piped_client/piped_client.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/models/database/database.dart'; + import 'package:youtube_explode_dart/youtube_explode_dart.dart'; class YoutubeVideoInfo { @@ -111,4 +113,24 @@ class YoutubeVideoInfo { channelId: stream.uploaderUrl, ); } + + factory YoutubeVideoInfo.fromSearchResponse( + InvidiousSearchResponseVideo searchResponse, + SearchMode searchMode, + ) { + return YoutubeVideoInfo( + searchMode: searchMode, + title: searchResponse.title, + duration: Duration(seconds: searchResponse.lengthSeconds), + thumbnailUrl: searchResponse.videoThumbnails.first.url, + id: searchResponse.videoId, + likes: 0, + dislikes: 0, + views: searchResponse.viewCount, + channelName: searchResponse.author, + channelId: searchResponse.authorId, + publishedAt: + DateTime.fromMillisecondsSinceEpoch(searchResponse.published * 1000), + ); + } } diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index c73f3078..bf0b22e6 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -1,21 +1,16 @@ -import 'dart:io'; - -import 'package:http/http.dart'; -import 'package:collection/collection.dart'; -import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + import 'package:spotube/services/sourced_track/enums.dart'; -import 'package:spotube/services/sourced_track/exceptions.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart'; import 'package:spotube/services/sourced_track/models/source_map.dart'; +import 'package:spotube/services/sourced_track/sources/invidious.dart'; import 'package:spotube/services/sourced_track/sources/jiosaavn.dart'; import 'package:spotube/services/sourced_track/sources/piped.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:youtube_explode_dart/youtube_explode_dart.dart'; abstract class SourcedTrack extends Track { final SourceMap source; @@ -84,15 +79,19 @@ abstract class SourcedTrack extends Track { sourceInfo: sourceInfo, track: track, ), + AudioSource.invidious => InvidiousSourcedTrack( + ref: ref, + source: source, + siblings: siblings, + sourceInfo: sourceInfo, + track: track, + ), }; } static String getSearchTerm(Track track) { - final artists = (track.artists ?? []) - .map((ar) => ar.name) - .toList() - .whereNotNull() - .toList(); + final artists = + (track.artists ?? []).map((ar) => ar.name).toList().nonNulls.toList(); final title = ServiceUtils.getTitle( track.name!, @@ -110,42 +109,21 @@ abstract class SourcedTrack extends Track { final preferences = ref.read(userPreferencesProvider); try { return switch (preferences.audioSource) { - AudioSource.piped => - await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref), AudioSource.youtube => await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref), + AudioSource.piped => + await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref), + AudioSource.invidious => + await InvidiousSourcedTrack.fetchFromTrack(track: track, ref: ref), AudioSource.jiosaavn => await JioSaavnSourcedTrack.fetchFromTrack(track: track, ref: ref), }; - } on TrackNotFoundError catch (_) { - return switch (preferences.audioSource) { - AudioSource.piped || - AudioSource.youtube => - await JioSaavnSourcedTrack.fetchFromTrack( - track: track, - ref: ref, - weakMatch: true, - ), - AudioSource.jiosaavn => - await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref), - }; - } on HttpClientClosedException catch (_) { - return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); } catch (e) { - if (e is DioException || e is ClientException || e is SocketException) { - if (preferences.audioSource == AudioSource.jiosaavn) { - return await JioSaavnSourcedTrack.fetchFromTrack( - track: track, - ref: ref, - weakMatch: true, - ); - } - return await JioSaavnSourcedTrack.fetchFromTrack( - track: track, - ref: ref, - ); + if (preferences.audioSource == AudioSource.youtube) { + rethrow; } - rethrow; + + return await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref); } } @@ -162,6 +140,8 @@ abstract class SourcedTrack extends Track { YoutubeSourcedTrack.fetchSiblings(track: track, ref: ref), AudioSource.jiosaavn => JioSaavnSourcedTrack.fetchSiblings(track: track, ref: ref), + AudioSource.invidious => + InvidiousSourcedTrack.fetchSiblings(track: track, ref: ref), }; } diff --git a/lib/services/sourced_track/sources/invidious.dart b/lib/services/sourced_track/sources/invidious.dart new file mode 100644 index 00000000..4a32ad41 --- /dev/null +++ b/lib/services/sourced_track/sources/invidious.dart @@ -0,0 +1,282 @@ +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; +import 'package:spotube/services/sourced_track/exceptions.dart'; +import 'package:spotube/services/sourced_track/models/source_info.dart'; +import 'package:spotube/services/sourced_track/models/source_map.dart'; +import 'package:spotube/services/sourced_track/models/video_info.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:invidious/invidious.dart'; +import 'package:spotube/services/sourced_track/sources/youtube.dart'; +import 'package:spotube/utils/service_utils.dart'; + +final invidiousProvider = Provider( + (ref) { + final invidiousInstance = ref.watch( + userPreferencesProvider.select((s) => s.invidiousInstance), + ); + return InvidiousClient(server: invidiousInstance); + }, +); + +class InvidiousSourceInfo extends SourceInfo { + InvidiousSourceInfo({ + required super.id, + required super.title, + required super.artist, + required super.thumbnail, + required super.pageUrl, + required super.duration, + required super.artistUrl, + required super.album, + }); +} + +class InvidiousSourcedTrack extends SourcedTrack { + InvidiousSourcedTrack({ + required super.ref, + required super.source, + required super.siblings, + required super.sourceInfo, + required super.track, + }); + + static Future fetchFromTrack({ + required Track track, + required Ref ref, + }) async { + // Indicates a stream url refresh + if (track is InvidiousSourcedTrack) { + final manifest = await ref + .read(invidiousProvider) + .videos + .get(track.sourceInfo.id, local: true); + + return InvidiousSourcedTrack( + ref: ref, + siblings: track.siblings, + source: toSourceMap(manifest), + sourceInfo: track.sourceInfo, + track: track, + ); + } + + final database = ref.read(databaseProvider); + final cachedSource = await (database.select(database.sourceMatchTable) + ..where((s) => s.trackId.equals(track.id!)) + ..limit(1) + ..orderBy([ + (s) => + OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc), + ])) + .getSingleOrNull(); + final invidiousClient = ref.read(invidiousProvider); + + if (cachedSource == null) { + final siblings = await fetchSiblings(ref: ref, track: track); + if (siblings.isEmpty) { + throw TrackNotFoundError(track); + } + + await database.into(database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: track.id!, + sourceId: siblings.first.info.id, + sourceType: const Value(SourceType.youtube), + ), + ); + + return InvidiousSourcedTrack( + ref: ref, + siblings: siblings.map((s) => s.info).skip(1).toList(), + source: siblings.first.source as SourceMap, + sourceInfo: siblings.first.info, + track: track, + ); + } else { + final manifest = + await invidiousClient.videos.get(cachedSource.sourceId, local: true); + + return InvidiousSourcedTrack( + ref: ref, + siblings: [], + source: toSourceMap(manifest), + sourceInfo: InvidiousSourceInfo( + id: manifest.videoId, + artist: manifest.author, + artistUrl: manifest.authorUrl, + pageUrl: "https://www.youtube.com/watch?v=${manifest.videoId}", + thumbnail: manifest.videoThumbnails.first.url, + title: manifest.title, + duration: Duration(seconds: manifest.lengthSeconds), + album: null, + ), + track: track, + ); + } + } + + static SourceMap toSourceMap(InvidiousVideoResponse manifest) { + final m4a = manifest.adaptiveFormats + .where((audio) => audio.type.contains("audio/mp4")) + .sorted((a, b) => int.parse(a.bitrate).compareTo(int.parse(b.bitrate))); + + final weba = manifest.adaptiveFormats + .where((audio) => audio.type.contains("audio/webm")) + .sorted((a, b) => int.parse(a.bitrate).compareTo(int.parse(b.bitrate))); + + return SourceMap( + m4a: SourceQualityMap( + high: m4a.first.url.toString(), + medium: (m4a.elementAtOrNull(m4a.length ~/ 2) ?? m4a[1]).url.toString(), + low: m4a.last.url.toString(), + ), + weba: SourceQualityMap( + high: weba.first.url.toString(), + medium: + (weba.elementAtOrNull(weba.length ~/ 2) ?? weba[1]).url.toString(), + low: weba.last.url.toString(), + ), + ); + } + + static Future toSiblingType( + int index, + YoutubeVideoInfo item, + InvidiousClient invidiousClient, + ) async { + SourceMap? sourceMap; + if (index == 0) { + final manifest = await invidiousClient.videos.get(item.id, local: true); + sourceMap = toSourceMap(manifest); + } + + final SiblingType sibling = ( + info: InvidiousSourceInfo( + id: item.id, + artist: item.channelName, + artistUrl: "https://www.youtube.com/${item.channelId}", + pageUrl: "https://www.youtube.com/watch?v=${item.id}", + thumbnail: item.thumbnailUrl, + title: item.title, + duration: item.duration, + album: null, + ), + source: sourceMap, + ); + + return sibling; + } + + static Future> fetchSiblings({ + required Track track, + required Ref ref, + }) async { + final invidiousClient = ref.read(invidiousProvider); + final preference = ref.read(userPreferencesProvider); + + final query = SourcedTrack.getSearchTerm(track); + + final searchResults = await invidiousClient.search.list( + query, + type: InvidiousSearchType.video, + ); + + if (ServiceUtils.onlyContainsEnglish(query)) { + return await Future.wait( + searchResults + .whereType() + .map( + (result) => YoutubeVideoInfo.fromSearchResponse( + result, + preference.searchMode, + ), + ) + .mapIndexed((i, r) => toSiblingType(i, r, invidiousClient)), + ); + } + + final rankedSiblings = YoutubeSourcedTrack.rankResults( + searchResults + .whereType() + .map( + (result) => YoutubeVideoInfo.fromSearchResponse( + result, + preference.searchMode, + ), + ) + .toList(), + track, + ); + + return await Future.wait( + rankedSiblings.mapIndexed((i, r) => toSiblingType(i, r, invidiousClient)), + ); + } + + @override + Future copyWithSibling() async { + if (siblings.isNotEmpty) { + return this; + } + final fetchedSiblings = await fetchSiblings(ref: ref, track: this); + + return InvidiousSourcedTrack( + ref: ref, + siblings: fetchedSiblings + .where((s) => s.info.id != sourceInfo.id) + .map((s) => s.info) + .toList(), + source: source, + sourceInfo: sourceInfo, + track: this, + ); + } + + @override + Future swapWithSibling(SourceInfo sibling) async { + if (sibling.id == sourceInfo.id) { + return null; + } + + // 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, sourceInfo); + + final pipedClient = ref.read(invidiousProvider); + + final manifest = + await pipedClient.videos.get(newSourceInfo.id, local: true); + + final database = ref.read(databaseProvider); + await database.into(database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: id!, + sourceId: newSourceInfo.id, + sourceType: const Value(SourceType.youtube), + // Because we're sorting by createdAt in the query + // we have to update it to indicate priority + createdAt: Value(DateTime.now()), + ), + mode: InsertMode.replace, + ); + + return InvidiousSourcedTrack( + ref: ref, + siblings: newSiblings, + source: toSourceMap(manifest), + sourceInfo: newSourceInfo, + track: this, + ); + } +} diff --git a/lib/services/sourced_track/sources/jiosaavn.dart b/lib/services/sourced_track/sources/jiosaavn.dart index f731de6c..1434e4f7 100644 --- a/lib/services/sourced_track/sources/jiosaavn.dart +++ b/lib/services/sourced_track/sources/jiosaavn.dart @@ -1,7 +1,9 @@ import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/models/source_match.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/exceptions.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart'; @@ -39,7 +41,15 @@ class JioSaavnSourcedTrack extends SourcedTrack { required Ref ref, bool weakMatch = false, }) async { - final cachedSource = await SourceMatch.box.get(track.id); + final database = ref.read(databaseProvider); + final cachedSource = await (database.select(database.sourceMatchTable) + ..where((s) => s.trackId.equals(track.id!)) + ..limit(1) + ..orderBy([ + (s) => + OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc), + ])) + .getSingleOrNull(); if (cachedSource == null || cachedSource.sourceType != SourceType.jiosaavn) { @@ -50,15 +60,13 @@ class JioSaavnSourcedTrack extends SourcedTrack { throw TrackNotFoundError(track); } - await SourceMatch.box.put( - track.id!, - SourceMatch( - id: track.id!, - sourceType: SourceType.jiosaavn, - createdAt: DateTime.now(), - sourceId: siblings.first.info.id, - ), - ); + await database.into(database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: track.id!, + sourceId: siblings.first.info.id, + sourceType: const Value(SourceType.jiosaavn), + ), + ); return JioSaavnSourcedTrack( ref: ref, @@ -206,15 +214,18 @@ class JioSaavnSourcedTrack extends SourcedTrack { final (:info, :source) = toSiblingType(item); - await SourceMatch.box.put( - id!, - SourceMatch( - id: id!, - sourceType: SourceType.jiosaavn, - createdAt: DateTime.now(), - sourceId: info.id, - ), - ); + final database = ref.read(databaseProvider); + await database.into(database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: id!, + sourceId: info.id, + sourceType: const Value(SourceType.jiosaavn), + // Because we're sorting by createdAt in the query + // we have to update it to indicate priority + createdAt: Value(DateTime.now()), + ), + mode: InsertMode.replace, + ); return JioSaavnSourcedTrack( ref: ref, diff --git a/lib/services/sourced_track/sources/piped.dart b/lib/services/sourced_track/sources/piped.dart index 75f83125..7ab9df44 100644 --- a/lib/services/sourced_track/sources/piped.dart +++ b/lib/services/sourced_track/sources/piped.dart @@ -1,10 +1,12 @@ import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:piped_client/piped_client.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/models/source_match.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/exceptions.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart'; @@ -48,7 +50,28 @@ class PipedSourcedTrack extends SourcedTrack { required Track track, required Ref ref, }) async { - final cachedSource = await SourceMatch.box.get(track.id); + // Means it wants a refresh of the stream + if (track is PipedSourcedTrack) { + final manifest = + await ref.read(pipedProvider).streams(track.sourceInfo.id); + return PipedSourcedTrack( + ref: ref, + siblings: track.siblings, + sourceInfo: track.sourceInfo, + source: toSourceMap(manifest), + track: track, + ); + } + + final database = ref.read(databaseProvider); + final cachedSource = await (database.select(database.sourceMatchTable) + ..where((s) => s.trackId.equals(track.id!)) + ..limit(1) + ..orderBy([ + (s) => + OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc), + ])) + .getSingleOrNull(); final preferences = ref.read(userPreferencesProvider); final pipedClient = ref.read(pipedProvider); @@ -58,17 +81,17 @@ class PipedSourcedTrack extends SourcedTrack { throw TrackNotFoundError(track); } - await SourceMatch.box.put( - track.id!, - SourceMatch( - id: track.id!, - sourceType: preferences.searchMode == SearchMode.youtube - ? SourceType.youtube - : SourceType.youtubeMusic, - createdAt: DateTime.now(), - sourceId: siblings.first.info.id, - ), - ); + await database.into(database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: track.id!, + sourceId: siblings.first.info.id, + sourceType: Value( + preferences.searchMode == SearchMode.youtube + ? SourceType.youtube + : SourceType.youtubeMusic, + ), + ), + ); return PipedSourcedTrack( ref: ref, @@ -173,11 +196,8 @@ class PipedSourcedTrack extends SourcedTrack { : preference.searchMode == SearchMode.youtubeMusic; if (isYouTubeMusic) { - final artists = (track.artists ?? []) - .map((ar) => ar.name) - .toList() - .whereNotNull() - .toList(); + final artists = + (track.artists ?? []).map((ar) => ar.name).toList().nonNulls.toList(); return await Future.wait( searchResults @@ -267,15 +287,18 @@ class PipedSourcedTrack extends SourcedTrack { final manifest = await pipedClient.streams(newSourceInfo.id); - await SourceMatch.box.put( - id!, - SourceMatch( - id: id!, - sourceType: SourceType.jiosaavn, - createdAt: DateTime.now(), - sourceId: newSourceInfo.id, - ), - ); + final database = ref.read(databaseProvider); + await database.into(database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: id!, + sourceId: newSourceInfo.id, + sourceType: const Value(SourceType.youtube), + // Because we're sorting by createdAt in the query + // we have to update it to indicate priority + createdAt: Value(DateTime.now()), + ), + mode: InsertMode.replace, + ); return PipedSourcedTrack( ref: ref, diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart index 3fc78f0b..193bdc0d 100644 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -1,8 +1,11 @@ import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:http/http.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/models/source_match.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/provider/youtube_engine/youtube_engine.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/song_link/song_link.dart'; import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/exceptions.dart'; @@ -13,7 +16,6 @@ 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 youtubeClient = YoutubeExplode(); final officialMusicRegex = RegExp( r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)", caseSensitive: false, @@ -45,7 +47,35 @@ class YoutubeSourcedTrack extends SourcedTrack { required Track track, required Ref ref, }) async { - final cachedSource = await SourceMatch.box.get(track.id); + // Indicates the track is requesting a stream refresh + if (track is YoutubeSourcedTrack) { + final manifest = await ref + .read(youtubeEngineProvider) + .getStreamManifest(track.sourceInfo.id); + + final sourcedTrack = YoutubeSourcedTrack( + ref: ref, + siblings: track.siblings, + source: toSourceMap(manifest), + sourceInfo: track.sourceInfo, + track: track, + ); + + AppLogger.log.i("Refreshing ${track.name}: ${sourcedTrack.url}"); + + return sourcedTrack; + } + + final database = ref.read(databaseProvider); + final cachedSource = await (database.select(database.sourceMatchTable) + ..where((s) => s.trackId.equals(track.id!)) + ..limit(1) + ..orderBy([ + (s) => + OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc), + ])) + .get() + .then((s) => s.firstOrNull); if (cachedSource == null || cachedSource.sourceType != SourceType.youtube) { final siblings = await fetchSiblings(ref: ref, track: track); @@ -53,15 +83,13 @@ class YoutubeSourcedTrack extends SourcedTrack { throw TrackNotFoundError(track); } - await SourceMatch.box.put( - track.id!, - SourceMatch( - id: track.id!, - sourceType: SourceType.youtube, - createdAt: DateTime.now(), - sourceId: siblings.first.info.id, - ), - ); + await database.into(database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: track.id!, + sourceId: siblings.first.info.id, + sourceType: const Value(SourceType.youtube), + ), + ); return YoutubeSourcedTrack( ref: ref, @@ -71,16 +99,11 @@ class YoutubeSourcedTrack extends SourcedTrack { track: track, ); } - final item = await youtubeClient.videos.get(cachedSource.sourceId); - final manifest = await youtubeClient.videos.streamsClient - .getManifest( - cachedSource.sourceId, - ) - .timeout( - const Duration(seconds: 5), - onTimeout: () => throw ClientException("Timeout"), - ); - return YoutubeSourcedTrack( + final (item, manifest) = await ref + .read(youtubeEngineProvider) + .getVideoWithStreamInfo(cachedSource.sourceId); + + final sourcedTrack = YoutubeSourcedTrack( ref: ref, siblings: [], source: toSourceMap(manifest), @@ -96,6 +119,10 @@ class YoutubeSourcedTrack extends SourcedTrack { ), track: track, ); + + AppLogger.log.i("${track.name}: ${sourcedTrack.url}"); + + return sourcedTrack; } static SourceMap toSourceMap(StreamManifest manifest) { @@ -128,14 +155,13 @@ class YoutubeSourcedTrack extends SourcedTrack { static Future toSiblingType( int index, YoutubeVideoInfo item, + dynamic ref, ) async { + assert(ref is WidgetRef || ref is Ref, "Invalid ref type"); SourceMap? sourceMap; if (index == 0) { final manifest = - await youtubeClient.videos.streamsClient.getManifest(item.id).timeout( - const Duration(seconds: 5), - onTimeout: () => throw ClientException("Timeout"), - ); + await ref.read(youtubeEngineProvider).getStreamManifest(item.id); sourceMap = toSourceMap(manifest); } @@ -158,11 +184,8 @@ class YoutubeSourcedTrack extends SourcedTrack { static List rankResults( List results, Track track) { - final artists = (track.artists ?? []) - .map((ar) => ar.name) - .toList() - .whereNotNull() - .toList(); + final artists = + (track.artists ?? []).map((ar) => ar.name).toList().nonNulls.toList(); return results .sorted((a, b) => b.views.compareTo(a.views)) @@ -213,43 +236,105 @@ class YoutubeSourcedTrack extends SourcedTrack { .toList(); } + static Future> fetchFromIsrc({ + required Track track, + required Ref ref, + }) async { + final isrcResults = []; + final isrc = track.externalIds?.isrc; + if (isrc != null && isrc.isNotEmpty) { + final searchedVideos = + await ref.read(youtubeEngineProvider).searchVideos(isrc.toString()); + if (searchedVideos.isNotEmpty) { + isrcResults.addAll(searchedVideos + .map(YoutubeVideoInfo.fromVideo) + .map((YoutubeVideoInfo videoInfo) { + final ytWords = videoInfo.title + .toLowerCase() + .replaceAll(RegExp(r'[^\p{L}\p{N}\p{Z}]+', unicode: true), '') + .split(RegExp(r'\p{Z}+', unicode: true)) + .where((item) => item.isNotEmpty); + final spWords = track.name! + .toLowerCase() + .replaceAll(RegExp(r'[^\p{L}\p{N}\p{Z}]+', unicode: true), '') + .split(RegExp(r'\p{Z}+', unicode: true)) + .where((item) => item.isNotEmpty); + // Single word and duration match with 3 second tolerance + if (ytWords.any((word) => spWords.contains(word)) && + (videoInfo.duration - track.duration!) + .abs().inMilliseconds <= 3000) { + return videoInfo; + } + return null; + }) + .whereType() + .toList()); + } + } + return isrcResults; + } + static Future> fetchSiblings({ required Track track, required Ref ref, }) async { - final links = await SongLinkService.links(track.id!); - final ytLink = links.firstWhereOrNull((link) => link.platform == "youtube"); + final videoResults = []; - if (ytLink?.url != null) { - return [ - await toSiblingType( - 0, - YoutubeVideoInfo.fromVideo( - await youtubeClient.videos.get(ytLink!.url!), - ), - ) - ]; + if (track is! SourcedTrack) { + final isrcResults = await fetchFromIsrc( + track: track, + ref: ref, + ); + + videoResults.addAll(isrcResults); + + if (isrcResults.isEmpty) { + final links = await SongLinkService.links(track.id!); + final ytLink = links.firstWhereOrNull( + (link) => link.platform == "youtube", + ); + if (ytLink?.url != null) { + try { + videoResults.add( + YoutubeVideoInfo.fromVideo(await ref + .read(youtubeEngineProvider) + .getVideo(Uri.parse(ytLink!.url!).queryParameters["v"]!)), + ); + } on VideoUnplayableException catch (e, stack) { + // Ignore this error and continue with the search + AppLogger.reportError(e, stack); + } + } + } } final query = SourcedTrack.getSearchTerm(track); - final searchResults = await youtubeClient.search.search( - "$query - Topic", - filter: TypeFilters.video, - ); + final searchResults = + await ref.read(youtubeEngineProvider).searchVideos(query); if (ServiceUtils.onlyContainsEnglish(query)) { - return await Future.wait(searchResults - .map(YoutubeVideoInfo.fromVideo) - .mapIndexed(toSiblingType)); + videoResults + .addAll(searchResults.map(YoutubeVideoInfo.fromVideo).toList()); + } else { + videoResults.addAll(rankResults( + searchResults.map(YoutubeVideoInfo.fromVideo).toList(), + track, + )); } - final rankedSiblings = rankResults( - searchResults.map(YoutubeVideoInfo.fromVideo).toList(), - track, - ); - - return await Future.wait(rankedSiblings.mapIndexed(toSiblingType)); + final seenIds = {}; + int index = 0; + return await Future.wait( + videoResults.map((videoResult) async { + // Deduplicate results + if (!seenIds.contains(videoResult.id)) { + seenIds.add(videoResult.id); + return await toSiblingType(index++, videoResult, ref); + } + return null; + }), + ).then((s) => s.whereType().toList()); } @override @@ -267,22 +352,23 @@ class YoutubeSourcedTrack extends SourcedTrack { final newSiblings = siblings.where((s) => s.id != sibling.id).toList() ..insert(0, sourceInfo); - final manifest = await youtubeClient.videos.streamsClient - .getManifest(newSourceInfo.id) - .timeout( - const Duration(seconds: 5), - onTimeout: () => throw ClientException("Timeout"), - ); + final manifest = await ref + .read(youtubeEngineProvider) + .getStreamManifest(newSourceInfo.id); - await SourceMatch.box.put( - id!, - SourceMatch( - id: id!, - sourceType: SourceType.jiosaavn, - createdAt: DateTime.now(), - sourceId: newSourceInfo.id, - ), - ); + final database = ref.read(databaseProvider); + + await database.into(database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: id!, + sourceId: newSourceInfo.id, + sourceType: const Value(SourceType.youtube), + // Because we're sorting by createdAt in the query + // we have to update it to indicate priority + createdAt: Value(DateTime.now()), + ), + mode: InsertMode.replace, + ); return YoutubeSourcedTrack( ref: ref, diff --git a/lib/services/wm_tools/wm_tools.dart b/lib/services/wm_tools/wm_tools.dart new file mode 100644 index 00000000..f60b4ac9 --- /dev/null +++ b/lib/services/wm_tools/wm_tools.dart @@ -0,0 +1,89 @@ +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 new file mode 100644 index 00000000..f58fc333 --- /dev/null +++ b/lib/services/youtube_engine/newpipe_engine.dart @@ -0,0 +1,109 @@ +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; + + 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, + stream.quality, + [], + 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)); + + return StreamManifest(streams); + } + + @override + Future