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..6a88cb99 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,8 @@ ENABLE_UPDATE_CHECK= LASTFM_API_KEY= LASTFM_API_SECRET= + +# Release channel. Can be: nightly, stable +RELEASE_CHANNEL= + +HIDE_DONATIONS= diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index 7ca74200..305f34df 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,3 @@ { - "flutterSdkVersion": "3.19.1", - "flavors": {} + "flutterSdkVersion": "3.24.3" } \ No newline at end of file diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 00000000..c62692b4 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,4 @@ +{ + "flutter": "3.24.3", + "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..a9c57836 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -7,8 +7,12 @@ 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,23 +20,41 @@ 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 @@ -53,7 +75,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 +96,7 @@ 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! 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..db158029 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -4,13 +4,15 @@ on: pull_request: env: - FLUTTER_VERSION: '3.16.0' + FLUTTER_VERSION: 3.22.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 }} diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 12a2f99b..3a456bda 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: 3.8.3 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: @@ -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 adb99003..d059a067 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -2,279 +2,126 @@ 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.24.3 + +permissions: + contents: write jobs: - windows: - runs-on: windows-latest + build_platform: + strategy: + matrix: + include: + - os: ubuntu-latest + platform: linux + files: | + dist/Spotube-linux-x86_64.deb + dist/Spotube-linux-x86_64.rpm + dist/spotube-linux-*-x86_64.tar.xz + - os: ubuntu-latest + platform: linux_arm + files: | + dist/Spotube-linux-aarch64.deb + dist/spotube-linux-*-aarch64.tar.xz + - os: ubuntu-latest + platform: android + files: | + build/Spotube-android-all-arch.apk + build/Spotube-playstore-all-arch.aab + - os: windows-latest + platform: windows + files: | + dist/Spotube-windows-x86_64.nupkg + dist/Spotube-windows-x86_64-setup.exe + - os: macos-latest + platform: ios + files: | + Spotube-iOS.ipa + - os: macos-14 + platform: macos + 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 with: cache: true + cache-key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.yaml') }} 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 + - 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-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 + distribution: 'zulu' + java-version: '17' + cache: 'gradle' + check-latest: true + - name: Set up QEMU + if: ${{matrix.platform == 'linux_arm'}} + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + if: ${{matrix.platform == 'linux_arm'}} + uses: docker/setup-buildx-action@v3 + - name: Setup Rust toolchain + if: ${{matrix.platform != 'linux_arm'}} + uses: dtolnay/rust-toolchain@stable with: - limit-access-to-actor: true + toolchain: stable - linux: - 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: 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 == 'release' }} - 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' }} - 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 - - - name: Debug With SSH When fails - if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} - uses: mxschmitt/action-tmate@v3 - with: - limit-access-to-actor: true - - - 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 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 - - + + - name: Unessary hosted tools + if: ${{matrix.platform == 'linux_arm'}} + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + swap-storage: false + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: true + + - name: Build ${{matrix.platform}} binaries + run: dart cli/cli.dart build ${{matrix.platform}} + env: + CHANNEL: ${{inputs.channel}} + DOTENV: ${{secrets.DOTENV_RELEASE}} + - uses: actions/upload-artifact@v3 with: if-no-files-found: error name: Spotube-Release-Binaries - path: | - build/Spotube-android-all-arch.apk - build/Spotube-playstore-all-arch.aab + path: ${{matrix.files}} - name: Debug With SSH When fails if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} @@ -282,136 +129,12 @@ 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 - needs: - - windows - - linux - - android - - macos - - iOS + - build_platform steps: + - uses: actions/checkout@v4 - uses: actions/download-artifact@v3 with: name: Spotube-Release-Binaries @@ -426,6 +149,10 @@ jobs: md5sum Spotube-Release-Binaries/* >> RELEASE.md5sum sha256sum Spotube-Release-Binaries/* >> RELEASE.sha256sum sed -i 's|Spotube-Release-Binaries/||' RELEASE.sha256sum RELEASE.md5sum + + - name: Extract pubspec version + run: | + echo "PUBSPEC_VERSION=$(grep -oP 'version:\s*\K[^+]+(?=\+)' pubspec.yaml)" >> $GITHUB_ENV - uses: actions/upload-artifact@v3 with: @@ -435,17 +162,12 @@ jobs: RELEASE.md5sum RELEASE.sha256sum - - name: Debug With SSH - uses: mxschmitt/action-tmate@v3 - with: - limit-access-to-actor: true - - name: Upload Release Binaries (stable) if: ${{ !inputs.dry_run && inputs.channel == 'stable' }} 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 @@ -463,3 +185,8 @@ jobs: omitPrereleaseDuringUpdate: true allowUpdates: true 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 diff --git a/.gitignore b/.gitignore index 96d81087..f9bd15f8 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,10 @@ dist appimage-build android/key.properties -.fvm/flutter_sdk **/pb_data + +tm.json + +# FVM Version Cache +.fvm/ \ No newline at end of file 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/settings.json b/.vscode/settings.json index 0e6a4294..11fae610 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,13 +2,22 @@ "cmake.configureOnOpen": false, "cSpell.words": [ "acousticness", + "ambiguate", + "Amoled", + "Buildless", "danceability", + "fuzzywuzzy", + "gapless", "instrumentalness", "Mpris", + "RGBO", "riverpod", "Scrobblenaut", + "skeletonizer", + "songlink", "speechiness", "Spotube", + "titlebar", "winget" ], "editor.formatOnSave": true, @@ -16,5 +25,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/flutter_sdk" } \ 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 8f48b39e..11b06ed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,191 @@ 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. +## [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)) + + +### 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)) + ## [3.4.1](https://personal.github.com/krtirtho/spotube/compare/v3.4.0...v3.4.1) (2024-01-27) 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/README.md b/README.md index 469d03ac..71c879ba 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ eliminating the need for Spotify Premium Btw it's not just another Electron app 😉 -Visit the website +Visit the website Discord Server Support me on Patron @@ -97,12 +97,7 @@ This handy table lists all the methods you can use to install Spotube: AppImage - - - Download AppImage - -

Note: AppimageLauncher is required!

- + AppImage's lacking stability led to it's temporal removal. More information at https://github.com/KRTirtho/spotube/issues/1082 Debian/Ubuntu @@ -204,6 +199,8 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 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. [LRCLib](https://lrclib.net/) - A public synced lyric API 1. [Linux](https://www.linux.org) - Linux is a family of open-source Unix-like operating systems based on the Linux kernel, an operating system kernel first released on September 17, 1991, by Linus Torvalds. Linux is typically packaged in a Linux distribution 1. [AUR](https://aur.archlinux.org) - AUR stands for Arch User Repository. It is a community-driven repository for Arch-based Linux distributions users 1. [Flatpak](https://flatpak.org) - Flatpak is a utility for software deployment and package management for Linux @@ -213,106 +210,116 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [LastFM](https://last.fm) - Last.fm is a music streaming and discovery platform that helps users discover and share new music. It tracks users' music listening habits across many devices and platforms. ### Dependencies +1. [app_links](https://github.com/llfbandit/app_links) - Android App Links, Deep Links, iOs Universal Links and Custom URL schemes handler for Flutter (desktop included). 1. [args](https://pub.dev/packages/args) - Library for defining parsers for parsing raw command-line arguments into a set of options and values using GNU and POSIX style options. 1. [async](https://pub.dev/packages/async) - Utility functions and classes related to the 'dart:async' library. +1. [audio_service_mpris](https://github.com/bdrazhzhov/audio-service-mpris) - audio_service platform interface supporting Media Player Remote Interfacing Specification. 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. [bonsoir](https://bonsoir.skyost.eu) - A Zeroconf library that allows you to discover network services and to broadcast your own. Based on Apple Bonjour and Android NSD. +1. [build_runner](https://pub.dev/packages/build_runner) - A build system for Dart code generation and modular compilation. 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. [crypto](https://pub.dev/packages/crypto) - Implementations of SHA, MD5, and HMAC cryptographic functions. 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. [custom_lint](https://pub.dev/packages/custom_lint) - Lint rules are a powerful way to improve the maintainability of a project. Custom Lint allows package authors and developers to easily write custom lint rules. +1. [dart_discord_rpc](https://github.com/alexmercerind/dart_discord_rpc) - Discord Rich Presence for Flutter & Dart apps & games. 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. [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. [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_generator](https://github.com/petercinibulk/envied) - Generator for the Envied package. See https://pub.dev/packages/envied. 1. [envied](https://github.com/petercinibulk/envied) - Explicitly reads environment variables into a dart file from a .env file for more security and faster start up times. +1. [file_picker](https://github.com/miguelpruivo/plugins_flutter_file_picker) - A package that allows you to use a native file explorer to pick single or multiple absolute file paths, with extension filtering support. 1. [file_selector](https://pub.dev/packages/file_selector) - Flutter plugin for opening and saving files, or selecting directories, using native file selection UI. -1. [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_broadcasts](https://pub.dev/packages/flutter_broadcasts) - A plugin for sending and receiving broadcasts with Android intents and iOS notifications. 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_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_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_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. [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 simple way to access state from anywhere in your application while robust and testable. +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_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_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. [freezed_annotation](https://pub.dev/packages/freezed_annotation) - Annotations for the freezed code-generator. This package does nothing without freezed too. +1. [freezed](https://pub.dev/packages/freezed) - Code generation for immutable classes that has a simple syntax/API without compromising on the features. 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. [gap](https://github.com/letsar/gap) - Flutter widgets for easily adding gaps inside Flex widgets such as Columns and Rows or scrolling views. 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 simple way to access state from anywhere in your application while robust and testable. +1. [hive_generator](https://github.com/hivedb/hive/tree/master/hive_generator) - Extension for Hive. Automatically generates TypeAdapters to store any class. +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. [hooks_riverpod](https://riverpod.dev) - A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze. +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. [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. [introduction_screen](https://pub.dev/packages/introduction_screen) - Introduction/Onboarding package for flutter app with some customizations possibilities +1. [io](https://pub.dev/packages/io) - Utilities for the Dart VM Runtime including support for ANSI colors, file copying, and standard exit code values. +1. [jiosaavn](https://github.com/KRTirtho/jiosaavn) - Unofficial API client for jiosaavn.com 1. [json_annotation](https://pub.dev/packages/json_annotation) - Classes and helper functions that support JSON code generation via the `json_serializable` package. +1. [json_serializable](https://pub.dev/packages/json_serializable) - Automatically generate code for converting to and from JSON by annotating Dart classes. +1. [local_notifier](https://github.com/leanflutter/local_notifier) - This plugin allows Flutter desktop apps to displaying local notifications. 1. [logger](https://pub.dev/packages/logger) - Small, easy to use and extensible logger which prints beautiful logs. -1. [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. [lrc](https://pub.dev/packages/lrc) - A Dart-only package that creates, parses, and handles LRC, which is a format that stores song lyrics. 1. [media_kit_libs_audio](https://github.com/media-kit/media-kit.git) - package:media_kit audio (only) playback native libraries for all platforms. +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. [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. [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. [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. [piped_client](https://github.com/KRTirtho/piped_client) - API Client for piped.video 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. [process_run](https://github.com/tekartik/process_run.dart/blob/master/packages/process_run) - Process run helpers for Linux/Win/Mac and which like feature for finding executables. +1. [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. [riverpod_lint](https://riverpod.dev) - Riverpod_lint is a developer tool for users of Riverpod, designed to help stop common issues and simplify repetitive tasks. +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. [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. [shelf_router](https://pub.dev/packages/shelf_router) - A convenient request router for the shelf web-framework, with support for URL-parameters, nested routers and routers generated from source annotations. +1. [shelf_web_socket](https://pub.dev/packages/shelf_web_socket) - A shelf handler that wires up a listener for every connection. +1. [shelf](https://pub.dev/packages/shelf) - A model for web server middleware that encourages composition and easy reuse. +1. [sidebarx](https://github.com/Frezyx/sidebarx) - flutter multiplatform navigation sidebar / side navigationbar / drawer widget +1. [simple_icons](https://teavelopment.com/) - The Simple Icon pack available as Flutter Icons. Provides over 1500 Free SVG icons for popular brands. 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. [skeletonizer](https://github.com/Milad-Akarie/skeletonizer) - Converts already built widgets into skeleton loaders with no extra effort. +1. [sliver_tools](https://github.com/Kavantix) - A set of useful sliver tools that are missing from the flutter framework 1. [smtc_windows](https://github.com/KRTirtho/smtc_windows) - Windows `SystemMediaTransportControls` implementation for Flutter giving access to Windows OS Media Control applet. 1. [spotify](https://github.com/rinukkusu/spotify-dart) - An incomplete dart library for interfacing with the Spotify Web API. 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. [timezone](https://pub.dev/packages/timezone) - Time zone database and time zone aware DateTime. 1. [titlebar_buttons](https://github.com/gtk-flutter/titlebar_buttons) - A package which provides most of the titlebar buttons from windows, linux and macos. +1. [tray_manager](https://github.com/leanflutter/tray_manager) - This plugin allows Flutter desktop apps to defines system tray. 1. [url_launcher](https://pub.dev/packages/url_launcher) - Flutter plugin for launching a URL. Supports web, phone, SMS, and email schemes. 1. [uuid](https://pub.dev/packages/uuid) - RFC4122 (v1, v4, v5, v6, v7, v8) UUID Generator and Parser for Dart 1. [version](https://github.com/dartninja/version) - Provides a simple class for parsing and comparing semantic versions as defined by http://semver.org/ -1. [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. [visibility_detector](https://pub.dev/packages/visibility_detector) - A widget that detects the visibility of its child and notifies a callback. +1. [web_socket_channel](https://pub.dev/packages/web_socket_channel) - StreamChannel wrappers for WebSockets. Provides a cross-platform WebSocketChannel API, a cross-platform implementation of that API that communicates over an underlying StreamChannel. 1. [wikipedia_api](https://github.com/KRTirtho/wikipedia_api) - Wikipedia API for dart and flutter -1. [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. [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. [flutter_desktop_tools](https://github.com/KRTirtho/flutter_desktop_tools) - Essential collection of tools for flutter desktop app development -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. +1. [win32_registry](https://pub.dev/packages/win32_registry) - A package that provides a friendly Dart API for accessing the Windows Registry. +1. [window_manager](https://github.com/leanflutter/window_manager) - This plugin allows Flutter desktop apps to resizing and repositioning the window. +1. [xml](https://github.com/renggli/dart-xml) - A lightweight library for parsing, traversing, querying, transforming and building XML documents. +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.

© Copyright Spotube 2024

diff --git a/analysis_options.yaml b/analysis_options.yaml index 5f2cbbe1..d5b904cc 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -25,12 +25,17 @@ 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 + plugins: + - custom_lint + exclude: + - "**.freezed.dart" + - "**.g.dart" + - "**.gr.dart" + - "**/generated_plugin_registrant.dart" diff --git a/android/app/build.gradle b/android/app/build.gradle index 2f85cdeb..8ec1872e 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,10 +22,6 @@ 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()) { @@ -34,7 +31,7 @@ if (keystorePropertiesFile.exists()) { android { compileSdkVersion 34 - ndkVersion "21.4.7075529" + ndkVersion "25.1.8937393" compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 @@ -50,10 +47,9 @@ android { } 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 34 versionCode flutterVersionCode.toInteger() versionName flutterVersionName multiDexEnabled true @@ -71,6 +67,9 @@ android { release { signingConfig signingConfigs.release } + debug { + signingConfig signingConfigs.release + } } flavorDimensions "default" @@ -81,16 +80,19 @@ 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 } } @@ -101,15 +103,6 @@ flutter { } 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' // other deps so just ignore diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5ab7a0b5..64c32e28 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + @@ -24,6 +25,11 @@ android:usesCleartextTraffic="true" android:requestLegacyExternalStorage="true" > + + + 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 "7.2.1" apply false + id "org.jetbrains.kotlin.android" version "1.8.22" apply false +} + +include ":app" \ No newline at end of file diff --git a/assets/spotube-logo.bmp b/assets/spotube-logo.bmp new file mode 100644 index 00000000..c3503e85 Binary files /dev/null and b/assets/spotube-logo.bmp differ diff --git a/aur-struct/.SRCINFO b/aur-struct/.SRCINFO index ae0b6d10..4c07a045 100644 --- a/aur-struct/.SRCINFO +++ b/aur-struct/.SRCINFO @@ -1,17 +1,18 @@ 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 Spotify client that doesn't require Premium nor uses Electron! Available for both desktop & mobile! +pkgver = 3.7.1 +pkgrel = 2 +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 +depends = webkit2gtk-4.1 +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..d7e1052b 100644 --- a/aur-struct/PKGBUILD +++ b/aur-struct/PKGBUILD @@ -8,7 +8,7 @@ arch=(x86_64) url="https://github.com/KRTirtho/spotube/" 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=() 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/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..8dbfe45d 100644 --- a/build.yaml +++ b/build.yaml @@ -2,4 +2,16 @@ targets: $default: sources: exclude: - - bin/*.dart \ No newline at end of file + - bin/*.dart + builders: + json_serializable: + options: + any_map: true + explicit_to_json: true + drift_dev: + options: + sql: + dialect: sqlite + options: + modules: + - json1 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..fdf35a95 --- /dev/null +++ b/cli/commands/build.dart @@ -0,0 +1,25 @@ +import 'package:args/command_runner.dart'; + +import 'build/android.dart'; +import 'build/ios.dart'; +import 'build/linux.dart'; +import 'build/linux_arm.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(LinuxArmBuildCommand()); + addSubcommand(MacosBuildCommand()); + addSubcommand(WindowsBuildCommand()); + } +} 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..4c7e3e51 --- /dev/null +++ b/cli/commands/build/common.dart @@ -0,0 +1,66 @@ +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 + """, + ); + } +} 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..a218720c --- /dev/null +++ b/cli/commands/build/linux.dart @@ -0,0 +1,106 @@ +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 + flutter_distributor package --platform=linux --targets=rpm + """, + ); + + final tempDir = join(Directory.systemTemp.path, "spotube-tar"); + + final bundleDirPath = + join(cwd.path, "build", "linux", "x64", "release", "bundle"); + + final tarFile = File(join( + cwd.path, + "dist", + "spotube-linux-" + "${CliEnv.channel == BuildChannel.nightly ? "nightly" : versionWithoutBuildNumber}" + "-x86_64.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", + ), + ); + + final ogRpm = File( + join( + cwd.path, + "dist", + pubspec.version.toString(), + "spotube-${pubspec.version}-linux.rpm", + ), + ); + + await ogDeb.copy( + join(cwd.path, "dist", "Spotube-linux-x86_64.deb"), + ); + await ogRpm.copy( + join(cwd.path, "dist", "Spotube-linux-x86_64.rpm"), + ); + + await ogDeb.delete(); + await ogRpm.delete(); + + stdout.writeln("✅ Linux building done"); + } +} diff --git a/cli/commands/build/linux_arm.dart b/cli/commands/build/linux_arm.dart new file mode 100644 index 00000000..a09f0980 --- /dev/null +++ b/cli/commands/build/linux_arm.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:args/command_runner.dart'; +import 'package:path/path.dart'; + +import '../../core/env.dart'; +import 'common.dart'; + +class LinuxArmBuildCommand extends Command with BuildCommandCommonSteps { + @override + String get description => "Build Linux Arm"; + + @override + String get name => "linux_arm"; + + @override + FutureOr? run() async { + await bootstrap(); + + await shell.run( + "docker buildx build --platform=linux/arm64 " + "-f ${join(cwd.path, ".github", "Dockerfile")} ${cwd.path} " + "--build-arg FLUTTER_VERSION=${CliEnv.flutterVersion} " + "--build-arg BUILD_VERSION=${CliEnv.channel == BuildChannel.nightly ? "nightly" : versionWithoutBuildNumber} " + "-t krtirtho/spotube_linux_arm:latest " + "--load", + ); + + await shell.run( + """ + docker images ls + docker create --name spotube_linux_arm krtirtho/spotube_linux_arm:latest + docker cp spotube_linux_arm:/app/dist/ dist/ + """, + ); + } +} 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..dc519cc6 --- /dev/null +++ b/cli/commands/install-dependencies.dart @@ -0,0 +1,79 @@ +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, + ); + } + + @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 "linux_arm": + await shell.run( + """ + sudo apt-get update -y + sudo apt-get install -y pkg-config make python3-pip python3-setuptools + """, + ); + 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/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..2d570cbc 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,23 @@ 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 + - OrderedSet (~> 6.0.3) - flutter_native_splash (0.0.1): - Flutter - flutter_secure_storage (6.0.0): - Flutter - flutter_sharing_intent (0.0.1): - Flutter - - fluttertoast (0.0.2): - - 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 +72,15 @@ 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 + - 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) @@ -92,9 +90,23 @@ PODS: - FlutterMacOS - sqflite (0.0.3): - Flutter - - FMDB (>= 2.7.5) + - FlutterMacOS + - "sqlite3 (3.46.0+1)": + - "sqlite3/common (= 3.46.0+1)" + - "sqlite3/common (3.46.0+1)" + - "sqlite3/fts5 (3.46.0+1)": + - sqlite3/common + - "sqlite3/perf-threadsafe (3.46.0+1)": + - sqlite3/common + - "sqlite3/rtree (3.46.0+1)": + - sqlite3/common + - sqlite3_flutter_libs (0.0.1): + - Flutter + - sqlite3 (~> 3.46.0) + - sqlite3/fts5 + - sqlite3/perf-threadsafe + - sqlite3/rtree - SwiftyGif (5.4.4) - - Toast (4.0.0) - url_launcher_ios (0.0.1): - Flutter @@ -102,17 +114,17 @@ 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`) - 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`) @@ -122,18 +134,18 @@ DEPENDENCIES: - 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 (from `.symlinks/plugins/sqflite/darwin`) + - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/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 +154,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 +164,18 @@ 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" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" integration_test: @@ -183,44 +195,46 @@ EXTERNAL SOURCES: shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sqflite: - :path: ".symlinks/plugins/sqflite/ios" + :path: ".symlinks/plugins/sqflite/darwin" + sqlite3_flutter_libs: + :path: ".symlinks/plugins/sqlite3_flutter_libs/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: 97af1d7e84681a90d0693e63169a5d50e0839a0d DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de - file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b + file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 + file_selector_ios: 78baf21d03f1e37a7df97bb2494f9cd86de8fa5d Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - flutter_inappwebview: acd4fc0f012cefd09015000c241137d82f01ba62 - flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 - flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83 - flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef + flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882 + flutter_discord_rpc: e1c342f29ceb9dd76cdc01db59a70c93bb4d9ec5 + flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4 + flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be flutter_sharing_intent: e35380d0e1501d7111dbb7e46d5ac6339da6da98 - fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265 - FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a - image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 - integration_test: 13825b8a9334a850581300559b8839134b124670 + image_picker_ios: b545a5f16c0fa88e3ecbbce3ed4de45567a8ec18 + 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 + OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 + package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c + path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 SDWebImage: a81bbb3ba4ea5f810f4069c68727cb118467a04a - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 - sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a + shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 + sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec + sqlite3: 292c3e1bfe89f64e51ea7fc7dab9182a017c8630 + sqlite3_flutter_libs: 0d611efdf6d1c9297d5ab03dab21b75aeebdae31 SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f - Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 - url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 + url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586 -PODFILE CHECKSUM: 5129d2e80ab0dfc533f262cedf032011b1dfe4fd +PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e COCOAPODS: 1.15.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 13f624a4..34793f68 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -324,6 +324,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 6E9FEF583EA597C8B76255B2 /* [CP] Embed Pods Frameworks */, + 46F6EB27C31C41D86428A28B /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -346,6 +347,7 @@ B536BD992B405DB1009B3CE4 /* Embed Frameworks */, B536BD9A2B405DB1009B3CE4 /* Thin Binary */, A6D446F111DE4C4A202BE7F7 /* [CP] Embed Pods Frameworks */, + 2DEF3CF18D30E819C0FF4BCE /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -368,6 +370,7 @@ B536BDB62B405FDE009B3CE4 /* Embed Frameworks */, B536BDB72B405FDE009B3CE4 /* Thin Binary */, 244D41CE80E4BC0FFD63F8C6 /* [CP] Embed Pods Frameworks */, + 4DD66E9E53D92195290872BE /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -390,6 +393,7 @@ B536BDD82B4060B3009B3CE4 /* Embed Frameworks */, B536BDD92B4060B3009B3CE4 /* Thin Binary */, D566C841A84D807A607F6DE5 /* [CP] Embed Pods Frameworks */, + 5C9D945D6569D9C3AC420285 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -523,6 +527,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 +560,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; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 8e103cfa..ffd511a4 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -66,5 +66,11 @@ UIViewControllerBasedStatusBarAppearance + NSLocalNetworkUsageDescription + To allow other devices on the network control playback of Spotube securely. + NSBonjourServices + + _spotube._tcp + \ No newline at end of file diff --git a/lib/collections/assets.gen.dart b/lib/collections/assets.gen.dart index 8a2950fb..cff5b74f 100644 --- a/lib/collections/assets.gen.dart +++ b/lib/collections/assets.gen.dart @@ -59,6 +59,8 @@ class Assets { AssetGenImage('assets/spotube-hero-banner.png'); static const AssetGenImage spotubeLogoForeground = AssetGenImage('assets/spotube-logo-foreground.jpg'); + 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'); @@ -88,7 +90,7 @@ class Assets { AssetGenImage('assets/user-placeholder.png'); /// List of all assets - List get values => [ + static List get values => [ albumPlaceholder, bengaliPatternsBg, branding, @@ -98,6 +100,7 @@ class Assets { placeholder, spotubeHeroBanner, spotubeLogoForeground, + spotubeLogoBmp, spotubeLogoIco, spotubeLogoPng, spotubeLogoSvg, 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..eb60851f 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,15 @@ 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; + + 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..31f97e0c 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" @@ -196,4 +198,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/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/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..4f446831 100644 --- a/lib/collections/intents.dart +++ b/lib/collections/intents.dart @@ -5,9 +5,12 @@ 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/modules/player/player_controls.dart'; +import 'package:spotube/pages/home/home.dart'; +import 'package:spotube/pages/library/library.dart'; +import 'package:spotube/pages/lyrics/lyrics.dart'; +import 'package:spotube/pages/search/search.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 +20,6 @@ class PlayPauseIntent extends Intent { } class PlayPauseAction extends Action { - final logger = getLogger(PlayPauseAction); - @override invoke(intent) async { if (PlayerControls.focusNode.canRequestFocus) { @@ -67,16 +68,16 @@ class HomeTabAction extends Action { final router = intent.ref.read(routerProvider); switch (intent.tab) { case HomeTabs.browse: - router.go("/"); + router.goNamed(HomePage.name); break; case HomeTabs.search: - router.go("/search"); + router.goNamed(SearchPage.name); break; case HomeTabs.library: - router.go("/library"); + router.goNamed(LibraryPage.name); break; case HomeTabs.lyrics: - router.go("/lyrics"); + router.goNamed(LyricsPage.name); break; } return null; @@ -92,8 +93,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 +102,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..44da6ee6 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", @@ -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: "ትግርኛ", diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 43d0cf2e..3bf1d883 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -1,39 +1,48 @@ -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:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart' hide Search; +import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/pages/album/album.dart'; +import 'package:spotube/pages/connect/connect.dart'; +import 'package:spotube/pages/connect/control/control.dart'; import 'package:spotube/pages/getting_started/getting_started.dart'; +import 'package:spotube/pages/home/feed/feed_section.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/local_folder.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/profile/profile.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/stats/albums/albums.dart'; +import 'package:spotube/pages/stats/artists/artists.dart'; +import 'package:spotube/pages/stats/fees/fees.dart'; +import 'package:spotube/pages/stats/minutes/minutes.dart'; +import 'package:spotube/pages/stats/playlists/playlists.dart'; +import 'package:spotube/pages/stats/stats.dart'; +import 'package:spotube/pages/stats/streams/streams.dart'; import 'package:spotube/pages/track/track.dart'; -import 'package:spotube/provider/authentication_provider.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/components/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 rootNavigatorKey = GlobalKey(); final shellRouteNavigatorKey = GlobalKey(); final routerProvider = Provider((ref) { return GoRouter( @@ -45,13 +54,11 @@ final routerProvider = Provider((ref) { routes: [ GoRoute( path: "/", + name: HomePage.name, redirect: (context, state) async { - final authNotifier = - ref.read(AuthenticationNotifier.provider.notifier); - final json = await authNotifier.box.get(authNotifier.cacheKey); + final auth = await ref.read(authenticationProvider.future); - if (json?["cookie"] == null && - !KVStoreService.doneGettingStarted) { + if (auth == null && !KVStoreService.doneGettingStarted) { return "/getting-started"; } @@ -62,61 +69,88 @@ final routerProvider = Provider((ref) { routes: [ GoRoute( path: "genres", + name: GenrePage.name, pageBuilder: (context, state) => const SpotubePage(child: GenrePage()), ), GoRoute( path: "genre/:categoryId", + name: GenrePlaylistsPage.name, pageBuilder: (context, state) => SpotubePage( child: GenrePlaylistsPage( category: state.extra as Category, ), ), ), + GoRoute( + path: "feeds/:feedId", + name: HomeFeedSectionPage.name, + pageBuilder: (context, state) => SpotubePage( + child: HomeFeedSectionPage( + sectionUri: state.pathParameters["feedId"] as String, + ), + ), + ) ], ), GoRoute( path: "/search", - name: "Search", + name: SearchPage.name, pageBuilder: (context, state) => const SpotubePage(child: SearchPage()), ), GoRoute( path: "/library", - name: "Library", + name: LibraryPage.name, 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, - ), + path: "generate", + name: PlaylistGeneratorPage.name, + pageBuilder: (context, state) => + const SpotubePage(child: PlaylistGeneratorPage()), + routes: [ + GoRoute( + path: "result", + name: PlaylistGenerateResultPage.name, + pageBuilder: (context, state) => SpotubePage( + child: PlaylistGenerateResultPage( + state: state.extra as GeneratePlaylistProviderInput, ), ), - ]), + ) + ], + ), + GoRoute( + path: "local", + name: LocalLibraryPage.name, + pageBuilder: (context, state) { + assert(state.extra is String); + return SpotubePage( + child: LocalLibraryPage(state.extra as String, + isDownloads: + state.uri.queryParameters["downloads"] != null), + ); + }, + ), ]), GoRoute( path: "/lyrics", - name: "Lyrics", + name: LyricsPage.name, pageBuilder: (context, state) => const SpotubePage(child: LyricsPage()), ), GoRoute( path: "/settings", + name: SettingsPage.name, pageBuilder: (context, state) => const SpotubePage( child: SettingsPage(), ), routes: [ GoRoute( path: "blacklist", + name: BlackListPage.name, pageBuilder: (context, state) => SpotubeSlidePage( child: const BlackListPage(), ), @@ -124,12 +158,14 @@ final routerProvider = Provider((ref) { if (!kIsWeb) GoRoute( path: "logs", + name: LogsPage.name, pageBuilder: (context, state) => SpotubeSlidePage( child: const LogsPage(), ), ), GoRoute( path: "about", + name: AboutSpotube.name, pageBuilder: (context, state) => SpotubeSlidePage( child: const AboutSpotube(), ), @@ -138,6 +174,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/album/:id", + name: AlbumPage.name, pageBuilder: (context, state) { assert(state.extra is AlbumSimple); return SpotubePage( @@ -147,6 +184,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/artist/:id", + name: ArtistPage.name, pageBuilder: (context, state) { assert(state.pathParameters["id"] != null); return SpotubePage( @@ -155,6 +193,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/playlist/:id", + name: PlaylistPage.name, pageBuilder: (context, state) { assert(state.extra is PlaylistSimple); return SpotubePage( @@ -166,6 +205,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/track/:id", + name: TrackPage.name, pageBuilder: (context, state) { final id = state.pathParameters["id"]!; return SpotubePage( @@ -173,10 +213,86 @@ final routerProvider = Provider((ref) { ); }, ), + GoRoute( + path: "/connect", + name: ConnectPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: ConnectPage(), + ), + routes: [ + GoRoute( + path: "control", + name: ConnectControlPage.name, + pageBuilder: (context, state) { + return const SpotubePage( + child: ConnectControlPage(), + ); + }, + ) + ], + ), + GoRoute( + path: "/profile", + name: ProfilePage.name, + pageBuilder: (context, state) => + const SpotubePage(child: ProfilePage()), + ), + GoRoute( + path: "/stats", + name: StatsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsPage(), + ), + routes: [ + GoRoute( + path: "minutes", + name: StatsMinutesPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsMinutesPage(), + ), + ), + GoRoute( + path: "streams", + name: StatsStreamsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsStreamsPage(), + ), + ), + GoRoute( + path: "fees", + name: StatsStreamFeesPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsStreamFeesPage(), + ), + ), + GoRoute( + path: "artists", + name: StatsArtistsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsArtistsPage(), + ), + ), + GoRoute( + path: "albums", + name: StatsAlbumsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsAlbumsPage(), + ), + ), + GoRoute( + path: "playlists", + name: StatsPlaylistsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsPlaylistsPage(), + ), + ), + ], + ) ], ), GoRoute( path: "/mini-player", + name: MiniLyricsPage.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => SpotubePage( child: MiniLyricsPage(prevSize: state.extra as Size), @@ -184,6 +300,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/getting-started", + name: GettingStarting.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => const SpotubePage( child: GettingStarting(), @@ -191,20 +308,15 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/login", - parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) => SpotubePage( - child: kIsMobile ? const WebViewLogin() : const DesktopLoginPage(), - ), - ), - GoRoute( - path: "/login-tutorial", + name: WebViewLogin.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => const SpotubePage( - child: LoginTutorial(), + child: WebViewLogin(), ), ), GoRoute( path: "/lastfm-login", + name: LastFMLoginPage.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => const SpotubePage(child: LastFMLoginPage()), diff --git a/lib/collections/side_bar_tiles.dart b/lib/collections/side_bar_tiles.dart index 551d70d7..4f23c049 100644 --- a/lib/collections/side_bar_tiles.dart +++ b/lib/collections/side_bar_tiles.dart @@ -1,33 +1,82 @@ import 'package:flutter/material.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:spotube/pages/home/home.dart'; +import 'package:spotube/pages/library/library.dart'; +import 'package:spotube/pages/lyrics/lyrics.dart'; +import 'package:spotube/pages/search/search.dart'; +import 'package:spotube/pages/stats/stats.dart'; class SideBarTiles { final IconData icon; final String title; final String id; - SideBarTiles({required this.icon, required this.title, required this.id}); + final String name; + + SideBarTiles({ + required this.icon, + required this.title, + required this.id, + required this.name, + }); } 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), - ]; - -List getNavbarTileList(AppLocalizations l10n) => [ - SideBarTiles(id: "browse", icon: SpotubeIcons.home, title: l10n.browse), - SideBarTiles(id: "search", icon: SpotubeIcons.search, title: l10n.search), + id: "browse", + name: HomePage.name, + icon: SpotubeIcons.home, + title: l10n.browse, + ), + SideBarTiles( + id: "search", + name: SearchPage.name, + icon: SpotubeIcons.search, + title: l10n.search, + ), SideBarTiles( id: "library", + name: LibraryPage.name, icon: SpotubeIcons.library, title: l10n.library, ), SideBarTiles( - id: "settings", - icon: SpotubeIcons.settings, - title: l10n.settings, - ) + id: "lyrics", + name: LyricsPage.name, + icon: SpotubeIcons.music, + title: l10n.lyrics, + ), + SideBarTiles( + id: "stats", + name: StatsPage.name, + icon: SpotubeIcons.chart, + title: l10n.stats, + ), + ]; + +List getNavbarTileList(AppLocalizations l10n) => [ + SideBarTiles( + id: "browse", + name: HomePage.name, + icon: SpotubeIcons.home, + title: l10n.browse, + ), + SideBarTiles( + id: "search", + name: SearchPage.name, + icon: SpotubeIcons.search, + title: l10n.search, + ), + SideBarTiles( + id: "library", + name: LibraryPage.name, + icon: SpotubeIcons.library, + title: l10n.library, + ), + SideBarTiles( + id: "stats", + name: StatsPage.name, + icon: SpotubeIcons.chart, + title: l10n.stats, + ), ]; diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 6cf92085..a45e581e 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -115,4 +115,13 @@ 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; } diff --git a/lib/components/shared/adaptive/adaptive_list_tile.dart b/lib/components/adaptive/adaptive_list_tile.dart similarity index 100% rename from lib/components/shared/adaptive/adaptive_list_tile.dart rename to lib/components/adaptive/adaptive_list_tile.dart diff --git a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart b/lib/components/adaptive/adaptive_pop_sheet_list.dart similarity index 97% rename from lib/components/shared/adaptive/adaptive_pop_sheet_list.dart rename to lib/components/adaptive/adaptive_pop_sheet_list.dart index 21f56a22..97dc6132 100644 --- a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart +++ b/lib/components/adaptive/adaptive_pop_sheet_list.dart @@ -187,7 +187,7 @@ class AdaptivePopSheetList extends StatelessWidget { icon: icon ?? const Icon(SpotubeIcons.moreVertical), tooltip: tooltip, style: theme.iconButtonTheme.style?.copyWith( - shape: MaterialStatePropertyAll( + shape: WidgetStatePropertyAll( RoundedRectangleBorder( borderRadius: borderRadius, ), @@ -226,7 +226,10 @@ class _AdaptivePopSheetListItem extends StatelessWidget { }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), - child: IgnorePointer(child: item), + child: IconTheme.merge( + data: const IconThemeData(opacity: 1), + child: IgnorePointer(child: item), + ), ), ); } diff --git a/lib/components/shared/adaptive/adaptive_popup_menu_button.dart b/lib/components/adaptive/adaptive_popup_menu_button.dart similarity index 98% rename from lib/components/shared/adaptive/adaptive_popup_menu_button.dart rename to lib/components/adaptive/adaptive_popup_menu_button.dart index 45f22825..02fced52 100644 --- a/lib/components/shared/adaptive/adaptive_popup_menu_button.dart +++ b/lib/components/adaptive/adaptive_popup_menu_button.dart @@ -12,13 +12,13 @@ class Action extends StatelessWidget { final bool isExpanded; final Color? backgroundColor; const Action({ - Key? key, + super.key, required this.icon, required this.text, required this.onPressed, this.isExpanded = true, this.backgroundColor, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/adaptive/adaptive_select_tile.dart b/lib/components/adaptive/adaptive_select_tile.dart similarity index 83% rename from lib/components/shared/adaptive/adaptive_select_tile.dart rename to lib/components/adaptive/adaptive_select_tile.dart index 58666e46..3f6d2700 100644 --- a/lib/components/shared/adaptive/adaptive_select_tile.dart +++ b/lib/components/adaptive/adaptive_select_tile.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/extensions/constrains.dart'; class AdaptiveSelectTile extends HookWidget { @@ -38,11 +39,22 @@ class AdaptiveSelectTile extends HookWidget { 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 rawControl = DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: DropdownButton( + items: options, + value: value, + onChanged: onChanged, + menuMaxHeight: mediaQuery.size.height * 0.6, + underline: const SizedBox.shrink(), + padding: const EdgeInsets.symmetric(horizontal: 10), + borderRadius: BorderRadius.circular(10), + icon: const Icon(SpotubeIcons.angleDown), + dropdownColor: theme.colorScheme.secondaryContainer, + ), ); final controlPlaceholder = useMemoized( () => options diff --git a/lib/components/shared/animated_gradient.dart b/lib/components/animated_gradient.dart similarity index 98% rename from lib/components/shared/animated_gradient.dart rename to lib/components/animated_gradient.dart index b6485f6b..aaba2ff9 100644 --- a/lib/components/shared/animated_gradient.dart +++ b/lib/components/animated_gradient.dart @@ -3,7 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; class AnimateGradient extends HookWidget { const AnimateGradient({ - Key? key, + super.key, required this.primaryColors, required this.secondaryColors, this.child, @@ -17,8 +17,7 @@ class AnimateGradient extends HookWidget { this.reverse = true, }) : assert(primaryColors.length >= 2), assert(primaryColors.length == secondaryColors.length), - _controller = controller, - super(key: key); + _controller = controller; /// [controller]: pass this to have a fine control over the [Animation] final AnimationController? _controller; 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/shared/bordered_text.dart b/lib/components/bordered_text.dart similarity index 97% rename from lib/components/shared/bordered_text.dart rename to lib/components/bordered_text.dart index 627b2a3c..f25f2208 100644 --- a/lib/components/shared/bordered_text.dart +++ b/lib/components/bordered_text.dart @@ -79,7 +79,7 @@ class BorderedText extends StatelessWidget { strutStyle: child.strutStyle, textAlign: child.textAlign, textDirection: child.textDirection, - textScaleFactor: child.textScaleFactor, + textScaler: child.textScaler, ), child, ], diff --git a/lib/components/shared/compact_search.dart b/lib/components/compact_search.dart similarity index 97% rename from lib/components/shared/compact_search.dart rename to lib/components/compact_search.dart index 70815291..d37cb673 100644 --- a/lib/components/shared/compact_search.dart +++ b/lib/components/compact_search.dart @@ -11,12 +11,12 @@ class CompactSearch extends HookWidget { final Color? iconColor; const CompactSearch({ - Key? key, + super.key, this.onChanged, this.placeholder = "Search...", this.icon = SpotubeIcons.search, this.iconColor, - }) : super(key: key); + }); @override Widget build(BuildContext context) { 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/shared/dialogs/confirm_download_dialog.dart b/lib/components/dialogs/confirm_download_dialog.dart similarity index 93% rename from lib/components/shared/dialogs/confirm_download_dialog.dart rename to lib/components/dialogs/confirm_download_dialog.dart index c371e803..897c64cb 100644 --- a/lib/components/shared/dialogs/confirm_download_dialog.dart +++ b/lib/components/dialogs/confirm_download_dialog.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.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'; class ConfirmDownloadDialog extends StatelessWidget { - const ConfirmDownloadDialog({Key? key}) : super(key: key); + const ConfirmDownloadDialog({super.key}); @override Widget build(BuildContext context) { @@ -82,7 +82,7 @@ class ConfirmDownloadDialog extends StatelessWidget { class BulletPoint extends StatelessWidget { final String text; - const BulletPoint(this.text, {Key? key}) : super(key: key); + const BulletPoint(this.text, {super.key}); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/dialogs/piped_down_dialog.dart b/lib/components/dialogs/piped_down_dialog.dart similarity index 96% rename from lib/components/shared/dialogs/piped_down_dialog.dart rename to lib/components/dialogs/piped_down_dialog.dart index 6220adeb..b1717a2a 100644 --- a/lib/components/shared/dialogs/piped_down_dialog.dart +++ b/lib/components/dialogs/piped_down_dialog.dart @@ -5,7 +5,7 @@ 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); + const PipedDownDialog({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/dialogs/playlist_add_track_dialog.dart b/lib/components/dialogs/playlist_add_track_dialog.dart similarity index 72% rename from lib/components/shared/dialogs/playlist_add_track_dialog.dart rename to lib/components/dialogs/playlist_add_track_dialog.dart index 51b77c76..5af9c9e4 100644 --- a/lib/components/shared/dialogs/playlist_add_track_dialog.dart +++ b/lib/components/dialogs/playlist_add_track_dialog.dart @@ -1,16 +1,14 @@ -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/modules/playlist/playlist_create_dialog.dart'; +import 'package:spotube/components/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'; +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 @@ -19,33 +17,40 @@ class PlaylistAddTrackDialog extends HookConsumerWidget { const PlaylistAddTrackDialog({ required this.tracks, required this.openFromPlaylist, - Key? key, - }) : super(key: key); + super.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 userPlaylists = ref.watch(favoritePlaylistsProvider); + final favoritePlaylistsNotifier = + ref.watch(favoritePlaylistsProvider.notifier); - final me = useQueries.user.me(ref); + final me = ref.watch(meProvider); final filteredPlaylists = useMemoized( () => - userPlaylists.data - ?.where( + userPlaylists.asData?.value.items + .where( (playlist) => playlist.owner?.id != null && - playlist.owner!.id == me.data?.id && + playlist.owner!.id == me.asData?.value.id && playlist.id != openFromPlaylist, ) .toList() ?? [], - [userPlaylists.data, me.data?.id, openFromPlaylist], + [userPlaylists.asData?.value, me.asData?.value.id, openFromPlaylist], ); final playlistsCheck = useState({}); - final queryClient = useQueryClient(); + + useEffect(() { + if (userPlaylists.asData?.value != null) { + favoritePlaylistsNotifier.fetchAll(); + } + return null; + }, [userPlaylists.asData?.value]); Future onAdd() async { final selectedPlaylists = playlistsCheck.value.entries @@ -54,21 +59,12 @@ class PlaylistAddTrackDialog extends HookConsumerWidget { await Future.wait( selectedPlaylists.map( - (playlistId) => spotify.playlists.addTracks( - tracks - .map( - (track) => track.uri!, - ) - .toList(), - playlistId), + (playlistId) => favoritePlaylistsNotifier.addTracks( + playlistId, + tracks.map((e) => e.id!).toList(), + ), ), ).then((_) => Navigator.pop(context, true)); - - await queryClient.refreshQueries( - selectedPlaylists - .map((playlistId) => "playlist-tracks/$playlistId") - .toList(), - ); } return AlertDialog( @@ -109,8 +105,7 @@ class PlaylistAddTrackDialog extends HookConsumerWidget { return CheckboxListTile( secondary: CircleAvatar( backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - playlist.images, + playlist.images.asUrlString( placeholder: ImagePlaceholder.collection, ), ), diff --git a/lib/components/shared/dialogs/prompt_dialog.dart b/lib/components/dialogs/prompt_dialog.dart similarity index 100% rename from lib/components/shared/dialogs/prompt_dialog.dart rename to lib/components/dialogs/prompt_dialog.dart diff --git a/lib/components/shared/dialogs/replace_downloaded_dialog.dart b/lib/components/dialogs/replace_downloaded_dialog.dart similarity index 96% rename from lib/components/shared/dialogs/replace_downloaded_dialog.dart rename to lib/components/dialogs/replace_downloaded_dialog.dart index 77721041..00461d34 100644 --- a/lib/components/shared/dialogs/replace_downloaded_dialog.dart +++ b/lib/components/dialogs/replace_downloaded_dialog.dart @@ -8,8 +8,7 @@ final replaceDownloadedFileState = StateProvider((ref) => null); class ReplaceDownloadedDialog extends ConsumerWidget { final Track track; - const ReplaceDownloadedDialog({required this.track, Key? key}) - : super(key: key); + const ReplaceDownloadedDialog({required this.track, super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/dialogs/select_device_dialog.dart b/lib/components/dialogs/select_device_dialog.dart new file mode 100644 index 00000000..3a3bde60 --- /dev/null +++ b/lib/components/dialogs/select_device_dialog.dart @@ -0,0 +1,67 @@ +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/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), + insetPadding: const EdgeInsets.all(16), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(context.l10n.multiple_device_connected), + RadioListTile.adaptive( + title: Text(remoteService.name), + value: true, + groupValue: isRemoteService.value, + onChanged: (value) { + isRemoteService.value = value!; + }, + ), + RadioListTile.adaptive( + title: Text(context.l10n.this_device), + value: false, + groupValue: isRemoteService.value, + onChanged: (value) { + isRemoteService.value = !value!; + }, + ), + ], + ), + actions: [ + TextButton( + 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 ?? false; +} diff --git a/lib/components/shared/dialogs/track_details_dialog.dart b/lib/components/dialogs/track_details_dialog.dart similarity index 93% rename from lib/components/shared/dialogs/track_details_dialog.dart rename to lib/components/dialogs/track_details_dialog.dart index 8634776f..61bca7b1 100644 --- a/lib/components/shared/dialogs/track_details_dialog.dart +++ b/lib/components/dialogs/track_details_dialog.dart @@ -2,20 +2,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:spotify/spotify.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,10 +24,11 @@ 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!, diff --git a/lib/components/shared/expandable_search/expandable_search.dart b/lib/components/expandable_search/expandable_search.dart similarity index 97% rename from lib/components/shared/expandable_search/expandable_search.dart rename to lib/components/expandable_search/expandable_search.dart index 75ac6841..157e180f 100644 --- a/lib/components/shared/expandable_search/expandable_search.dart +++ b/lib/components/expandable_search/expandable_search.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) { @@ -60,12 +60,12 @@ 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) { diff --git a/lib/components/shared/fallbacks/anonymous_fallback.dart b/lib/components/fallbacks/anonymous_fallback.dart similarity index 60% rename from lib/components/shared/fallbacks/anonymous_fallback.dart rename to lib/components/fallbacks/anonymous_fallback.dart index aea7bf38..62ed8ddd 100644 --- a/lib/components/shared/fallbacks/anonymous_fallback.dart +++ b/lib/components/fallbacks/anonymous_fallback.dart @@ -1,22 +1,27 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/settings/settings.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/utils/service_utils.dart'; class AnonymousFallback extends ConsumerWidget { final Widget? child; const AnonymousFallback({ - Key? key, + super.key, this.child, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { - final isLoggedIn = ref.watch(AuthenticationNotifier.provider) != null; + final isLoggedIn = ref.watch(authenticationProvider); - if (isLoggedIn && child != null) return child!; + if (isLoggedIn.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (isLoggedIn.asData?.value != null && child != null) return child!; return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -25,7 +30,7 @@ class AnonymousFallback extends ConsumerWidget { const SizedBox(height: 10), FilledButton( child: Text(context.l10n.login_with_spotify), - onPressed: () => ServiceUtils.push(context, "/settings"), + onPressed: () => ServiceUtils.pushNamed(context, SettingsPage.name), ) ], ), diff --git a/lib/components/shared/fallbacks/not_found.dart b/lib/components/fallbacks/not_found.dart similarity index 75% rename from lib/components/shared/fallbacks/not_found.dart rename to lib/components/fallbacks/not_found.dart index f45573ad..ce168f17 100644 --- a/lib/components/shared/fallbacks/not_found.dart +++ b/lib/components/fallbacks/not_found.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/extensions/context.dart'; class NotFound extends StatelessWidget { final bool vertical; - const NotFound({Key? key, this.vertical = false}) : super(key: key); + const NotFound({super.key, this.vertical = false}); @override Widget build(BuildContext context) { @@ -18,9 +19,9 @@ class NotFound extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text("Nothing found", style: theme.textTheme.titleLarge), + Text(context.l10n.nothing_found, style: theme.textTheme.titleLarge), Text( - "The box is empty", + context.l10n.the_box_is_empty, style: theme.textTheme.titleMedium, ), ], diff --git a/lib/components/framework/app_pop_scope.dart b/lib/components/framework/app_pop_scope.dart new file mode 100644 index 00000000..b8e35767 --- /dev/null +++ b/lib/components/framework/app_pop_scope.dart @@ -0,0 +1,104 @@ +import 'dart:io'; + +import 'package:flutter/material.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..fa4318cc --- /dev/null +++ b/lib/components/heart_button/heart_button.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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; + const HeartButton({ + required this.isLiked, + required this.onPressed, + this.color, + this.tooltip, + this.icon, + super.key, + }); + + @override + Widget build(BuildContext context, ref) { + final auth = ref.watch(authenticationProvider); + + if (auth.asData?.value == 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, + ); + } +} + +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/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 76% 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..16204952 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 @@ -5,9 +5,9 @@ import 'package:flutter_hooks/flutter_hooks.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/modules/album/album_card.dart'; +import 'package:spotube/modules/artist/artist_card.dart'; +import 'package:spotube/modules/playlist/playlist_card.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; @@ -17,20 +17,22 @@ 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) { @@ -49,9 +51,15 @@ class HorizontalPlaybuttonCardView extends HookWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - DefaultTextStyle( - style: textTheme.titleMedium!, - child: title, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + DefaultTextStyle( + style: textTheme.titleMedium!, + child: title, + ), + if (titleTrailing != null) titleTrailing!, + ], ), SizedBox( height: height, @@ -85,11 +93,11 @@ class HorizontalPlaybuttonCardView extends HookWidget { 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( + AlbumSimple() => AlbumCard(item as AlbumSimple), + Artist() => Padding( padding: const EdgeInsets.symmetric( horizontal: 12.0), child: ArtistCard(item as Artist), 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 80% rename from lib/components/shared/inter_scrollbar/inter_scrollbar.dart rename to lib/components/inter_scrollbar/inter_scrollbar.dart index 2b3ce319..8a86b643 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: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/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/shared/links/anchor_button.dart b/lib/components/links/anchor_button.dart similarity index 92% rename from lib/components/shared/links/anchor_button.dart rename to lib/components/links/anchor_button.dart index b1b1cfea..c6f0b889 100644 --- a/lib/components/shared/links/anchor_button.dart +++ b/lib/components/links/anchor_button.dart @@ -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..9f06f1b3 --- /dev/null +++ b/lib/components/links/artist_link.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/links/anchor_button.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/artist/artist.dart'; +import 'package:spotube/utils/service_utils.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 { + ServiceUtils.pushNamed( + context, + ArtistPage.name, + pathParameters: { + "id": 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 88% rename from lib/components/shared/links/hyper_link.dart rename to lib/components/links/hyper_link.dart index fd31298e..32d715e0 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: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 89% rename from lib/components/shared/links/link_text.dart rename to lib/components/links/link_text.dart index d7b00b72..0cab71d0 100644 --- a/lib/components/shared/links/link_text.dart +++ b/lib/components/links/link_text.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:spotube/components/shared/links/anchor_button.dart'; +import 'package:spotube/components/links/anchor_button.dart'; import 'package:spotube/utils/service_utils.dart'; class LinkText extends StatelessWidget { @@ -15,14 +15,14 @@ class LinkText extends StatelessWidget { 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) { diff --git a/lib/components/shared/panels/controller.dart b/lib/components/panels/controller.dart similarity index 92% rename from lib/components/shared/panels/controller.dart rename to lib/components/panels/controller.dart index a573c06c..4e367701 100644 --- a/lib/components/shared/panels/controller.dart +++ b/lib/components/panels/controller.dart @@ -1,4 +1,4 @@ -part of panels; +part of 'sliding_up_panel.dart'; class PanelController extends ChangeNotifier { SlidingUpPanelState? _panelState; @@ -41,29 +41,33 @@ class PanelController extends ChangeNotifier { bool get isAttached => _panelState != null; /// Closes the sliding panel to its collapsed state (i.e. to the minHeight) - Future close() { + Future close() async { assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - return _panelState!._close(); + await _panelState!._close(); + notifyListeners(); } /// Opens the sliding panel fully /// (i.e. to the maxHeight) - Future open() { + Future open() async { assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - return _panelState!._open(); + await _panelState!._open(); + notifyListeners(); } /// Hides the sliding panel (i.e. is invisible) - Future hide() { + Future hide() async { assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - return _panelState!._hide(); + await _panelState!._hide(); + notifyListeners(); } /// Shows the sliding panel in its collapsed state /// (i.e. "un-hide" the sliding panel) - Future show() { + Future show() async { assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - return _panelState!._show(); + await _panelState!._show(); + notifyListeners(); } /// Animates the panel position to the value. diff --git a/lib/components/shared/panels/helpers.dart b/lib/components/panels/helpers.dart similarity index 95% rename from lib/components/shared/panels/helpers.dart rename to lib/components/panels/helpers.dart index 2e754bdf..d79fa97c 100644 --- a/lib/components/shared/panels/helpers.dart +++ b/lib/components/panels/helpers.dart @@ -1,4 +1,4 @@ -part of panels; +part of "sliding_up_panel.dart"; /// if you want to prevent the panel from being dragged using the widget, /// wrap the widget with this @@ -47,8 +47,7 @@ class ForceDraggableWidgetRenderBox extends RenderPointerListener { /// To make [ForceDraggableWidget] work in [Scrollable] widgets class PanelScrollPhysics extends ScrollPhysics { final PanelController controller; - const PanelScrollPhysics({required this.controller, ScrollPhysics? parent}) - : super(parent: parent); + const PanelScrollPhysics({required this.controller, super.parent}); @override PanelScrollPhysics applyTo(ScrollPhysics? ancestor) { return PanelScrollPhysics( diff --git a/lib/components/shared/panels/sliding_up_panel.dart b/lib/components/panels/sliding_up_panel.dart similarity index 99% rename from lib/components/shared/panels/sliding_up_panel.dart rename to lib/components/panels/sliding_up_panel.dart index 137d5eb7..e99fe261 100644 --- a/lib/components/shared/panels/sliding_up_panel.dart +++ b/lib/components/panels/sliding_up_panel.dart @@ -146,7 +146,7 @@ class SlidingUpPanel extends StatefulWidget { final BoxDecoration? panelDecoration; const SlidingUpPanel( - {Key? key, + {super.key, this.body, this.collapsed, this.minHeight = 100.0, @@ -176,8 +176,7 @@ class SlidingUpPanel extends StatefulWidget { this.panelBuilder}) : assert(panelBuilder != null), assert(0 <= backdropOpacity && backdropOpacity <= 1.0), - assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0), - super(key: key); + assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0); @override SlidingUpPanelState createState() => SlidingUpPanelState(); diff --git a/lib/components/shared/playbutton_card.dart b/lib/components/playbutton_card.dart similarity index 91% rename from lib/components/shared/playbutton_card.dart rename to lib/components/playbutton_card.dart index a8a75d30..ae9050d8 100644 --- a/lib/components/shared/playbutton_card.dart +++ b/lib/components/playbutton_card.dart @@ -3,23 +3,15 @@ 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/components/hover_builder.dart'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/string.dart'; import 'package:spotube/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; @@ -43,8 +35,8 @@ class PlaybuttonCard extends HookWidget { this.onAddToQueuePressed, this.onTap, this.isOwner = false, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context) { @@ -66,19 +58,18 @@ class PlaybuttonCard extends HookWidget { others: 15, ); - final cleanDescription = useDescription(description); - + final unescapeHtml = description?.unescapeHtml().cleanHtml(); return Container( constraints: BoxConstraints(maxWidth: size), margin: margin, child: Material( color: Color.lerp( - theme.colorScheme.surfaceVariant, + theme.colorScheme.surfaceContainerHighest, theme.colorScheme.surface, useBrightnessValue(.9, .7), ), borderRadius: radius, - shadowColor: theme.colorScheme.background, + shadowColor: theme.colorScheme.surface, elevation: 3, child: InkWell( mouseCursor: SystemMouseCursors.click, @@ -137,7 +128,7 @@ class PlaybuttonCard extends HookWidget { ), if (isHovered) Text( - "Owned by you", + context.l10n.owned_by_you, style: theme.textTheme.bodySmall?.copyWith( color: Colors.white, ), @@ -158,7 +149,7 @@ class PlaybuttonCard extends HookWidget { Skeleton.keep( child: IconButton( style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.background, + backgroundColor: theme.colorScheme.surface, foregroundColor: theme.colorScheme.primary, minimumSize: const Size.square(10), ), @@ -205,11 +196,11 @@ class PlaybuttonCard extends HookWidget { overflow: TextOverflow.ellipsis, ), ), - if (cleanDescription != null) + if (description != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0), child: AutoSizeText( - cleanDescription, + unescapeHtml!, maxLines: 2, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withOpacity(.5), 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/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/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/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/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/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/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/shimmers/shimmer_lyrics.dart b/lib/components/shimmers/shimmer_lyrics.dart similarity index 94% rename from lib/components/shared/shimmers/shimmer_lyrics.dart rename to lib/components/shimmers/shimmer_lyrics.dart index b225c008..03816202 100644 --- a/lib/components/shared/shimmers/shimmer_lyrics.dart +++ b/lib/components/shimmers/shimmer_lyrics.dart @@ -5,7 +5,7 @@ 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/shared/sort_tracks_dropdown.dart b/lib/components/sort_tracks_dropdown.dart similarity index 93% rename from lib/components/shared/sort_tracks_dropdown.dart rename to lib/components/sort_tracks_dropdown.dart index ab35b2e3..16727013 100644 --- a/lib/components/shared/sort_tracks_dropdown.dart +++ b/lib/components/sort_tracks_dropdown.dart @@ -1,8 +1,8 @@ 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/modules/library/user_local_tracks.dart'; +import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/extensions/context.dart'; class SortTracksDropdown extends StatelessWidget { @@ -11,8 +11,8 @@ class SortTracksDropdown extends StatelessWidget { const SortTracksDropdown({ this.onChanged, this.value, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/spotube_page_route.dart b/lib/components/spotube_page_route.dart similarity index 100% rename from lib/components/shared/spotube_page_route.dart rename to lib/components/spotube_page_route.dart diff --git a/lib/components/shared/themed_button_tab_bar.dart b/lib/components/themed_button_tab_bar.dart similarity index 87% rename from lib/components/shared/themed_button_tab_bar.dart rename to lib/components/themed_button_tab_bar.dart index d5798189..c245e5f4 100644 --- a/lib/components/shared/themed_button_tab_bar.dart +++ b/lib/components/themed_button_tab_bar.dart @@ -5,7 +5,8 @@ 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); + final TabController? controller; + const ThemedButtonsTabBar({super.key, required this.tabs, this.controller}); @override Widget build(BuildContext context) { @@ -21,6 +22,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { bottom: 8, ), child: ButtonsTabBar( + controller: controller, radius: 100, decoration: BoxDecoration( color: bgColor, @@ -32,7 +34,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { ), borderWidth: 0, unselectedDecoration: BoxDecoration( - color: theme.colorScheme.background, + color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(15), ), unselectedLabelStyle: theme.textTheme.labelLarge?.copyWith( diff --git a/lib/components/titlebar/mouse_state.dart b/lib/components/titlebar/mouse_state.dart new file mode 100644 index 00000000..9af2a8b0 --- /dev/null +++ b/lib/components/titlebar/mouse_state.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +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({super.key, required this.builder, this.onPressed}); + @override + // ignore: library_private_types_in_public_api + _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/titlebar/titlebar.dart b/lib/components/titlebar/titlebar.dart new file mode 100644 index 00000000..76a5ec8a --- /dev/null +++ b/lib/components/titlebar/titlebar.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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'; + +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; + + final bool _sliver; + + const PageWindowTitleBar({ + super.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, + }) : _sliver = false, + pinned = false, + floating = false, + snap = false, + stretch = false; + + final bool pinned; + final bool floating; + final bool snap; + final bool stretch; + + const PageWindowTitleBar.sliver({ + super.key, + this.actions, + this.title, + this.backgroundColor, + this.actionsIconTheme, + this.automaticallyImplyLeading = false, + this.centerTitle, + this.foregroundColor, + this.leading, + this.leadingWidth, + this.titleSpacing, + this.titleTextStyle, + this.titleWidth, + this.toolbarTextStyle, + this.pinned = false, + this.floating = false, + this.snap = false, + this.stretch = false, + }) : _sliver = true, + toolbarOpacity = 1; + + @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) { + windowManager.startDragging(); + } + } + + @override + Widget build(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + + if (widget._sliver) { + return SliverLayoutBuilder( + builder: (context, constraints) { + final hasFullscreen = + mediaQuery.size.width == constraints.crossAxisExtent; + final hasLeadingOrCanPop = + widget.leading != null || Navigator.canPop(context); + + return SliverPadding( + padding: EdgeInsets.only( + left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0, + ), + sliver: SliverAppBar( + 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, + leadingWidth: widget.leadingWidth, + toolbarTextStyle: widget.toolbarTextStyle, + titleTextStyle: widget.titleTextStyle, + title: SizedBox( + width: double.infinity, // workaround to force dragging + child: widget.title ?? const Text(""), + ), + pinned: widget.pinned, + floating: widget.floating, + snap: widget.snap, + stretch: widget.stretch, + ), + ); + }, + ); + } + + 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: kIsMacOS && 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: SizedBox( + width: double.infinity, // workaround to force dragging + child: widget.title ?? const Text(""), + ), + scrolledUnderElevation: 0, + shadowColor: Colors.transparent, + forceMaterialTransparency: true, + elevation: 0, + ), + ), + ); + }); + } +} diff --git a/lib/components/titlebar/titlebar_buttons.dart b/lib/components/titlebar/titlebar_buttons.dart new file mode 100644 index 00000000..35cdf08e --- /dev/null +++ b/lib/components/titlebar/titlebar_buttons.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/titlebar/titlebar_icon_buttons.dart'; +import 'package:spotube/components/titlebar/window_button.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(); + } + + useEffect(() { + if (kIsDesktop) { + windowManager.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.onSurface, + mouseOver: theme.colorScheme.onSurface.withOpacity(0.1), + mouseDown: theme.colorScheme.onSurface.withOpacity(0.2), + iconMouseOver: theme.colorScheme.onSurface, + iconMouseDown: theme.colorScheme.onSurface, + ); + + final closeColors = WindowButtonColors( + normal: Colors.transparent, + iconNormal: foregroundColor ?? theme.colorScheme.onSurface, + 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: windowManager.minimize, + colors: colors, + ), + if (isMaximized.value != true) + MaximizeWindowButton( + colors: colors, + onPressed: () { + windowManager.maximize(); + isMaximized.value = true; + }, + ) + else + RestoreWindowButton( + colors: colors, + onPressed: () { + windowManager.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: 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..70170262 --- /dev/null +++ b/lib/components/titlebar/titlebar_icon_buttons.dart @@ -0,0 +1,161 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:spotube/components/titlebar/window_button.dart'; + +class MinimizeWindowButton extends WindowButton { + MinimizeWindowButton( + {super.key, super.colors, super.onPressed, bool? animate}) + : super( + animate: animate ?? false, + iconBuilder: (buttonContext) => + MinimizeIcon(color: buttonContext.iconColor), + ); +} + +class MaximizeWindowButton extends WindowButton { + MaximizeWindowButton( + {super.key, super.colors, super.onPressed, bool? animate}) + : super( + animate: animate ?? false, + iconBuilder: (buttonContext) => + MaximizeIcon(color: buttonContext.iconColor), + ); +} + +class RestoreWindowButton extends WindowButton { + RestoreWindowButton({super.key, super.colors, super.onPressed, bool? animate}) + : super( + animate: animate ?? false, + iconBuilder: (buttonContext) => + RestoreIcon(color: buttonContext.iconColor), + ); +} + +final _defaultCloseButtonColors = WindowButtonColors( + mouseOver: const Color(0xFFD32F2F), + mouseDown: const Color(0xFFB71C1C), + iconNormal: const Color(0xFF805306), + iconMouseOver: const Color(0xFFFFFFFF)); + +class CloseWindowButton extends WindowButton { + CloseWindowButton( + {super.key, WindowButtonColors? colors, super.onPressed, bool? animate}) + : super( + colors: colors ?? _defaultCloseButtonColors, + animate: animate ?? false, + iconBuilder: (buttonContext) => + CloseIcon(color: buttonContext.iconColor), + ); +} + +// Switched to CustomPaint icons by https://github.com/esDotDev + +/// 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/titlebar/window_button.dart b/lib/components/titlebar/window_button.dart new file mode 100644 index 00000000..3201d191 --- /dev/null +++ b/lib/components/titlebar/window_button.dart @@ -0,0 +1,133 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:spotube/components/titlebar/mouse_state.dart'; + +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( + {super.key, + WindowButtonColors? colors, + this.builder, + @required this.iconBuilder, + this.padding, + this.onPressed, + this.animate = false}) { + 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!(); + }, + ); + } +} diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/track_tile/track_options.dart similarity index 54% rename from lib/components/shared/track_tile/track_options.dart rename to lib/components/track_tile/track_options.dart index a094259d..d2cb92cf 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/track_tile/track_options.dart @@ -1,6 +1,5 @@ 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'; @@ -9,24 +8,28 @@ 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/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_provider.dart'; +import 'package:spotube/pages/track/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/proxy_playlist/proxy_playlist_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: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:spotube/utils/service_utils.dart'; + import 'package:url_launcher/url_launcher_string.dart'; enum TrackOptionValue { @@ -53,13 +56,13 @@ class TrackOptions extends HookConsumerWidget { final ObjectRef?>? showMenuCbRef; final Widget? icon; const TrackOptions({ - Key? key, + super.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}"; @@ -95,25 +98,16 @@ class TrackOptions extends HookConsumerWidget { WidgetRef ref, Track track, ) async { - final playback = ref.read(ProxyPlaylistNotifier.notifier); - final playlist = ref.read(ProxyPlaylistNotifier.provider); + final playback = ref.read(audioPlayerProvider.notifier); + final playlist = ref.read(audioPlayerProvider); 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 pages = + await spotify.search.get(query, types: [SearchType.playlist]).first(); final radios = pages - .expand((e) => e.items?.toList() ?? []) - .toList() - .cast(); + .expand((e) => e.items?.cast().toList() ?? []) + .toList(); final artists = track.artists!.map((e) => e.name); @@ -170,30 +164,26 @@ class TrackOptions extends HookConsumerWidget { 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); + 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(BlackListNotifier.provider); + final blacklist = ref.watch(blacklistProvider); + final me = ref.watch(meProvider); final favorites = useTrackToggleLike(track, ref); final isBlackListed = useMemoized( - () => blacklist.contains( - BlacklistedElement.track( - track.id!, - track.name!, - ), + () => blacklist.asData?.value.any( + (element) => element.elementId == track.id, ), [blacklist, track], ); final removingTrack = useState(null); - final removeTrack = useMutations.playlist.removeTrackOf( - ref, - playlistId ?? "", - ); + final favoritePlaylistsNotifier = + ref.watch(favoritePlaylistsProvider.notifier); final isInQueue = useMemoized(() { if (playlist.activeTrack == null) return false; @@ -209,6 +199,8 @@ class TrackOptions extends HookConsumerWidget { return downloadManager.getProgressNotifier(spotubeTrack); }); + final isLocalTrack = track is LocalTrack; + final adaptivePopSheetList = AdaptivePopSheetList( onSelected: (value) async { switch (value) { @@ -220,7 +212,7 @@ class TrackOptions extends HookConsumerWidget { break; case TrackOptionValue.delete: await File((track as LocalTrack).path).delete(); - ref.refresh(localTracksProvider); + ref.invalidate(localTracksProvider); break; case TrackOptionValue.addToQueue: await playback.addTrack(track); @@ -257,23 +249,27 @@ class TrackOptions extends HookConsumerWidget { ); break; case TrackOptionValue.favorite: - favorites.toggleTrackLike.mutate(favorites.isLiked); + favorites.toggleTrackLike(track); break; case TrackOptionValue.addToPlaylist: actionAddToPlaylist(context, track); break; case TrackOptionValue.removeFromPlaylist: removingTrack.value = track.uri; - removeTrack.mutate(track.uri!); + favoritePlaylistsNotifier + .removeTracks(playlistId ?? "", [track.id!]); break; case TrackOptionValue.blacklist: - if (isBlackListed) { - ref.read(BlackListNotifier.provider.notifier).remove( - BlacklistedElement.track(track.id!, track.name!), - ); + if (isBlackListed == null) break; + if (isBlackListed == true) { + await ref.read(blacklistProvider.notifier).remove(track.id!); } else { - ref.read(BlackListNotifier.provider.notifier).add( - BlacklistedElement.track(track.id!, track.name!), + await ref.read(blacklistProvider.notifier).add( + BlacklistTableCompanion.insert( + name: track.name!, + elementId: track.id!, + elementType: BlacklistedType.track, + ), ); } break; @@ -307,8 +303,8 @@ class TrackOptions extends HookConsumerWidget { child: ClipRRect( borderRadius: BorderRadius.circular(10), child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString(track.album!.images, - placeholder: ImagePlaceholder.albumArt), + path: track.album!.images + .asUrlString(placeholder: ImagePlaceholder.albumArt), fit: BoxFit.cover, ), ), @@ -321,127 +317,133 @@ class TrackOptions extends HookConsumerWidget { ), subtitle: Align( alignment: Alignment.centerLeft, - child: TypeConversionUtils.artists_X_ClickableArtists( - track.artists!, + child: ArtistLink( + artists: track.artists!, + onOverflowArtistClick: () => ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": track.id!, + }, + ), ), ), ), ], - 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), + children: [ + if (isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.delete, + leading: const Icon(SpotubeIcons.trash), + title: Text(context.l10n.delete), + ), + if (mediaQuery.smAndDown && !isLocalTrack) + 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 (me.asData?.value != null && !isLocalTrack) + 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, ), - 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, - ), + ), + if (auth.asData?.value != null && !isLocalTrack) ...[ + 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.asData?.value != null && !isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.removeFromPlaylist, + leading: const Icon(SpotubeIcons.removeFilled), + title: Text(context.l10n.remove_from_playlist), + ), + if (!isLocalTrack) + 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), + ), + if (!isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.blacklist, + leading: const Icon(SpotubeIcons.playlistRemove), + iconColor: isBlackListed != true ? Colors.red[400] : null, + textColor: isBlackListed != true ? Colors.red[400] : null, + title: Text( + isBlackListed == true + ? context.l10n.remove_from_blacklist + : context.l10n.add_to_blacklist, ), - PopSheetEntry( - value: TrackOptionValue.share, - leading: const Icon(SpotubeIcons.share), - title: Text(context.l10n.share), + ), + if (!isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.share, + leading: const Icon(SpotubeIcons.share), + title: Text(context.l10n.share), + ), + if (!isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.songlink, + leading: Assets.logos.songlinkTransparent.image( + width: 22, + height: 22, + color: colorScheme.onSurface.withOpacity(0.5), ), - 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), - ), - ] - }, + title: Text(context.l10n.song_link), + ), + if (!isLocalTrack) + 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 diff --git a/lib/components/track_tile/track_tile.dart b/lib/components/track_tile/track_tile.dart new file mode 100644 index 00000000..8ab889f8 --- /dev/null +++ b/lib/components/track_tile/track_tile.dart @@ -0,0 +1,289 @@ +import 'dart:async'; + +import 'package:flutter/gestures.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:skeletonizer/skeletonizer.dart'; +import 'package:spotify/spotify.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/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/pages/track/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'; +import 'package:spotube/utils/service_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 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) => 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: (track.album?.images).asUrlString( + 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: Consumer( + builder: (context, ref, _) { + final isFetchingActiveTrack = + ref.watch(queryingTrackInfoProvider); + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: (isPlaying && isFetchingActiveTrack) || + 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: switch (track) { + LocalTrack() => Text( + track.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + _ => 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) { + 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( + track.artists?.asString() ?? '', + ) + : ClipRect( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 40), + child: ArtistLink( + artists: track.artists ?? [], + onOverflowArtistClick: () => ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": 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/shared/tracks_view/sections/body/track_view_body.dart b/lib/components/tracks_view/sections/body/track_view_body.dart similarity index 54% rename from lib/components/shared/tracks_view/sections/body/track_view_body.dart rename to lib/components/tracks_view/sections/body/track_view_body.dart index 33c8fa82..0f161b0c 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_body.dart +++ b/lib/components/tracks_view/sections/body/track_view_body.dart @@ -8,23 +8,29 @@ 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/components/dialogs/select_device_dialog.dart'; +import 'package:spotube/components/expandable_search/expandable_search.dart'; +import 'package:spotube/components/track_tile/track_tile.dart'; +import 'package:spotube/components/tracks_view/sections/body/track_view_body_headers.dart'; +import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart'; +import 'package:spotube/components/tracks_view/track_view_props.dart'; +import 'package:spotube/components/tracks_view/track_view_provider.dart'; +import 'package:spotube/extensions/list.dart'; +import 'package:spotube/models/connect/connect.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/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); + const TrackViewBodySection({super.key}); @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final playlist = ref.watch(audioPlayerProvider); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryActionsProvider); final props = InheritedTrackView.of(context); final trackViewState = ref.watch(trackViewProvider(props.tracks)); @@ -60,6 +66,56 @@ class TrackViewBodySection extends HookConsumerWidget { final isActive = playlist.collections.contains(props.collectionId); + final onTapTrackTile = useCallback((Track track, int index) async { + if (trackViewState.isSelecting) { + trackViewState.toggleTrackSelection(track.id!); + return; + } + + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final remoteQueue = ref.read(queueProvider); + if (remoteQueue.collections.contains(props.collectionId) || + remoteQueue.tracks.any((s) => s.id == track.id)) { + await playlistNotifier.jumpToTrack(track); + } else { + final tracks = await props.pagination.onFetchAll(); + await remotePlayback.load( + props.collection is AlbumSimple + ? WebSocketLoadEventData.album( + tracks: tracks, + collection: props.collection as AlbumSimple, + initialIndex: index, + ) + : WebSocketLoadEventData.playlist( + tracks: tracks, + collection: props.collection as PlaylistSimple, + initialIndex: index, + ), + ); + } + } else { + if (isActive || playlist.tracks.containsBy(track, (a) => a.id)) { + await playlistNotifier.jumpToTrack(track); + } else { + final tracks = await props.pagination.onFetchAll(); + await playlistNotifier.load( + tracks, + initialIndex: index, + autoPlay: true, + ); + playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier.addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists([props.collection as PlaylistSimple]); + } + } + } + }, [isActive, playlist, props, playlistNotifier, historyNotifier]); + return SliverMainAxisGroup( slivers: [ SliverToBoxAdapter( @@ -89,6 +145,7 @@ class TrackViewBodySection extends HookConsumerWidget { loadingBuilder: (context) => Skeletonizer( enabled: true, child: TrackTile( + playlist: playlist, track: FakeData.track, index: 0, ), @@ -98,13 +155,18 @@ class TrackViewBodySection extends HookConsumerWidget { child: Column( children: List.generate( 10, - (index) => TrackTile(track: FakeData.track, index: index), + (index) => TrackTile( + track: FakeData.track, + index: index, + playlist: playlist, + ), ), ), ), itemBuilder: (context, index) { final track = tracks[index]; return TrackTile( + playlist: playlist, track: track, index: index, selected: trackViewState.selectedTrackIds.contains(track.id!), @@ -119,24 +181,7 @@ class TrackViewBodySection extends HookConsumerWidget { 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); - } - }, + onTap: () => onTapTrackTile(track, index), ); }, ), diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart b/lib/components/tracks_view/sections/body/track_view_body_headers.dart similarity index 85% rename from lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart rename to lib/components/tracks_view/sections/body/track_view_body_headers.dart index 7e4522a0..82cc7706 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart +++ b/lib/components/tracks_view/sections/body/track_view_body_headers.dart @@ -1,22 +1,24 @@ import 'package:flutter/material.dart'; +import 'package:gap/gap.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/components/expandable_search/expandable_search.dart'; +import 'package:spotube/components/sort_tracks_dropdown.dart'; +import 'package:spotube/components/tracks_view/sections/body/track_view_options.dart'; +import 'package:spotube/components/tracks_view/track_view_props.dart'; +import 'package:spotube/components/tracks_view/track_view_provider.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/utils/platform.dart'; class TrackViewBodyHeaders extends HookConsumerWidget { final ValueNotifier isFiltering; final FocusNode searchFocus; const TrackViewBodyHeaders({ - Key? key, + super.key, required this.isFiltering, required this.searchFocus, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { @@ -94,6 +96,7 @@ class TrackViewBodyHeaders extends HookConsumerWidget { }, ), const TrackViewBodyOptions(), + if (kIsDesktop) const Gap(10), ], ); }, diff --git a/lib/components/shared/tracks_view/sections/body/track_view_options.dart b/lib/components/tracks_view/sections/body/track_view_options.dart similarity index 74% rename from lib/components/shared/tracks_view/sections/body/track_view_options.dart rename to lib/components/tracks_view/sections/body/track_view_options.dart index 583c9107..23198aec 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_options.dart +++ b/lib/components/tracks_view/sections/body/track_view_options.dart @@ -1,19 +1,21 @@ import 'package:flutter/material.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/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/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/tracks_view/track_view_props.dart'; +import 'package:spotube/components/tracks_view/track_view_provider.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/proxy_playlist/proxy_playlist_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'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; class TrackViewBodyOptions extends HookConsumerWidget { - const TrackViewBodyOptions({Key? key}) : super(key: key); + const TrackViewBodyOptions({super.key}); @override Widget build(BuildContext context, ref) { @@ -22,7 +24,8 @@ class TrackViewBodyOptions extends HookConsumerWidget { ref.watch(downloadManagerProvider); final downloader = ref.watch(downloadManagerProvider.notifier); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryActionsProvider); final audioSource = ref.watch(userPreferencesProvider.select((s) => s.audioSource)); @@ -72,6 +75,12 @@ class TrackViewBodyOptions extends HookConsumerWidget { { playlistNotifier.addTracksAtFirst(selectedTracks); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier.addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([props.collection as PlaylistSimple]); + } trackViewState.deselectAll(); break; } @@ -79,6 +88,12 @@ class TrackViewBodyOptions extends HookConsumerWidget { { playlistNotifier.addTracks(selectedTracks); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier.addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([props.collection as PlaylistSimple]); + } trackViewState.deselectAll(); break; } diff --git a/lib/components/tracks_view/sections/body/use_is_user_playlist.dart b/lib/components/tracks_view/sections/body/use_is_user_playlist.dart new file mode 100644 index 00000000..2f87ccc8 --- /dev/null +++ b/lib/components/tracks_view/sections/body/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/shared/tracks_view/sections/header/flexible_header.dart b/lib/components/tracks_view/sections/header/flexible_header.dart similarity index 88% rename from lib/components/shared/tracks_view/sections/header/flexible_header.dart rename to lib/components/tracks_view/sections/header/flexible_header.dart index 19241dc6..508d289c 100644 --- a/lib/components/shared/tracks_view/sections/header/flexible_header.dart +++ b/lib/components/tracks_view/sections/header/flexible_header.dart @@ -1,20 +1,21 @@ 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:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/tracks_view/sections/header/header_actions.dart'; +import 'package:spotube/components/tracks_view/sections/header/header_buttons.dart'; +import 'package:spotube/components/tracks_view/track_view_props.dart'; import 'package:gap/gap.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/string.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; +import 'package:spotube/utils/platform.dart'; class TrackViewFlexHeader extends HookConsumerWidget { - const TrackViewFlexHeader({Key? key}) : super(key: key); + const TrackViewFlexHeader({super.key}); @override Widget build(BuildContext context, ref) { @@ -23,8 +24,6 @@ class TrackViewFlexHeader extends HookConsumerWidget { final defaultTextStyle = DefaultTextStyle.of(context); final mediaQuery = MediaQuery.of(context); - final description = useDescription(props.description); - final palette = usePaletteColor(props.image, ref); return IconTheme( @@ -53,7 +52,7 @@ class TrackViewFlexHeader extends HookConsumerWidget { floating: false, pinned: true, expandedHeight: 450, - automaticallyImplyLeading: DesktopTools.platform.isMobile, + automaticallyImplyLeading: kIsMobile, backgroundColor: palette.color, title: isExpanded ? null : Text(props.title, style: headingStyle), flexibleSpace: FlexibleSpaceBar( @@ -126,10 +125,12 @@ class TrackViewFlexHeader extends HookConsumerWidget { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 10), - if (description != null && - description.isNotEmpty) + if (props.description != null && + props.description!.isNotEmpty) Text( - description, + props.description! + .unescapeHtml() + .cleanHtml(), style: defaultTextStyle.style.copyWith( color: palette.bodyTextColor, diff --git a/lib/components/shared/tracks_view/sections/header/header_actions.dart b/lib/components/tracks_view/sections/header/header_actions.dart similarity index 67% rename from lib/components/shared/tracks_view/sections/header/header_actions.dart rename to lib/components/tracks_view/sections/header/header_actions.dart index 75aa3f61..8e378f97 100644 --- a/lib/components/shared/tracks_view/sections/header/header_actions.dart +++ b/lib/components/tracks_view/sections/header/header_actions.dart @@ -2,24 +2,27 @@ 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:spotify/spotify.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/modules/playlist/playlist_create_dialog.dart'; +import 'package:spotube/components/heart_button/heart_button.dart'; +import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart'; +import 'package:spotube/components/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'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/history/history.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; class TrackViewHeaderActions extends HookConsumerWidget { - const TrackViewHeaderActions({Key? key}) : super(key: key); + const TrackViewHeaderActions({super.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 playlist = ref.watch(audioPlayerProvider); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryActionsProvider); final isActive = playlist.collections.contains(props.collectionId); @@ -27,7 +30,10 @@ class TrackViewHeaderActions extends HookConsumerWidget { final scaffoldMessenger = ScaffoldMessenger.of(context); - final auth = ref.watch(AuthenticationNotifier.provider); + final auth = ref.watch(authenticationProvider); + + final copiedText = + context.l10n.copied_shareurl_to_clipboard(props.shareUrl); return Row( mainAxisSize: MainAxisSize.min, @@ -45,7 +51,7 @@ class TrackViewHeaderActions extends HookConsumerWidget { width: 300, behavior: SnackBarBehavior.floating, content: Text( - "Copied ${props.shareUrl} to clipboard", + copiedText, textAlign: TextAlign.center, ), ), @@ -61,9 +67,16 @@ class TrackViewHeaderActions extends HookConsumerWidget { final tracks = await props.pagination.onFetchAll(); await playlistNotifier.addTracks(tracks); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier + .addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([props.collection as PlaylistSimple]); + } }, ), - if (props.onHeart != null && auth != null) + if (props.onHeart != null && auth.asData?.value != null) HeartButton( isLiked: props.isLiked, icon: isUserPlaylist ? SpotubeIcons.trash : null, diff --git a/lib/components/tracks_view/sections/header/header_buttons.dart b/lib/components/tracks_view/sections/header/header_buttons.dart new file mode 100644 index 00000000..54e0f0cf --- /dev/null +++ b/lib/components/tracks_view/sections/header/header_buttons.dart @@ -0,0 +1,206 @@ +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:spotify/spotify.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/dialogs/select_device_dialog.dart'; +import 'package:spotube/components/tracks_view/track_view_props.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/connect/connect.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/services/audio_player/audio_player.dart'; + +class TrackViewHeaderButtons extends HookConsumerWidget { + final PaletteColor color; + final bool compact; + const TrackViewHeaderButtons({ + super.key, + required this.color, + this.compact = false, + }); + + @override + Widget build(BuildContext context, ref) { + final props = InheritedTrackView.of(context); + final playlist = ref.watch(audioPlayerProvider); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryActionsProvider); + + 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 initialTracks = props.tracks; + if (!context.mounted) return; + + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final allTracks = await props.pagination.onFetchAll(); + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + props.collection is AlbumSimple + ? WebSocketLoadEventData.album( + tracks: allTracks, + collection: props.collection as AlbumSimple, + initialIndex: Random().nextInt(allTracks.length)) + : WebSocketLoadEventData.playlist( + tracks: allTracks, + collection: props.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(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier.addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists([props.collection as PlaylistSimple]); + } + + final allTracks = await props.pagination.onFetchAll(); + + await playlistNotifier.addTracks( + allTracks.sublist(initialTracks.length), + ); + } + } finally { + isLoading.value = false; + } + } + + void onPlay() async { + try { + isLoading.value = true; + + final initialTracks = props.tracks; + + if (!context.mounted) return; + + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final allTracks = await props.pagination.onFetchAll(); + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + props.collection is AlbumSimple + ? WebSocketLoadEventData.album( + tracks: allTracks, + collection: props.collection as AlbumSimple, + ) + : WebSocketLoadEventData.playlist( + tracks: allTracks, + collection: props.collection as PlaylistSimple, + ), + ); + } else { + await playlistNotifier.load(initialTracks, autoPlay: true); + playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier.addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists([props.collection as PlaylistSimple]); + } + + final allTracks = await props.pagination.onFetchAll(); + + await playlistNotifier.addTracks( + allTracks.sublist(initialTracks.length), + ); + } + } finally { + if (context.mounted) { + 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/tracks_view/track_view.dart similarity index 69% rename from lib/components/shared/tracks_view/track_view.dart rename to lib/components/tracks_view/track_view.dart index 4103573c..2a3f5237 100644 --- a/lib/components/shared/tracks_view/track_view.dart +++ b/lib/components/tracks_view/track_view.dart @@ -1,16 +1,17 @@ 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'; +import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; +import 'package:spotube/components/tracks_view/sections/header/flexible_header.dart'; +import 'package:spotube/components/tracks_view/sections/body/track_view_body.dart'; +import 'package:spotube/components/tracks_view/track_view_props.dart'; +import 'package:spotube/utils/platform.dart'; class TrackView extends HookConsumerWidget { - const TrackView({Key? key}) : super(key: key); + const TrackView({super.key}); @override Widget build(BuildContext context, ref) { @@ -18,7 +19,7 @@ class TrackView extends HookConsumerWidget { final controller = useScrollController(); return Scaffold( - appBar: DesktopTools.platform.isDesktop + appBar: kIsDesktop ? const PageWindowTitleBar( backgroundColor: Colors.transparent, foregroundColor: Colors.white, diff --git a/lib/components/shared/tracks_view/track_view_props.dart b/lib/components/tracks_view/track_view_props.dart similarity index 82% rename from lib/components/shared/tracks_view/track_view_props.dart rename to lib/components/tracks_view/track_view_props.dart index 21bbaec7..b0a00ae2 100644 --- a/lib/components/shared/tracks_view/track_view_props.dart +++ b/lib/components/tracks_view/track_view_props.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart' hide Page; import 'package:spotify/spotify.dart'; @@ -19,19 +18,6 @@ class PaginationProps { 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 && @@ -53,7 +39,7 @@ class PaginationProps { } class InheritedTrackView extends InheritedWidget { - final String collectionId; + final Object collection; final String title; final String? description; final String image; @@ -69,7 +55,7 @@ class InheritedTrackView extends InheritedWidget { const InheritedTrackView({ super.key, required super.child, - required this.collectionId, + required this.collection, required this.title, this.description, required this.image, @@ -79,7 +65,11 @@ class InheritedTrackView extends InheritedWidget { required 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!; @override bool updateShouldNotify(InheritedTrackView oldWidget) { @@ -92,7 +82,7 @@ class InheritedTrackView extends InheritedWidget { oldWidget.onHeart != onHeart || oldWidget.shareUrl != shareUrl || oldWidget.routePath != routePath || - oldWidget.collectionId != collectionId || + oldWidget.collection != collection || oldWidget.child != child; } diff --git a/lib/components/shared/tracks_view/track_view_provider.dart b/lib/components/tracks_view/track_view_provider.dart similarity index 95% rename from lib/components/shared/tracks_view/track_view_provider.dart rename to lib/components/tracks_view/track_view_provider.dart index 14dc1136..16aa6d9c 100644 --- a/lib/components/shared/tracks_view/track_view_provider.dart +++ b/lib/components/tracks_view/track_view_provider.dart @@ -1,7 +1,7 @@ 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'; +import 'package:spotube/modules/library/user_local_tracks.dart'; class TrackViewNotifier extends ChangeNotifier { List tracks; 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/constrains.dart b/lib/extensions/constrains.dart index 1177f5ac..dc1027e2 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; 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/string.dart b/lib/extensions/string.dart index b7ab7514..94123fe3 100644 --- a/lib/extensions/string.dart +++ b/lib/extensions/string.dart @@ -1,11 +1,20 @@ 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 { + String capitalize() { + return "${this[0].toUpperCase()}${substring(1)}"; + } } diff --git a/lib/extensions/track.dart b/lib/extensions/track.dart index 51498b33..02c0c492 100644 --- a/lib/extensions/track.dart +++ b/lib/extensions/track.dart @@ -1,32 +1,70 @@ +import 'dart:io'; + +import 'package:metadata_god/metadata_god.dart'; +import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/album_simple.dart'; -import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; -extension TrackJson on Track { - Map toJson() { - return TrackJson.trackToJson(this); - } +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" + ]; - 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, - }; + id = metadata?.title ?? basenameWithoutExtension(file.path); + name = metadata?.title ?? basenameWithoutExtension(file.path); + type = "track"; + uri = file.path; + durationMs = (metadata?.durationMs?.toInt() ?? 0); + + return this; + } +} + +extension TrackSimpleExtensions on TrackSimple { + Track asTrack(AlbumSimple album) { + Track track = Track(); + track.name = name; + track.album = album; + track.artists = artists; + track.availableMarkets = availableMarkets; + track.discNumber = discNumber; + track.durationMs = durationMs; + track.explicit = explicit; + track.externalUrls = externalUrls; + track.href = href; + track.id = id; + track.isPlayable = isPlayable; + track.linkedFrom = linkedFrom; + track.name = name; + track.previewUrl = previewUrl; + track.trackNumber = trackNumber; + track.type = type; + track.uri = uri; + return track; + } +} + +extension TracksToMediaExtension on Iterable { + List asMediaList() { + return map((track) => SpotubeMedia(track)).toList(); } } 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..ec6d8516 100644 --- a/lib/hooks/configurators/use_deep_linking.dart +++ b/lib/hooks/configurators/use_deep_linking.dart @@ -1,24 +1,21 @@ 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: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) { // single instance no worries final spotify = ref.watch(spotifyProvider); - final queryClient = useQueryClient(); - final router = ref.watch(routerProvider); useEffect(() { @@ -32,10 +29,7 @@ void useDeepLinking(WidgetRef ref) { case "album": router.push( "/album/${url.pathSegments.last}", - extra: await queryClient.fetchQuery( - "album/${url.pathSegments.last}", - () => spotify.albums.get(url.pathSegments.last), - ), + extra: await spotify.albums.get(url.pathSegments.last), ); break; case "artist": @@ -44,10 +38,7 @@ void useDeepLinking(WidgetRef ref) { case "playlist": router.push( "/playlist/${url.pathSegments.last}", - extra: await queryClient.fetchQuery( - "playlist/${url.pathSegments.last}", - () => spotify.playlists.get(url.pathSegments.last), - ), + extra: await spotify.playlists.get(url.pathSegments.last), ); break; case "track": @@ -63,7 +54,7 @@ void useDeepLinking(WidgetRef ref) { StreamSubscription? mediaStream; - if (DesktopTools.platform.isMobile) { + if (kIsMobile) { FlutterSharingIntent.instance.getInitialSharing().then(uriListener); mediaStream = @@ -71,36 +62,34 @@ 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": + await router.push( + "/album/$endSegment", + extra: await 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 spotify.playlists.get(endSegment), + ); + break; + default: + break; + } + } catch (e, stack) { + AppLogger.reportError(e, stack); } }); @@ -108,5 +97,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 c1155d19..4aa51b74 100644 --- a/lib/hooks/configurators/use_disable_battery_optimizations.dart +++ b/lib/hooks/configurators/use_disable_battery_optimizations.dart @@ -1,47 +1,21 @@ import 'package:disable_battery_optimization/disable_battery_optimization.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:spotube/hooks/utils/use_async_effect.dart'; -bool _asked = false; +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 || _asked) return; - final localStorage = await SharedPreferences.getInstance(); + if (!kIsAndroid || KVStoreService.askedForBatteryOptimization) return; - final rawIsBatteryOptimizationDisabled = - localStorage.getBool("isBatteryOptimizationDisabled"); - final isBatteryOptimizationDisabled = - await DisableBatteryOptimization.isBatteryOptimizationDisabled; - if (rawIsBatteryOptimizationDisabled != false && - isBatteryOptimizationDisabled == false) { - final hasDisabled = await DisableBatteryOptimization - .showDisableBatteryOptimizationSettings(); + await DisableBatteryOptimization.showDisableBatteryOptimizationSettings(); - localStorage.setBool( - "isBatteryOptimizationDisabled", - hasDisabled == true, - ); - } + await DisableBatteryOptimization + .showDisableManufacturerBatteryOptimizationSettings( + "Your device has additional battery optimization", + "Follow the steps and disable the optimizations to allow smooth functioning of this app", + ); - final rawIsManBatteryOptimizationDisabled = - localStorage.getBool("isManufacturerBatteryOptimizationDisabled"); - final isManBatteryOptimizationDisabled = await DisableBatteryOptimization - .isManufacturerBatteryOptimizationDisabled; - - if (rawIsManBatteryOptimizationDisabled != false && - isManBatteryOptimizationDisabled == false) { - final hasDisabled = await DisableBatteryOptimization - .showDisableManufacturerBatteryOptimizationSettings( - "Your device has additional battery optimization", - "Follow the steps and disable the optimizations to allow smooth functioning of this app", - ); - - localStorage.setBool( - "isManufacturerBatteryOptimizationDisabled", - hasDisabled == true, - ); - } - _asked = true; + await KVStoreService.setAskedForBatteryOptimization(true); }, null, []); } diff --git a/lib/hooks/configurators/use_endless_playback.dart b/lib/hooks/configurators/use_endless_playback.dart index f5d11829..e2fb1e6e 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/authentication/authentication.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/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.search + .get(query, types: [SearchType.playlist]).first(); final radios = pages .expand((e) => e.items?.toList() ?? []) @@ -68,22 +56,22 @@ void useEndlessPlayback(WidgetRef ref) { 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..a6603d59 --- /dev/null +++ b/lib/hooks/configurators/use_fix_window_stretching.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.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..75353f27 --- /dev/null +++ b/lib/hooks/configurators/use_has_touch.dart @@ -0,0 +1,27 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.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..0c7119e4 100644 --- a/lib/hooks/controllers/use_auto_scroll_controller.dart +++ b/lib/hooks/controllers/use_auto_scroll_controller.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..b3c05665 100644 --- a/lib/hooks/controllers/use_package_info.dart +++ b/lib/hooks/controllers/use_package_info.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_sidebarx_controller.dart b/lib/hooks/controllers/use_sidebarx_controller.dart index 5af921b7..a14c3305 100644 --- a/lib/hooks/controllers/use_sidebarx_controller.dart +++ b/lib/hooks/controllers/use_sidebarx_controller.dart @@ -24,8 +24,8 @@ class _SidebarXControllerHook extends Hook { const _SidebarXControllerHook({ required this.selectedIndex, this.extended, - List? keys, - }) : super(keys: keys); + super.keys, + }); final int selectedIndex; final bool? extended; 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_custom_status_bar_color.dart b/lib/hooks/utils/use_custom_status_bar_color.dart index d1266fe2..7c5c7b27 100644 --- a/lib/hooks/utils/use_custom_status_bar_color.dart +++ b/lib/hooks/utils/use_custom_status_bar_color.dart @@ -19,11 +19,13 @@ void useCustomStatusBarColor( ), ); + // 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 +45,7 @@ void useCustomStatusBarColor( }); return () { if (automaticSystemUiAdjustment != null) { + // ignore: deprecated_member_use WidgetsBinding.instance.renderView.automaticSystemUiAdjustment = false; } }; 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..64994d2b 100644 --- a/lib/hooks/utils/use_palette_color.dart +++ b/lib/hooks/utils/use_palette_color.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.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) { @@ -14,7 +14,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 +24,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 +40,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 +51,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 eebede99..141e10f0 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -286,5 +286,106 @@ "step_3_steps": "انسخ قيمة الكوكي \"sp_dc\"", "step_4_steps": "الصق قيمة \"sp_dc\" المنسوخة", "friends": "أصدقاء", - "no_lyrics_available": "عذرًا، تعذر العثور على كلمات الأغنية لهذه العنصر" + "no_lyrics_available": "عذرًا، تعذر العثور على كلمات الأغنية لهذه العنصر", + "sort_duration": "ترتيب حسب المدة", + "start_a_radio": "بدء راديو", + "how_to_start_radio": "كيف تريد بدء الراديو؟", + "replace_queue_question": "هل تريد استبدال قائمة التشغيل الحالية أم إضافة إليها؟", + "endless_playback": "تشغيل بلا نهاية", + "delete_playlist": "حذف قائمة التشغيل", + "delete_playlist_confirmation": "هل أنت متأكد أنك تريد حذف هذه قائمة التشغيل؟", + "local_tracks": "المسارات المحلية", + "song_link": "رابط الأغنية", + "skip_this_nonsense": "تخطي هذه الهراء", + "freedom_of_music": "“حرية الموسيقى”", + "freedom_of_music_palm": "“حرية الموسيقى في متناول يدك”", + "get_started": "لنبدأ", + "youtube_source_description": "موصى به ويعمل بشكل أفضل.", + "piped_source_description": "تشعر بالحرية؟ نفس يوتيوب ولكن أكثر حرية.", + "jiosaavn_source_description": "الأفضل لمنطقة جنوب آسيا.", + "highest_quality": "أعلى جودة: {quality}", + "select_audio_source": "اختر مصدر الصوت", + "endless_playback_description": "إلحاق الأغاني الجديدة تلقائيًا\nإلى نهاية قائمة التشغيل", + "choose_your_region": "اختر منطقتك", + "choose_your_region_description": "سيساعدك هذا في عرض المحتوى المناسب\nلموقعك.", + "choose_your_language": "اختر لغتك", + "help_project_grow": "ساعد في نمو هذا المشروع", + "help_project_grow_description": "Spotube هو مشروع مفتوح المصدر. يمكنك مساعدة هذا المشروع في النمو عن طريق المساهمة في المشروع، أو الإبلاغ عن الأخطاء، أو اقتراح ميزات جديدة.", + "contribute_on_github": "المساهمة على GitHub", + "donate_on_open_collective": "التبرع على Open Collective", + "browse_anonymously": "تصفح بشكل مجهول", + "enable_connect": "تمكين الاتصال", + "enable_connect_description": "التحكم في Spotube من الأجهزة الأخرى", + "devices": "الأجهزة", + "select": "اختر", + "connect_client_alert": "أنت تتم التحكم بواسطة {client}", + "this_device": "هذا الجهاز", + "remote": "بعيد", + "local_library": "المكتبة المحلية", + "add_library_location": "أضف إلى المكتبة", + "remove_library_location": "إزالة من المكتبة", + "local_tab": "محلي", + "stats": "إحصائيات", + "and_n_more": "و {count} أكثر", + "recently_played": "تم تشغيله مؤخرًا", + "browse_more": "تصفح المزيد", + "no_title": "بدون عنوان", + "not_playing": "غير مشغل", + "epic_failure": "فشل كبير!", + "added_num_tracks_to_queue": "تمت إضافة {tracks_length} مسارات إلى قائمة الانتظار", + "spotube_has_an_update": "يوجد تحديث لسبوتيوب", + "download_now": "تحميل الآن", + "nightly_version": "تم إصدار سبوتيوب الليلي {nightlyBuildNum}", + "release_version": "تم إصدار سبوتيوب v{version}", + "read_the_latest": "اقرأ الأحدث", + "release_notes": "ملاحظات الإصدار", + "pick_color_scheme": "اختر نظام الألوان", + "save": "حفظ", + "choose_the_device": "اختر الجهاز:", + "multiple_device_connected": "تم توصيل أجهزة متعددة.\nاختر الجهاز الذي تريد إجراء هذه العملية عليه", + "nothing_found": "لم يتم العثور على شيء", + "the_box_is_empty": "الصندوق فارغ", + "top_artists": "أفضل الفنانين", + "top_albums": "أفضل الألبومات", + "this_week": "هذا الأسبوع", + "this_month": "هذا الشهر", + "last_6_months": "آخر 6 أشهر", + "this_year": "هذا العام", + "last_2_years": "آخر سنتين", + "all_time": "كل الوقت", + "powered_by_provider": "مدعوم من {providerName}", + "email": "البريد الإلكتروني", + "profile_followers": "المتابعين", + "birthday": "عيد الميلاد", + "subscription": "اشتراك", + "not_born": "لم يولد", + "hacker": "هاكر", + "profile": "الملف الشخصي", + "no_name": "بدون اسم", + "edit": "تعديل", + "user_profile": "ملف المستخدم", + "count_plays": "{count} تشغيلات", + "streaming_fees_hypothetical": "رسوم البث (افتراضية)", + "minutes_listened": "الدقائق المستمعة", + "streamed_songs": "الأغاني المذاعة", + "count_streams": "{count} بث", + "owned_by_you": "مملوك لك", + "copied_shareurl_to_clipboard": "تم نسخ {shareUrl} إلى الحافظة", + "spotify_hipotetical_calculation": "*هذا محسوب بناءً على الدفع لكل بث من سبوتيفاي\nبقيمة 0.003 إلى 0.005 دولار. هذا حساب افتراضي\nلإعطاء المستخدم فكرة عن المبلغ الذي\nكان سيدفعه للفنانين إذا كانوا قد استمعوا\nإلى أغنيتهم على سبوتيفاي.", + "count_mins": "{minutes} دقيقة", + "summary_minutes": "الدقائق", + "summary_listened_to_music": "استمعت إلى الموسيقى", + "summary_songs": "أغاني", + "summary_streamed_overall": "بث بشكل عام", + "summary_owed_to_artists": "مدين للفنانين\nهذا الشهر", + "summary_artists": "الفنانين", + "summary_music_reached_you": "وصلت إليك الموسيقى", + "summary_full_albums": "ألبومات كاملة", + "summary_got_your_love": "حصلت على حبك", + "summary_playlists": "قوائم التشغيل", + "summary_were_on_repeat": "كانت على التكرار", + "total_money": "المجموع {money}", + "webview_not_found": "لم يتم العثور على Webview", + "webview_not_found_description": "لم يتم تثبيت بيئة تشغيل Webview على جهازك.\nإذا كانت مثبتة، تأكد من وجودها في environment PATH\n\nبعد التثبيت، أعد تشغيل التطبيق", + "unsupported_platform": "المنصة غير مدعومة" } \ No newline at end of file diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 2711f8d2..ae088b45 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -286,5 +286,106 @@ "step_3_steps": "কুকি \"sp_dc\" এর মানটি কপি করুন", "step_4_steps": "কপি করা \"sp_dc\" মানটি পেস্ট করুন", "friends": "বন্ধু", - "no_lyrics_available": "দুঃখিত, এই ট্র্যাকের জন্য কথা খুঁজে পাওয়া গেলনা" + "no_lyrics_available": "দুঃখিত, এই ট্র্যাকের জন্য কথা খুঁজে পাওয়া গেলনা", + "sort_duration": "দৈর্ঘ্য অনুযায়ী বাছাই করুন", + "start_a_radio": "রেডিও শুরু করুন", + "how_to_start_radio": "রেডিও কিভাবে শুরু করতে চান?", + "replace_queue_question": "আপনি বর্তমান কিউটি প্রতিস্থাপন করতে চান কিনা বা এর সাথে যুক্ত করতে চান?", + "endless_playback": "অবিরাম প্রচার", + "delete_playlist": "প্লেলিস্ট মুছুন", + "delete_playlist_confirmation": "আপনি কি নিশ্চিত যে আপনি এই প্লেলিস্টটি মুছতে চান?", + "local_tracks": "স্থানীয় ট্র্যাক", + "song_link": "গানের লিংক", + "skip_this_nonsense": "এই বাকবাস পালান", + "freedom_of_music": "“সংগীতের স্বাধীনতা”", + "freedom_of_music_palm": "“তোমার হাতের কাছে সংগীতের স্বাধীনতা”", + "get_started": "শুরু করা যাক", + "youtube_source_description": "প্রস্তাবিত এবং সেরা কাজ করে।", + "piped_source_description": "মন খারাপ? ইউটিউবের মতো আবার ফ্রি।", + "jiosaavn_source_description": "দক্ষিণ এশিয়ান অঞ্চলের জন্য সেরা।", + "highest_quality": "সর্বোচ্চ গুণগতি: {quality}", + "select_audio_source": "অডিও উৎস নির্বাচন করুন", + "endless_playback_description": "নতুন গান নিজে নিজে প্লেলিস্টের শেষে\nসংযুক্ত করুন", + "choose_your_region": "আপনার অঞ্চল নির্বাচন করুন", + "choose_your_region_description": "এটি স্পটুবে আপনাকে আপনার অবস্থানের জন্য ঠিক কন্টেন্ট দেখানোর সাহায্য করবে।", + "choose_your_language": "আপনার ভাষা নির্বাচন করুন", + "help_project_grow": "এই প্রকল্পের বৃদ্ধি করুন", + "help_project_grow_description": "স্পটুব একটি ওপেন সোর্স প্রকল্প। আপনি প্রকল্পে অবদান রাখেন, বাগ রিপোর্ট করেন, বা নতুন বৈশিষ্ট্যগুলি সুপারিশ করেন।", + "contribute_on_github": "গিটহাবে অবদান রাখুন", + "donate_on_open_collective": "ওপেন কলেক্টিভে অনুদান করুন", + "browse_anonymously": "অজানে ব্রাউজ করুন", + "enable_connect": "সংযোগ সক্রিয় করুন", + "enable_connect_description": "অন্যান্য ডিভাইস থেকে Spotube নিয়ন্ত্রণ করুন", + "devices": "ডিভাইস", + "select": "নির্বাচন করুন", + "connect_client_alert": "আপনি {client} দ্বারা নিয়ন্ত্রিত হচ্ছেন", + "this_device": "এই ডিভাইস", + "remote": "রিমোট", + "local_library": "স্থানীয় লাইব্রেরি", + "add_library_location": "লাইব্রেরিতে যোগ করুন", + "remove_library_location": "লাইব্রেরি থেকে সরান", + "local_tab": "স্থানীয়", + "stats": "পরিসংখ্যান", + "and_n_more": "এবং {count} আরও", + "recently_played": "সম্প্রতি বাজানো", + "browse_more": "আরও ব্রাউজ করুন", + "no_title": "কোনো শিরোনাম নেই", + "not_playing": "চালানো হচ্ছে না", + "epic_failure": "বিরাট ব্যর্থতা!", + "added_num_tracks_to_queue": "{tracks_length} ট্র্যাক সারিতে যোগ করা হয়েছে", + "spotube_has_an_update": "স্পটিউবে একটি আপডেট আছে", + "download_now": "এখনই ডাউনলোড করুন", + "nightly_version": "স্পটিউব নাইটলি {nightlyBuildNum} প্রকাশিত হয়েছে", + "release_version": "স্পটিউব v{version} প্রকাশিত হয়েছে", + "read_the_latest": "সর্বশেষ পড়ুন", + "release_notes": "রিলিজ নোট", + "pick_color_scheme": "রঙের থিম নির্বাচন করুন", + "save": "সংরক্ষণ করুন", + "choose_the_device": "ডিভাইস নির্বাচন করুন:", + "multiple_device_connected": "একাধিক ডিভাইস সংযুক্ত রয়েছে।\nযে ডিভাইসে আপনি এই ক্রিয়াটি চালাতে চান সেটি নির্বাচন করুন", + "nothing_found": "কিছুই পাওয়া যায়নি", + "the_box_is_empty": "বাক্সটি খালি", + "top_artists": "শীর্ষ শিল্পী", + "top_albums": "শীর্ষ অ্যালবাম", + "this_week": "এই সপ্তাহ", + "this_month": "এই মাস", + "last_6_months": "গত ৬ মাস", + "this_year": "এই বছর", + "last_2_years": "গত ২ বছর", + "all_time": "সব সময়", + "powered_by_provider": "{providerName} দ্বারা চালিত", + "email": "ইমেইল", + "profile_followers": "অনুসারী", + "birthday": "জন্মদিন", + "subscription": "সাবস্ক্রিপশন", + "not_born": "জন্মগ্রহণ করেনি", + "hacker": "হ্যাকার", + "profile": "প্রোফাইল", + "no_name": "কোন নাম নেই", + "edit": "সম্পাদনা করুন", + "user_profile": "ব্যবহারকারীর প্রোফাইল", + "count_plays": "{count} বার প্লে হয়েছে", + "streaming_fees_hypothetical": "স্ট্রিমিং ফি (ধারণাগত)", + "minutes_listened": "শুনেছেন মিনিট", + "streamed_songs": "স্ট্রিম করা গান", + "count_streams": "{count} বার স্ট্রিম", + "owned_by_you": "আপনার মালিকানাধীন", + "copied_shareurl_to_clipboard": "{shareUrl} ক্লিপবোর্ডে কপি করা হয়েছে", + "spotify_hipotetical_calculation": "*এটি স্পোটিফাইয়ের প্রতি স্ট্রিম\n$0.003 থেকে $0.005 পেআউটের ভিত্তিতে গণনা করা হয়েছে। এটি একটি ধারণাগত\nগণনা ব্যবহারকারীদেরকে জানাতে দেয় যে কত টাকা\nতারা শিল্পীদের দিতো যদি তারা স্পোটিফাইতে\nতাদের গান শুনতেন।", + "count_mins": "{minutes} মিনিট", + "summary_minutes": "মিনিট", + "summary_listened_to_music": "সঙ্গীত শুনেছেন", + "summary_songs": "গান", + "summary_streamed_overall": "মোট স্ট্রিম", + "summary_owed_to_artists": "এই মাসে\nশিল্পীদেরকে ঋণী", + "summary_artists": "শিল্পীর", + "summary_music_reached_you": "আপনার কাছে পৌঁছেছে সঙ্গীত", + "summary_full_albums": "সম্পূর্ণ অ্যালবাম", + "summary_got_your_love": "আপনার ভালোবাসা পেয়েছে", + "summary_playlists": "প্লেলিস্ট", + "summary_were_on_repeat": "পুনরাবৃত্তিতে ছিল", + "total_money": "মোট {money}", + "webview_not_found": "ওয়েবভিউ পাওয়া যায়নি", + "webview_not_found_description": "আপনার ডিভাইসে কোনো ওয়েবভিউ রানটাইম ইনস্টল করা নেই।\nযদি ইনস্টল থাকে, তা নিশ্চিত করুন যে এটি environment PATH এ রয়েছে\n\nইনস্টল করার পর, অ্যাপটি পুনরায় চালু করুন", + "unsupported_platform": "সমর্থিত প্ল্যাটফর্ম নয়" } \ No newline at end of file diff --git a/lib/l10n/app_ca.arb b/lib/l10n/app_ca.arb index f46cfae4..58805e62 100644 --- a/lib/l10n/app_ca.arb +++ b/lib/l10n/app_ca.arb @@ -286,5 +286,106 @@ "step_3_steps": "Copia el valor de la cookie \"sp_dc\"", "step_4_steps": "Pega el valor copiado de \"sp_dc\"", "friends": "Amics", - "no_lyrics_available": "Ho sentim, no es poden trobar les lletres d'aquesta pista" + "no_lyrics_available": "Ho sentim, no es poden trobar les lletres d'aquesta pista", + "sort_duration": "Ordenar per Durada", + "start_a_radio": "Inicia una ràdio", + "how_to_start_radio": "Com vols començar la ràdio?", + "replace_queue_question": "Voleu substituir la cua actual o afegir-hi?", + "endless_playback": "Reproducció infinita", + "delete_playlist": "Suprimeix la llista de reproducció", + "delete_playlist_confirmation": "Esteu segur que voleu suprimir aquesta llista de reproducció?", + "local_tracks": "Pistes locals", + "song_link": "Enllaç de la cançó", + "skip_this_nonsense": "Omet aquesta tonteria", + "freedom_of_music": "“Llibertat de la música”", + "freedom_of_music_palm": "“Llibertat de la música a la palma de la mà”", + "get_started": "Comencem", + "youtube_source_description": "Recomanat i funciona millor.", + "piped_source_description": "Et sents lliure? El mateix que YouTube però més lliure.", + "jiosaavn_source_description": "El millor per a la regió del sud d'Àsia.", + "highest_quality": "Qualitat més alta: {quality}", + "select_audio_source": "Seleccioneu la font d'àudio", + "endless_playback_description": "Afegiu automàticament noves cançons\nal final de la cua", + "choose_your_region": "Trieu la vostra regió", + "choose_your_region_description": "Això ajudarà a Spotube a mostrar-vos el contingut adequat\nper a la vostra ubicació.", + "choose_your_language": "Trieu el vostre idioma", + "help_project_grow": "Ajuda a fer créixer aquest projecte", + "help_project_grow_description": "Spotube és un projecte de codi obert. Podeu ajudar a fer créixer aquest projecte contribuint al projecte, informant d'errors o suggerint noves funcionalitats.", + "contribute_on_github": "Contribueix a GitHub", + "donate_on_open_collective": "Fes una donació a Open Collective", + "browse_anonymously": "Navega de manera anònima", + "enable_connect": "Habilita la connexió", + "enable_connect_description": "Controla Spotube des d'altres dispositius", + "devices": "Dispositius", + "select": "Selecciona", + "connect_client_alert": "Estàs sent controlat per {client}", + "this_device": "Aquest dispositiu", + "remote": "Remot", + "local_library": "Biblioteca local", + "add_library_location": "Afegeix a la biblioteca", + "remove_library_location": "Elimina de la biblioteca", + "local_tab": "Local", + "stats": "Estadístiques", + "and_n_more": "i {count} més", + "recently_played": "Reproduït recentment", + "browse_more": "Navega més", + "no_title": "Sense títol", + "not_playing": "No s'està reproduint", + "epic_failure": "Fracàs èpic!", + "added_num_tracks_to_queue": "Afegit {tracks_length} pistes a la cua", + "spotube_has_an_update": "Spotube té una actualització", + "download_now": "Descarregar ara", + "nightly_version": "Spotube Nightly {nightlyBuildNum} ha estat publicat", + "release_version": "Spotube v{version} ha estat publicat", + "read_the_latest": "Llegeix el més recent", + "release_notes": "notes de la versió", + "pick_color_scheme": "Tria l'esquema de colors", + "save": "Desar", + "choose_the_device": "Tria el dispositiu:", + "multiple_device_connected": "Hi ha diversos dispositius connectats.\nTria el dispositiu on vols realitzar aquesta acció", + "nothing_found": "No s'ha trobat res", + "the_box_is_empty": "La caixa està buida", + "top_artists": "Millors artistes", + "top_albums": "Millors àlbums", + "this_week": "Aquesta setmana", + "this_month": "Aquest mes", + "last_6_months": "Últims 6 mesos", + "this_year": "Aquest any", + "last_2_years": "Últims 2 anys", + "all_time": "Tots els temps", + "powered_by_provider": "Funciona amb {providerName}", + "email": "Correu electrònic", + "profile_followers": "Seguidors", + "birthday": "Aniversari", + "subscription": "Subscripció", + "not_born": "No ha nascut", + "hacker": "Hacker", + "profile": "Perfil", + "no_name": "Sense nom", + "edit": "Editar", + "user_profile": "Perfil d'usuari", + "count_plays": "{count} reproduccions", + "streaming_fees_hypothetical": "Comissions de streaming (hipotètic)", + "minutes_listened": "minuts escoltats", + "streamed_songs": "cançons reproduïdes", + "count_streams": "{count} reproduccions", + "owned_by_you": "De la teva propietat", + "copied_shareurl_to_clipboard": "S'ha copiat {shareUrl} al porta-retalls", + "spotify_hipotetical_calculation": "*Això es calcula basant-se en els\npagaments per reproducció de Spotify de $0.003 a $0.005.\nAquest és un càlcul hipotètic per\ndonar als usuaris una idea de quant\nhaurien pagat als artistes si haguessin escoltat\nla seva cançó a Spotify.", + "count_mins": "{minutes} minuts", + "summary_minutes": "minuts", + "summary_listened_to_music": "has escoltat música", + "summary_songs": "cançons", + "summary_streamed_overall": "reproduït en general", + "summary_owed_to_artists": "degut als artistes\nAquest mes", + "summary_artists": "artistes", + "summary_music_reached_you": "La música t'ha arribat", + "summary_full_albums": "Àlbums complets", + "summary_got_your_love": "ha aconseguit el teu amor", + "summary_playlists": "llistes de reproducció", + "summary_were_on_repeat": "estaven en repetició", + "total_money": "total {money}", + "webview_not_found": "No s'ha trobat el Webview", + "webview_not_found_description": "No hi ha cap temps d'execució de Webview instal·lat al dispositiu.\nSi està instal·lat, assegureu-vos que estigui en el environment PATH\n\nDesprés d'instal·lar-lo, reinicieu l'aplicació", + "unsupported_platform": "Plataforma no compatible" } \ 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..99ee0962 --- /dev/null +++ b/lib/l10n/app_cs.arb @@ -0,0 +1,391 @@ +{ + "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" +} \ No newline at end of file diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index ebaa0329..36da0b3e 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -286,5 +286,106 @@ "step_3_steps": "Kopiere den Wert des Cookies \"sp_dc\"", "step_4_steps": "Füge den kopierten Wert von \"sp_dc\" ein", "friends": "Freunde", - "no_lyrics_available": "Entschuldigung, Texte für diesen Track konnten nicht gefunden werden" + "no_lyrics_available": "Entschuldigung, Texte für diesen Track konnten nicht gefunden werden", + "sort_duration": "Nach Dauer sortieren", + "start_a_radio": "Radio starten", + "how_to_start_radio": "Wie möchten Sie das Radio starten?", + "replace_queue_question": "Möchten Sie die aktuelle Wiedergabeliste ersetzen oder hinzufügen?", + "endless_playback": "Endlose Wiedergabe", + "delete_playlist": "Wiedergabeliste löschen", + "delete_playlist_confirmation": "Sind Sie sicher, dass Sie diese Wiedergabeliste löschen möchten?", + "local_tracks": "Lokale Titel", + "song_link": "Lied-Link", + "skip_this_nonsense": "Diesen Unsinn überspringen", + "freedom_of_music": "“Freiheit der Musik”", + "freedom_of_music_palm": "“Freiheit der Musik in Ihrer Handfläche”", + "get_started": "Lass uns anfangen", + "youtube_source_description": "Empfohlen und funktioniert am besten.", + "piped_source_description": "Fühlen Sie sich frei? Wie YouTube, aber viel freier.", + "jiosaavn_source_description": "Am besten für die südasiatische Region.", + "highest_quality": "Höchste Qualität: {quality}", + "select_audio_source": "Audioquelle auswählen", + "endless_playback_description": "Neue Lieder automatisch\nam Ende der Wiedergabeliste hinzufügen", + "choose_your_region": "Wählen Sie Ihre Region", + "choose_your_region_description": "Dies wird Spotube helfen, Ihnen den richtigen Inhalt\nfür Ihren Standort anzuzeigen.", + "choose_your_language": "Wählen Sie Ihre Sprache", + "help_project_grow": "Helfen Sie diesem Projekt zu wachsen", + "help_project_grow_description": "Spotube ist ein Open-Source-Projekt. Sie können diesem Projekt helfen, indem Sie zum Projekt beitragen, Fehler melden oder neue Funktionen vorschlagen.", + "contribute_on_github": "Auf GitHub beitragen", + "donate_on_open_collective": "Auf Open Collective spenden", + "browse_anonymously": "Anonym durchsuchen", + "enable_connect": "Verbindung aktivieren", + "enable_connect_description": "Spotube von anderen Geräten steuern", + "devices": "Geräte", + "select": "Auswählen", + "connect_client_alert": "Du wirst von {client} gesteuert", + "this_device": "Dieses Gerät", + "remote": "Fernbedienung", + "local_library": "Lokale Bibliothek", + "add_library_location": "Zur Bibliothek hinzufügen", + "remove_library_location": "Aus der Bibliothek entfernen", + "local_tab": "Lokal", + "stats": "Statistiken", + "and_n_more": "und {count} mehr", + "recently_played": "Zuletzt gespielt", + "browse_more": "Mehr durchsuchen", + "no_title": "Kein Titel", + "not_playing": "Wird nicht abgespielt", + "epic_failure": "Episches Versagen!", + "added_num_tracks_to_queue": "{tracks_length} Titel zur Warteschlange hinzugefügt", + "spotube_has_an_update": "Spotube hat ein Update", + "download_now": "Jetzt herunterladen", + "nightly_version": "Spotube Nightly {nightlyBuildNum} wurde veröffentlicht", + "release_version": "Spotube v{version} wurde veröffentlicht", + "read_the_latest": "Lese die neuesten ", + "release_notes": "Versionshinweise", + "pick_color_scheme": "Farbschema wählen", + "save": "Speichern", + "choose_the_device": "Wähle das Gerät:", + "multiple_device_connected": "Es sind mehrere Geräte verbunden.\nWähle das Gerät, auf dem diese Aktion ausgeführt werden soll", + "nothing_found": "Nichts gefunden", + "the_box_is_empty": "Die Box ist leer", + "top_artists": "Top-Künstler", + "top_albums": "Top-Alben", + "this_week": "Diese Woche", + "this_month": "Diesen Monat", + "last_6_months": "Letzte 6 Monate", + "this_year": "Dieses Jahr", + "last_2_years": "Letzte 2 Jahre", + "all_time": "Alle Zeiten", + "powered_by_provider": "Bereitgestellt von {providerName}", + "email": "Email", + "profile_followers": "Follower", + "birthday": "Geburtstag", + "subscription": "Abonnement", + "not_born": "Nicht geboren", + "hacker": "Hacker", + "profile": "Profil", + "no_name": "Kein Name", + "edit": "Bearbeiten", + "user_profile": "Benutzerprofil", + "count_plays": "{count} Wiedergaben", + "streaming_fees_hypothetical": "Streaming-Gebühren (hypothetisch)", + "minutes_listened": "Gehörte Minuten", + "streamed_songs": "Gestreamte Lieder", + "count_streams": "{count} Streams", + "owned_by_you": "In Ihrem Besitz", + "copied_shareurl_to_clipboard": "{shareUrl} in die Zwischenablage kopiert", + "spotify_hipotetical_calculation": "*Dies ist basierend auf Spotifys\npro Stream Auszahlung von $0,003 bis $0,005\nberechnet. Dies ist eine hypothetische Berechnung,\num dem Benutzer Einblick zu geben,\nwieviel sie den Künstlern gezahlt hätten,\nwenn sie ihren Song auf Spotify gehört hätten.", + "count_mins": "{minutes} Minuten", + "summary_minutes": "Minuten", + "summary_listened_to_music": "Hat Musik gehört", + "summary_songs": "Lieder", + "summary_streamed_overall": "Insgesamt gestreamt", + "summary_owed_to_artists": "Den Künstlern geschuldet\nDiesen Monat", + "summary_artists": "Künstler", + "summary_music_reached_you": "Musik hat Sie erreicht", + "summary_full_albums": "volle Alben", + "summary_got_your_love": "Hat Ihre Liebe gewonnen", + "summary_playlists": "Wiedergabelisten", + "summary_were_on_repeat": "Wurden wiederholt", + "total_money": "Gesamt {money}", + "webview_not_found": "Webview nicht gefunden", + "webview_not_found_description": "Es ist keine Webview-Laufzeitumgebung auf Ihrem Gerät installiert.\nFalls installiert, stellen Sie sicher, dass es im environment PATH ist\n\nNach der Installation starten Sie die App neu", + "unsupported_platform": "Nicht unterstützte Plattform" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8257eac9..c63f8543 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -107,6 +107,9 @@ "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", @@ -295,6 +298,7 @@ "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”", @@ -313,5 +317,75 @@ "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" + "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" } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 476056cb..d3c8b389 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -286,5 +286,106 @@ "step_3_steps": "Copia el valor de la cookie \"sp_dc\"", "step_4_steps": "Pega el valor copiado de \"sp_dc\"", "friends": "Amigos", - "no_lyrics_available": "Lo siento, no se pueden encontrar las letras de esta pista" + "no_lyrics_available": "Lo siento, no se pueden encontrar las letras de esta pista", + "sort_duration": "Ordenar por Duración", + "start_a_radio": "Iniciar una Radio", + "how_to_start_radio": "¿Cómo quieres iniciar la radio?", + "replace_queue_question": "¿Quieres reemplazar la lista de reproducción actual o añadir a ella?", + "endless_playback": "Reproducción Infinita", + "delete_playlist": "Eliminar Lista de Reproducción", + "delete_playlist_confirmation": "¿Estás seguro de que quieres eliminar esta lista de reproducción?", + "local_tracks": "Pistas Locales", + "song_link": "Enlace de la Canción", + "skip_this_nonsense": "Saltar esta tontería", + "freedom_of_music": "“Libertad de la Música”", + "freedom_of_music_palm": "“Libertad de la Música en la palma de tu mano”", + "get_started": "Empecemos", + "youtube_source_description": "Recomendado y funciona mejor.", + "piped_source_description": "¿Te sientes libre? Igual que YouTube pero más libre.", + "jiosaavn_source_description": "Lo mejor para la región del sur de Asia.", + "highest_quality": "Mayor Calidad: {quality}", + "select_audio_source": "Seleccionar Fuente de Audio", + "endless_playback_description": "Añadir automáticamente nuevas canciones\nal final de la cola de reproducción", + "choose_your_region": "Elige tu región", + "choose_your_region_description": "Esto ayudará a Spotube a mostrarte el contenido adecuado\npara tu ubicación.", + "choose_your_language": "Elige tu idioma", + "help_project_grow": "Ayuda a que este proyecto crezca", + "help_project_grow_description": "Spotube es un proyecto de código abierto. Puedes ayudar a que este proyecto crezca contribuyendo al proyecto, informando errores o sugiriendo nuevas funciones.", + "contribute_on_github": "Contribuir en GitHub", + "donate_on_open_collective": "Donar en Open Collective", + "browse_anonymously": "Navegar Anónimamente", + "enable_connect": "Habilitar conexión", + "enable_connect_description": "Controla Spotube desde otros dispositivos", + "devices": "Dispositivos", + "select": "Seleccionar", + "connect_client_alert": "Estás siendo controlado por {client}", + "this_device": "Este dispositivo", + "remote": "Remoto", + "local_library": "Biblioteca local", + "add_library_location": "Añadir a la biblioteca", + "remove_library_location": "Eliminar de la biblioteca", + "local_tab": "Local", + "stats": "Estadísticas", + "and_n_more": "y {count} más", + "recently_played": "Recién reproducido", + "browse_more": "Explorar más", + "no_title": "Sin título", + "not_playing": "No reproduciendo", + "epic_failure": "¡Fallo épico!", + "added_num_tracks_to_queue": "Se añadieron {tracks_length} canciones a la cola", + "spotube_has_an_update": "Spotube tiene una actualización", + "download_now": "Descargar ahora", + "nightly_version": "Spotube Nightly {nightlyBuildNum} ha sido lanzado", + "release_version": "Spotube v{version} ha sido lanzado", + "read_the_latest": "Lee las últimas ", + "release_notes": "notas de la versión", + "pick_color_scheme": "Elige esquema de color", + "save": "Guardar", + "choose_the_device": "Elige el dispositivo:", + "multiple_device_connected": "Hay múltiples dispositivos conectados.\nElige el dispositivo en el que deseas realizar esta acción", + "nothing_found": "Nada encontrado", + "the_box_is_empty": "La caja está vacía", + "top_artists": "Artistas principales", + "top_albums": "Álbumes principales", + "this_week": "Esta semana", + "this_month": "Este mes", + "last_6_months": "Últimos 6 meses", + "this_year": "Este año", + "last_2_years": "Últimos 2 años", + "all_time": "Todos los tiempos", + "powered_by_provider": "Impulsado por {providerName}", + "email": "Correo electrónico", + "profile_followers": "Seguidores", + "birthday": "Cumpleaños", + "subscription": "Suscripción", + "not_born": "No nacido", + "hacker": "Hacker", + "profile": "Perfil", + "no_name": "Sin nombre", + "edit": "Editar", + "user_profile": "Perfil de usuario", + "count_plays": "{count} reproducciones", + "streaming_fees_hypothetical": "Tarifas de streaming (hipotéticas)", + "minutes_listened": "Minutos escuchados", + "streamed_songs": "Canciones reproducidas", + "count_streams": "{count} streams", + "owned_by_you": "En tu posesión", + "copied_shareurl_to_clipboard": "Copiado {shareUrl} al portapapeles", + "spotify_hipotetical_calculation": "*Esto se calcula en base al\npago por stream de Spotify de $0.003 a $0.005.\nEs un cálculo hipotético para dar\nuna idea de cuánto habría\npagado a los artistas si hubieras escuchado\nsu canción en Spotify.", + "count_mins": "{minutes} minutos", + "summary_minutes": "minutos", + "summary_listened_to_music": "Escuchó música", + "summary_songs": "canciones", + "summary_streamed_overall": "Transmitido en general", + "summary_owed_to_artists": "Debido a los artistas\nEste mes", + "summary_artists": "artistas", + "summary_music_reached_you": "La música te alcanzó", + "summary_full_albums": "álbumes completos", + "summary_got_your_love": "Obtuvo tu amor", + "summary_playlists": "listas de reproducción", + "summary_were_on_repeat": "Estaban en repetición", + "total_money": "Total {money}", + "webview_not_found": "No se encontró el Webview", + "webview_not_found_description": "No hay tiempo de ejecución de Webview instalado en su dispositivo.\nSi está instalado, asegúrese de que esté en el environment PATH\n\nDespués de instalar, reinicie la aplicación", + "unsupported_platform": "Plataforma no soportada" } \ 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..36986804 --- /dev/null +++ b/lib/l10n/app_eu.arb @@ -0,0 +1,391 @@ +{ + "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" +} \ No newline at end of file diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 3a2bcb4b..47242a04 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -286,5 +286,106 @@ "step_3_steps": "مقدار کوکی \"sp_dc\" را کپی کنید", "step_4_steps": "مقدار کپی شده \"sp_dc\" را الصاق کنید", "friends": "دوستان", - "no_lyrics_available": "متاسفیم، قادر به یافتن متن این قطعه نیستیم" + "no_lyrics_available": "متاسفیم، قادر به یافتن متن این قطعه نیستیم", + "sort_duration": "مرتب کردن بر اساس مدت زمان", + "start_a_radio": "شروع یک رادیو", + "how_to_start_radio": "چگونه می‌خواهید رادیو را شروع کنید؟", + "replace_queue_question": "آیا می‌خواهید لیست پخش فعلی را جایگزین کنید یا به آن اضافه کنید؟", + "endless_playback": "پخش بی‌پایان", + "delete_playlist": "حذف لیست پخش", + "delete_playlist_confirmation": "آیا مطمئن هستید که می‌خواهید این لیست پخش را حذف کنید؟", + "local_tracks": "موسیقی‌های محلی", + "song_link": "پیوند آهنگ", + "skip_this_nonsense": "این احمقانه را بگذرانید", + "freedom_of_music": "“آزادی موسیقی”", + "freedom_of_music_palm": "“آزادی موسیقی در دستان شما”", + "get_started": "بیایید شروع کنیم", + "youtube_source_description": "پیشنهاد شده و بهترین عمل می‌کند.", + "piped_source_description": "احساس آزادی می‌کنید؟ مانند یوتیوب اما بیشتر آزاد.", + "jiosaavn_source_description": "بهترین برای منطقه جنوب آسیا.", + "highest_quality": "بالاترین کیفیت: {quality}", + "select_audio_source": "انتخاب منبع صوتی", + "endless_playback_description": "خودکار اضافه کردن آهنگ‌های جدید\nبه انتهای صف", + "choose_your_region": "منطقه خود را انتخاب کنید", + "choose_your_region_description": "این به Spotube کمک می‌کند تا محتوای مناسبی را برای موقعیت شما نشان دهد.", + "choose_your_language": "زبان خود را انتخاب کنید", + "help_project_grow": "کمک به رشد این پروژه", + "help_project_grow_description": "Spotube یک پروژه متن باز است. شما می‌توانید با به پروژه کمک کردن، گزارش دادن اشکالات یا پیشنهاد ویژگی‌های جدید، به این پروژه کمک کنید.", + "contribute_on_github": "مشارکت در GitHub", + "donate_on_open_collective": "کمک مالی در Open Collective", + "browse_anonymously": "مرور به صورت ناشناس", + "enable_connect": "فعال‌سازی اتصال", + "enable_connect_description": "کنترل Spotube از دیگر دستگاه‌ها", + "devices": "دستگاه‌ها", + "select": "انتخاب", + "connect_client_alert": "شما توسط {client} کنترل می‌شوید", + "this_device": "این دستگاه", + "remote": "راه‌دور", + "local_library": "کتابخانه محلی", + "add_library_location": "اضافه کردن به کتابخانه", + "remove_library_location": "حذف از کتابخانه", + "local_tab": "محلی", + "stats": "آمار", + "and_n_more": "و {count} بیشتر", + "recently_played": "اخیراً پخش شده", + "browse_more": "بیشتر مرور کنید", + "no_title": "بدون عنوان", + "not_playing": "در حال پخش نیست", + "epic_failure": "شکست حماسی!", + "added_num_tracks_to_queue": "{tracks_length} ترک به صف اضافه شد", + "spotube_has_an_update": "Spotube یک بروزرسانی دارد", + "download_now": "اکنون دانلود کنید", + "nightly_version": "نسخه شبانه Spotube {nightlyBuildNum} منتشر شد", + "release_version": "نسخه Spotube v{version} منتشر شد", + "read_the_latest": "آخرین‌ها را بخوانید", + "release_notes": "یادداشت‌های انتشار", + "pick_color_scheme": "طرح رنگ را انتخاب کنید", + "save": "ذخیره", + "choose_the_device": "دستگاه را انتخاب کنید:", + "multiple_device_connected": "چندین دستگاه متصل هستند.\nدستگاهی را انتخاب کنید که می‌خواهید این عملیات بر روی آن انجام شود", + "nothing_found": "چیزی پیدا نشد", + "the_box_is_empty": "جعبه خالی است", + "top_artists": "بهترین هنرمندان", + "top_albums": "بهترین آلبوم‌ها", + "this_week": "این هفته", + "this_month": "این ماه", + "last_6_months": "۶ ماه گذشته", + "this_year": "امسال", + "last_2_years": "۲ سال گذشته", + "all_time": "همیشه", + "powered_by_provider": "توسط {providerName} پشتیبانی شده است", + "email": "ایمیل", + "profile_followers": "دنبال‌کنندگان", + "birthday": "تولد", + "subscription": "اشتراک", + "not_born": "متولد نشده", + "hacker": "هکر", + "profile": "پروفایل", + "no_name": "بدون نام", + "edit": "ویرایش", + "user_profile": "پروفایل کاربر", + "count_plays": "{count} پخش", + "streaming_fees_hypothetical": "هزینه‌های پخش (فرضی)", + "minutes_listened": "دقایق گوش داده شده", + "streamed_songs": "ترانه‌های پخش شده", + "count_streams": "{count} پخش", + "owned_by_you": "توسط شما مالکیت شده", + "copied_shareurl_to_clipboard": "{shareUrl} به کلیپ‌بورد کپی شد", + "spotify_hipotetical_calculation": "*این بر اساس پرداخت هر پخش اسپاتیفای\nبه مبلغ 0.003 تا 0.005 دلار محاسبه شده است.\nاین یک محاسبه فرضی است که به کاربران نشان دهد چقدر ممکن است\nبه هنرمندان پرداخت می‌کردند اگر ترانه آنها را در اسپاتیفای گوش می‌دادند.", + "count_mins": "{minutes} دقیقه", + "summary_minutes": "دقیقه‌ها", + "summary_listened_to_music": "به موسیقی گوش داده شده", + "summary_songs": "ترانه‌ها", + "summary_streamed_overall": "پخش شده به طور کلی", + "summary_owed_to_artists": "به هنرمندان بدهکار است\nاین ماه", + "summary_artists": "هنرمندان", + "summary_music_reached_you": "موسیقی به شما رسیده است", + "summary_full_albums": "آلبوم‌های کامل", + "summary_got_your_love": "عشق شما را به دست آورد", + "summary_playlists": "لیست‌های پخش", + "summary_were_on_repeat": "در تکرار بودند", + "total_money": "مجموع {money}", + "webview_not_found": "وب‌ویو پیدا نشد", + "webview_not_found_description": "هیچ اجرای وب‌ویو روی دستگاه شما نصب نشده است.\nدر صورت نصب، مطمئن شوید که در environment PATH قرار دارد\n\nپس از نصب، برنامه را مجدداً راه‌اندازی کنید", + "unsupported_platform": "پلتفرم پشتیبانی نمی‌شود" } \ 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..53b948a6 --- /dev/null +++ b/lib/l10n/app_fi.arb @@ -0,0 +1,391 @@ +{ + "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" +} \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 5c24d0fe..522a2af4 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -286,5 +286,106 @@ "step_3_steps": "Copiez la valeur du cookie \"sp_dc\"", "step_4_steps": "Collez la valeur copiée de \"sp_dc\"", "friends": "Amis", - "no_lyrics_available": "Désolé, impossible de trouver les paroles de cette piste" + "no_lyrics_available": "Désolé, impossible de trouver les paroles de cette piste", + "sort_duration": "Trier par durée", + "start_a_radio": "Démarrer une radio", + "how_to_start_radio": "Comment voulez-vous démarrer la radio ?", + "replace_queue_question": "Voulez-vous remplacer la file d'attente actuelle ou y ajouter ?", + "endless_playback": "Lecture sans fin", + "delete_playlist": "Supprimer la playlist", + "delete_playlist_confirmation": "Êtes-vous sûr de vouloir supprimer cette playlist ?", + "local_tracks": "Titres locaux", + "song_link": "Lien de la chanson", + "skip_this_nonsense": "Passer cette absurdité", + "freedom_of_music": "“Liberté de la musique”", + "freedom_of_music_palm": "“Liberté de la musique dans la paume de votre main”", + "get_started": "Commençons", + "youtube_source_description": "Recommandé et fonctionne mieux.", + "piped_source_description": "Vous vous sentez libre ? Comme YouTube mais beaucoup plus gratuit.", + "jiosaavn_source_description": "Le meilleur pour la région d'Asie du Sud.", + "highest_quality": "Meilleure qualité : {quality}", + "select_audio_source": "Sélectionner la source audio", + "endless_playback_description": "Ajouter automatiquement de nouvelles chansons à la fin de la file d'attente", + "choose_your_region": "Choisissez votre région", + "choose_your_region_description": "Cela aidera Spotube à vous montrer le bon contenu pour votre emplacement.", + "choose_your_language": "Choisissez votre langue", + "help_project_grow": "Aidez ce projet à grandir", + "help_project_grow_description": "Spotube est un projet open-source. Vous pouvez aider ce projet à grandir en contribuant au projet, en signalant des bugs ou en suggérant de nouvelles fonctionnalités.", + "contribute_on_github": "Contribuer sur GitHub", + "donate_on_open_collective": "Faire un don sur Open Collective", + "browse_anonymously": "Naviguer anonymement", + "enable_connect": "Activer la connexion", + "enable_connect_description": "Contrôlez Spotube depuis d'autres appareils", + "devices": "Appareils", + "select": "Sélectionner", + "connect_client_alert": "Vous êtes contrôlé par {client}", + "this_device": "Cet appareil", + "remote": "À distance", + "local_library": "Bibliothèque locale", + "add_library_location": "Ajouter à la bibliothèque", + "remove_library_location": "Retirer de la bibliothèque", + "local_tab": "Local", + "stats": "Statistiques", + "and_n_more": "et {count} de plus", + "recently_played": "Récemment joué", + "browse_more": "Parcourir plus", + "no_title": "Sans titre", + "not_playing": "Non joué", + "epic_failure": "Échec épique!", + "added_num_tracks_to_queue": "{tracks_length} morceaux ajoutés à la file d'attente", + "spotube_has_an_update": "Spotube a une mise à jour", + "download_now": "Télécharger maintenant", + "nightly_version": "Spotube Nightly {nightlyBuildNum} a été publié", + "release_version": "Spotube v{version} a été publié", + "read_the_latest": "Lisez les dernières ", + "release_notes": "notes de version", + "pick_color_scheme": "Choisissez le schéma de couleurs", + "save": "Sauvegarder", + "choose_the_device": "Choisissez l'appareil:", + "multiple_device_connected": "Plusieurs appareils sont connectés.\nChoisissez l'appareil sur lequel vous souhaitez effectuer cette action", + "nothing_found": "Rien trouvé", + "the_box_is_empty": "La boîte est vide", + "top_artists": "Meilleurs artistes", + "top_albums": "Meilleurs albums", + "this_week": "Cette semaine", + "this_month": "Ce mois-ci", + "last_6_months": "Les 6 derniers mois", + "this_year": "Cette année", + "last_2_years": "Les 2 dernières années", + "all_time": "De tous les temps", + "powered_by_provider": "Propulsé par {providerName}", + "email": "Email", + "profile_followers": "Abonnés", + "birthday": "Anniversaire", + "subscription": "Abonnement", + "not_born": "Non né", + "hacker": "Hacker", + "profile": "Profil", + "no_name": "Sans nom", + "edit": "Modifier", + "user_profile": "Profil utilisateur", + "count_plays": "{count} lectures", + "streaming_fees_hypothetical": "Frais de streaming (hypothétiques)", + "minutes_listened": "Minutes écoutées", + "streamed_songs": "Morceaux diffusés", + "count_streams": "{count} streams", + "owned_by_you": "Possédé par vous", + "copied_shareurl_to_clipboard": "{shareUrl} copié dans le presse-papier", + "spotify_hipotetical_calculation": "*Cela est calculé en fonction du\npaiement par stream de Spotify de 0,003 $ à 0,005 $.\nIl s'agit d'un calcul hypothétique pour donner\nune idée de combien vous auriez\npayé aux artistes si vous aviez\nécouté leur chanson sur Spotify.", + "count_mins": "{minutes} minutes", + "summary_minutes": "minutes", + "summary_listened_to_music": "A écouté de la musique", + "summary_songs": "morceaux", + "summary_streamed_overall": "Diffusé en général", + "summary_owed_to_artists": "Dû aux artistes\nCe mois-ci", + "summary_artists": "artistes", + "summary_music_reached_you": "La musique vous a atteint", + "summary_full_albums": "albums complets", + "summary_got_your_love": "A obtenu votre amour", + "summary_playlists": "playlists", + "summary_were_on_repeat": "Était en répétition", + "total_money": "Total {money}", + "webview_not_found": "Webview non trouvé", + "webview_not_found_description": "Aucun environnement d'exécution Webview installé sur votre appareil.\nSi c'est installé, assurez-vous qu'il soit dans le environment PATH\n\nAprès l'installation, redémarrez l'application", + "unsupported_platform": "Plateforme non prise en charge" } \ No newline at end of file diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 1cf62398..ce01aebe 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -286,5 +286,106 @@ "step_3_steps": "\"sp_dc\" कुकी का मूल्य कॉपी करें", "step_4_steps": "कॉपी किए गए \"sp_dc\" मूल्य को पेस्ट करें", "friends": "दोस्त", - "no_lyrics_available": "क्षमा करें, इस ट्रैक के लिए गाने नहीं मिल सके" + "no_lyrics_available": "क्षमा करें, इस ट्रैक के लिए गाने नहीं मिल सके", + "sort_duration": "समय के आधार पर क्रमबद्ध करें", + "start_a_radio": "रेडियो शुरू करें", + "how_to_start_radio": "रेडियो कैसे शुरू करना चाहते हैं?", + "replace_queue_question": "क्या आप वर्तमान कतार को बदलना चाहते हैं या इसे जोड़ना चाहते हैं?", + "endless_playback": "अंतहीन प्लेबैक", + "delete_playlist": "प्लेलिस्ट हटाएं", + "delete_playlist_confirmation": "क्या आप वाकई इस प्लेलिस्ट को हटाना चाहते हैं?", + "local_tracks": "स्थानीय ट्रैक्स", + "song_link": "गाने का लिंक", + "skip_this_nonsense": "इस माया को छोड़ें", + "freedom_of_music": "“संगीत की स्वतंत्रता”", + "freedom_of_music_palm": "“हाथ में संगीत की स्वतंत्रता”", + "get_started": "आइए शुरू करें", + "youtube_source_description": "सिफारिश किया गया और सबसे अच्छा काम करता है।", + "piped_source_description": "मुफ्त महसूस कर रहे हैं? YouTube के समान लेकिन काफी अधिक मुफ्त।", + "jiosaavn_source_description": "दक्षिण एशियाई क्षेत्र के लिए सर्वोत्तम।", + "highest_quality": "सर्वोत्तम गुणवत्ता: {quality}", + "select_audio_source": "ऑडियो स्रोत चुनें", + "endless_playback_description": "क्रमबद्ध कतार के अंत में नए गाने स्वचालित रूप से जोड़ें", + "choose_your_region": "अपना क्षेत्र चुनें", + "choose_your_region_description": "यह Spotube को आपके स्थान के लिए सही सामग्री दिखाने में मदद करेगा।", + "choose_your_language": "अपनी भाषा चुनें", + "help_project_grow": "इस परियोजना को बढ़ावा दें", + "help_project_grow_description": "Spotube एक ओपन सोर्स परियोजना है। आप इस परियोजना को योगदान देकर, बग रिपोर्ट करके या नई विशेषताओं का सुझाव देकर इस परियोजना को बढ़ा सकते हैं।", + "contribute_on_github": "GitHub पर योगदान करें", + "donate_on_open_collective": "ओपन कलेक्टिव पर दान करें", + "browse_anonymously": "बिना नाम के ब्राउज़ करें", + "enable_connect": "कनेक्ट सक्षम करें", + "enable_connect_description": "अन्य उपकरणों से Spotube को नियंत्रित करें", + "devices": "उपकरण", + "select": "चयन करें", + "connect_client_alert": "आप {client} द्वारा नियंत्रित हो रहे हैं", + "this_device": "यह उपकरण", + "remote": "रिमोट", + "local_library": "स्थानीय पुस्तकालय", + "add_library_location": "पुस्तकालय में जोड़ें", + "remove_library_location": "पुस्तकालय से हटाएं", + "local_tab": "स्थानीय", + "stats": "आंकड़े", + "and_n_more": "और {count} और", + "recently_played": "हाल ही में खेले गए", + "browse_more": "अधिक ब्राउज़ करें", + "no_title": "कोई शीर्षक नहीं", + "not_playing": "नहीं चल रहा", + "epic_failure": "महान असफलता!", + "added_num_tracks_to_queue": "{tracks_length} ट्रैक्स कतार में जोड़े गए", + "spotube_has_an_update": "Spotube में एक अपडेट है", + "download_now": "अभी डाउनलोड करें", + "nightly_version": "Spotube Nightly {nightlyBuildNum} जारी किया गया है", + "release_version": "Spotube v{version} जारी किया गया है", + "read_the_latest": "नवीनतम पढ़ें", + "release_notes": "रिलीज़ नोट्स", + "pick_color_scheme": "रंग योजना चुनें", + "save": "सहेजें", + "choose_the_device": "उपकरण चुनें:", + "multiple_device_connected": "कई उपकरण जुड़े हुए हैं।\nउस उपकरण को चुनें जिस पर आप यह क्रिया करना चाहते हैं", + "nothing_found": "कुछ भी नहीं मिला", + "the_box_is_empty": "बॉक्स खाली है", + "top_artists": "शीर्ष कलाकार", + "top_albums": "शीर्ष एल्बम", + "this_week": "इस हफ्ते", + "this_month": "इस महीने", + "last_6_months": "पिछले 6 महीने", + "this_year": "इस साल", + "last_2_years": "पिछले 2 साल", + "all_time": "सभी समय", + "powered_by_provider": "{providerName} द्वारा संचालित", + "email": "ईमेल", + "profile_followers": "अनुयायी", + "birthday": "जन्मदिन", + "subscription": "सदस्यता", + "not_born": "अभी पैदा नहीं हुआ", + "hacker": "हैकर", + "profile": "प्रोफ़ाइल", + "no_name": "कोई नाम नहीं", + "edit": "संपादित करें", + "user_profile": "उपयोगकर्ता प्रोफ़ाइल", + "count_plays": "{count} प्ले", + "streaming_fees_hypothetical": "*Spotify की प्रति स्ट्रीम भुगतान के आधार पर\n$0.003 से $0.005 तक गणना की गई है। यह एक काल्पनिक\nगणना है जो उपयोगकर्ता को यह जानकारी देती है कि वे कितना भुगतान\nकरते यदि वे Spotify पर गाने सुनते।", + "count_mins": "{minutes} मिनट", + "summary_minutes": "मिनट", + "summary_listened_to_music": "सुनी गई संगीत", + "summary_songs": "गाने", + "summary_streamed_overall": "कुल स्ट्रीम", + "summary_owed_to_artists": "कलाकारों को देनदार\nइस महीने", + "summary_artists": "कलाकार", + "summary_music_reached_you": "संगीत आपके पास पहुंच गया", + "summary_full_albums": "पूरा एल्बम", + "summary_got_your_love": "आपका प्यार मिला", + "summary_playlists": "प्लेलिस्ट", + "summary_were_on_repeat": "दोहराया गया", + "total_money": "कुल {money}", + "minutes_listened": "सुनिएका मिनेटहरू", + "streamed_songs": "स्ट्रीम गरिएका गीतहरू", + "count_streams": "{count} स्ट्रिम", + "owned_by_you": "तपाईंले स्वामित्व गरेको", + "copied_shareurl_to_clipboard": "{shareUrl} क्लिपबोर्डमा कपी गरियो", + "spotify_hipotetical_calculation": "*यो Spotify को प्रति स्ट्रीम भुगतानको आधारमा\n$0.003 देखि $0.005 को बीचमा गणना गरिएको हो। यो एक काल्पनिक\nगणना हो जसले प्रयोगकर्तालाई देखाउँछ कि उनीहरूले कति\nअर्टिस्टहरूलाई तिनीहरूका गीतहरू Spotify मा सुनेमा\nभुक्तान गर्नुपर्ने थियो।", + "webview_not_found": "वेबव्यू नहीं मिला", + "webview_not_found_description": "आपके डिवाइस पर वेबव्यू रनटाइम इंस्टॉल नहीं है।\nअगर इंस्टॉल है, तो सुनिश्चित करें कि यह environment PATH में है\n\nइंस्टॉल करने के बाद, ऐप को पुनः शुरू करें", + "unsupported_platform": "असमर्थित प्लेटफार्म" } \ 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..121695f4 --- /dev/null +++ b/lib/l10n/app_id.arb @@ -0,0 +1,391 @@ +{ + "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" +} \ No newline at end of file diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index ec76b914..3a2c57c3 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -287,5 +287,106 @@ "step_3_steps": "Copia il valore del cookie \"sp_dc\"", "step_4_steps": "Incolla il valore copiato di \"sp_dc\"", "friends": "Amici", - "no_lyrics_available": "Spiacente, impossibile trovare il testo di questa traccia" + "no_lyrics_available": "Spiacente, impossibile trovare il testo di questa traccia", + "sort_duration": "Ordina per Durata", + "start_a_radio": "Avvia una Radio", + "how_to_start_radio": "Come vuoi avviare la radio?", + "replace_queue_question": "Vuoi sostituire la coda attuale o aggiungerla?", + "endless_playback": "Riproduzione Infinita", + "delete_playlist": "Elimina Playlist", + "delete_playlist_confirmation": "Sei sicuro di voler eliminare questa playlist?", + "local_tracks": "Tracce Locali", + "song_link": "Link della Canzone", + "skip_this_nonsense": "Salta questa sciocchezza", + "freedom_of_music": "“Libertà della Musica”", + "freedom_of_music_palm": "“Libertà della Musica nel palmo della tua mano”", + "get_started": "Cominciamo", + "youtube_source_description": "Consigliato e funziona meglio.", + "piped_source_description": "Ti senti libero? Come YouTube ma molto più gratuito.", + "jiosaavn_source_description": "Il migliore per la regione dell'Asia meridionale.", + "highest_quality": "Massima Qualità: {quality}", + "select_audio_source": "Seleziona Sorgente Audio", + "endless_playback_description": "Aggiungi automaticamente nuove canzoni alla fine della coda", + "choose_your_region": "Scegli la tua regione", + "choose_your_region_description": "Questo aiuterà Spotube a mostrarti il contenuto giusto per la tua posizione.", + "choose_your_language": "Scegli la tua lingua", + "help_project_grow": "Aiuta questo progetto a crescere", + "help_project_grow_description": "Spotube è un progetto open-source. Puoi aiutare questo progetto a crescere contribuendo al progetto, segnalando bug o suggerendo nuove funzionalità.", + "contribute_on_github": "Contribuisci su GitHub", + "donate_on_open_collective": "Dona su Open Collective", + "browse_anonymously": "Naviga in modo anonimo", + "enable_connect": "Abilita connessione", + "enable_connect_description": "Controlla Spotube da altri dispositivi", + "devices": "Dispositivi", + "select": "Seleziona", + "connect_client_alert": "Stai venendo controllato da {client}", + "this_device": "Questo dispositivo", + "remote": "Remoto", + "local_library": "Biblioteca locale", + "add_library_location": "Aggiungi alla biblioteca", + "remove_library_location": "Rimuovi dalla biblioteca", + "local_tab": "Locale", + "stats": "Statistiche", + "and_n_more": "e {count} in più", + "recently_played": "Riprodotti di recente", + "browse_more": "Esplora di più", + "no_title": "Nessun titolo", + "not_playing": "Non in riproduzione", + "epic_failure": "Fallimento epico!", + "added_num_tracks_to_queue": "Aggiunti {tracks_length} brani alla coda", + "spotube_has_an_update": "Spotube ha un aggiornamento", + "download_now": "Scarica ora", + "nightly_version": "Spotube Nightly {nightlyBuildNum} è stato rilasciato", + "release_version": "Spotube v{version} è stato rilasciato", + "read_the_latest": "Leggi l'ultimo ", + "release_notes": "note di rilascio", + "pick_color_scheme": "Scegli uno schema di colori", + "save": "Salva", + "choose_the_device": "Scegli il dispositivo:", + "multiple_device_connected": "Sono collegati più dispositivi.\nScegli il dispositivo su cui vuoi che venga eseguita questa azione", + "nothing_found": "Nessun risultato", + "the_box_is_empty": "La scatola è vuota", + "top_artists": "Artisti Top", + "top_albums": "Album Top", + "this_week": "Questa settimana", + "this_month": "Questo mese", + "last_6_months": "Ultimi 6 mesi", + "this_year": "Quest'anno", + "last_2_years": "Ultimi 2 anni", + "all_time": "Di tutti i tempi", + "powered_by_provider": "Sostenuto da {providerName}", + "email": "Email", + "profile_followers": "Follower", + "birthday": "Compleanno", + "subscription": "Abbonamento", + "not_born": "Non nato", + "hacker": "Hacker", + "profile": "Profilo", + "no_name": "Nessun nome", + "edit": "Modifica", + "user_profile": "Profilo utente", + "count_plays": "{count} riproduzioni", + "streaming_fees_hypothetical": "Spese di streaming (ipotetico)", + "minutes_listened": "Minuti ascoltati", + "streamed_songs": "Brani in streaming", + "count_streams": "{count} streaming", + "owned_by_you": "Di tua proprietà", + "copied_shareurl_to_clipboard": "Copiato {shareUrl} negli appunti", + "spotify_hipotetical_calculation": "*Questo è calcolato in base al pagamento per streaming di Spotify\nche va da $0.003 a $0.005. Questo è un calcolo ipotetico\nper dare all'utente un'idea di quanto avrebbe pagato agli artisti se avesse ascoltato\ne loro canzoni su Spotify.", + "count_mins": "{minutes} min", + "summary_minutes": "minuti", + "summary_listened_to_music": "Musica ascoltata", + "summary_songs": "brani", + "summary_streamed_overall": "Streaming complessivo", + "summary_owed_to_artists": "Dovuto agli artisti\nquesto mese", + "summary_artists": "dell'artista", + "summary_music_reached_you": "La musica ti ha raggiunto", + "summary_full_albums": "album completi", + "summary_got_your_love": "Ha ricevuto il tuo amore", + "summary_playlists": "playlist", + "summary_were_on_repeat": "Erano in ripetizione", + "total_money": "Totale {money}", + "webview_not_found": "Webview non trovato", + "webview_not_found_description": "Nessun runtime Webview installato nel tuo dispositivo.\nSe è installato, assicurati che sia nel environment PATH\n\nDopo l'installazione, riavvia l'app", + "unsupported_platform": "Piattaforma non supportata" } \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index d16708d7..ed779478 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -286,5 +286,106 @@ "step_3_steps": "\"sp_dc\" Cookieの値をコピー", "step_4_steps": "コピーした\"sp_dc\"の値を貼り付け", "friends": "友達", - "no_lyrics_available": "申し訳ありませんが、このトラックの歌詞を見つけることができません" + "no_lyrics_available": "申し訳ありませんが、このトラックの歌詞を見つけることができません", + "sort_duration": "時間で並べ替え", + "start_a_radio": "ラジオを開始", + "how_to_start_radio": "ラジオをどのように開始しますか?", + "replace_queue_question": "現在のキューを置き換えるか、追加しますか?", + "endless_playback": "エンドレス再生", + "delete_playlist": "プレイリストを削除", + "delete_playlist_confirmation": "このプレイリストを削除してもよろしいですか?", + "local_tracks": "ローカルトラック", + "song_link": "曲のリンク", + "skip_this_nonsense": "この愚かなことをスキップ", + "freedom_of_music": "“音楽の自由”", + "freedom_of_music_palm": "“手のひらの中の音楽の自由”", + "get_started": "さあ始めましょう", + "youtube_source_description": "推奨され、最適に機能します。", + "piped_source_description": "自由に感じますか? YouTubeと同じですが、はるかに無料です。", + "jiosaavn_source_description": "南アジア地域向けの最適です。", + "highest_quality": "最高品質:{quality}", + "select_audio_source": "オーディオソースを選択", + "endless_playback_description": "新しい曲をキューの最後に自動的に追加", + "choose_your_region": "地域を選択", + "choose_your_region_description": "これにより、Spotubeがあなたの場所に適したコンテンツを表示できます。", + "choose_your_language": "言語を選択してください", + "help_project_grow": "このプロジェクトの成長を支援する", + "help_project_grow_description": "Spotubeはオープンソースプロジェクトです。プロジェクトに貢献したり、バグを報告したり、新しい機能を提案することで、このプロジェクトの成長に貢献できます。", + "contribute_on_github": "GitHubで貢献する", + "donate_on_open_collective": "Open Collectiveで寄付する", + "browse_anonymously": "匿名で閲覧する", + "enable_connect": "接続を有効にする", + "enable_connect_description": "他のデバイスからSpotubeを制御する", + "devices": "デバイス", + "select": "選択する", + "connect_client_alert": "{client} によって操作されています", + "this_device": "このデバイス", + "remote": "リモート", + "local_library": "ローカルライブラリ", + "add_library_location": "ライブラリに追加", + "remove_library_location": "ライブラリから削除", + "local_tab": "ローカル", + "stats": "統計", + "and_n_more": "そして {count} つのアイテム", + "recently_played": "最近再生された", + "browse_more": "もっと見る", + "no_title": "タイトルなし", + "not_playing": "再生中ではありません", + "epic_failure": "壮大な失敗!", + "added_num_tracks_to_queue": "{tracks_length} 曲をキューに追加しました", + "spotube_has_an_update": "Spotube にアップデートがあります", + "download_now": "今すぐダウンロード", + "nightly_version": "Spotube Nightly {nightlyBuildNum} がリリースされました", + "release_version": "Spotube v{version} がリリースされました", + "read_the_latest": "最新の ", + "release_notes": "リリースノート", + "pick_color_scheme": "カラースキームを選択", + "save": "保存", + "choose_the_device": "デバイスを選択:", + "multiple_device_connected": "複数のデバイスが接続されています。\nこのアクションを実行するデバイスを選択してください", + "nothing_found": "何も見つかりませんでした", + "the_box_is_empty": "ボックスは空です", + "top_artists": "トップアーティスト", + "top_albums": "トップアルバム", + "this_week": "今週", + "this_month": "今月", + "last_6_months": "過去6か月", + "this_year": "今年", + "last_2_years": "過去2年間", + "all_time": "全期間", + "powered_by_provider": "{providerName} 提供", + "email": "メール", + "profile_followers": "フォロワー", + "birthday": "誕生日", + "subscription": "サブスクリプション", + "not_born": "未出生", + "hacker": "ハッカー", + "profile": "プロフィール", + "no_name": "名前なし", + "edit": "編集", + "user_profile": "ユーザープロフィール", + "count_plays": "{count} 回再生", + "streaming_fees_hypothetical": "*これは Spotify のストリームあたりの支払い\nが $0.003 から $0.005 であると仮定して計算されています。\nこれは、Spotify でその曲を聴いた場合にアーティストにいくら支払ったかの\n洞察を得るための仮定の計算です。", + "count_mins": "{minutes} 分", + "summary_minutes": "分", + "summary_listened_to_music": "音楽を聴いた", + "summary_songs": "曲", + "summary_streamed_overall": "全体のストリーミング", + "summary_owed_to_artists": "今月アーティストに支払うべき額", + "summary_artists": "アーティストの", + "summary_music_reached_you": "音楽があなたに届いた", + "summary_full_albums": "フルアルバム", + "summary_got_your_love": "あなたの愛を受け取った", + "summary_playlists": "プレイリスト", + "summary_were_on_repeat": "リピートしていた", + "total_money": "合計 {money}", + "minutes_listened": "リスニング時間", + "streamed_songs": "ストリーミングされた曲", + "count_streams": "{count} 回のストリーム", + "owned_by_you": "あなたが所有", + "copied_shareurl_to_clipboard": "{shareUrl} をクリップボードにコピーしました", + "spotify_hipotetical_calculation": "*これは、Spotifyのストリームごとの支払い\nが $0.003 から $0.005 の範囲で計算されています。これは仮想的な\n計算で、Spotify で曲を聴いた場合に、アーティストに\nどれくらい支払ったかをユーザーに示すためのものです。", + "webview_not_found": "Webviewが見つかりません", + "webview_not_found_description": "デバイスにWebviewランタイムがインストールされていません。\nインストールされている場合は、environment PATHにあることを確認してください\n\nインストール後、アプリを再起動してください", + "unsupported_platform": "サポートされていないプラットフォーム" } \ 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..888dbb6f --- /dev/null +++ b/lib/l10n/app_ka.arb @@ -0,0 +1,391 @@ +{ + "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": "მოუხერხებელი პლატფორმა" +} \ No newline at end of file diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index dac5b72a..a71b59ae 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -287,5 +287,106 @@ "step_4_steps": "복사한 \"sp_dc\"값을 붙여넣기", "friends": "친구", "no_lyrics_available": "죄송하지만 이 곡의 가사를 찾지 못했습니다", - "@@locale": "ko" -} + "@@locale": "ko", + "sort_duration": "시간순 정렬", + "start_a_radio": "라디오 시작", + "how_to_start_radio": "라디오를 어떻게 시작하시겠습니까?", + "replace_queue_question": "현재 큐를 대체하시겠습니까 아니면 추가하시겠습니까?", + "endless_playback": "끝없는 재생", + "delete_playlist": "재생 목록 삭제", + "delete_playlist_confirmation": "이 재생 목록을 삭제하시겠습니까?", + "local_tracks": "로컬 트랙", + "song_link": "곡 링크", + "skip_this_nonsense": "이 허튼소리 건너뛰기", + "freedom_of_music": "“음악의 자유”", + "freedom_of_music_palm": "“손바닥 안의 음악의 자유”", + "get_started": "시작합시다", + "youtube_source_description": "추천되며 가장 잘 작동합니다.", + "piped_source_description": "자유로운 기분이 듭니까? YouTube와 같지만 훨씬 더 무료합니다.", + "jiosaavn_source_description": "남아시아 지역에 최적입니다.", + "highest_quality": "최고 품질: {quality}", + "select_audio_source": "오디오 소스 선택", + "endless_playback_description": "자동으로 새로운 노래를 대기열의 끝에 추가", + "choose_your_region": "지역 선택", + "choose_your_region_description": "이것은 Spotube가 위치에 맞는 콘텐츠를 표시하는 데 도움이 됩니다.", + "choose_your_language": "언어 선택", + "help_project_grow": "이 프로젝트 성장에 도움을 주세요", + "help_project_grow_description": "Spotube는 오픈 소스 프로젝트입니다. 프로젝트에 기여하거나 버그를 보고하거나 새로운 기능을 제안하여이 프로젝트의 성장에 도움을 줄 수 있습니다.", + "contribute_on_github": "GitHub에서 기여하기", + "donate_on_open_collective": "Open Collective에 기부하기", + "browse_anonymously": "익명으로 둘러보기", + "enable_connect": "연결 활성화", + "enable_connect_description": "다른 장치에서 Spotube 제어", + "devices": "장치", + "select": "선택", + "connect_client_alert": "{client}님에 의해 제어되고 있습니다", + "this_device": "이 장치", + "remote": "원격", + "local_library": "로컬 도서관", + "add_library_location": "도서관에 추가", + "remove_library_location": "도서관에서 제거", + "local_tab": "로컬", + "stats": "통계", + "and_n_more": "그리고 {count}개 더", + "recently_played": "최근 재생", + "browse_more": "더 보기", + "no_title": "제목 없음", + "not_playing": "재생 중이 아님", + "epic_failure": "서사적 실패!", + "added_num_tracks_to_queue": "{tracks_length} 곡을 대기열에 추가했습니다", + "spotube_has_an_update": "Spotube에 업데이트가 있습니다", + "download_now": "지금 다운로드", + "nightly_version": "Spotube Nightly {nightlyBuildNum}이 출시되었습니다", + "release_version": "Spotube v{version}이 출시되었습니다", + "read_the_latest": "최신 ", + "release_notes": "릴리스 노트", + "pick_color_scheme": "색상 테마 선택", + "save": "저장", + "choose_the_device": "디바이스 선택:", + "multiple_device_connected": "여러 디바이스가 연결되어 있습니다.\n이 작업을 실행할 디바이스를 선택하세요", + "nothing_found": "찾을 수 없음", + "the_box_is_empty": "상자가 비어 있습니다", + "top_artists": "톱 아티스트", + "top_albums": "톱 앨범", + "this_week": "이번 주", + "this_month": "이번 달", + "last_6_months": "지난 6개월", + "this_year": "올해", + "last_2_years": "지난 2년", + "all_time": "모든 시간", + "powered_by_provider": "{providerName} 제공", + "email": "이메일", + "profile_followers": "팔로워", + "birthday": "생일", + "subscription": "구독", + "not_born": "태어나지 않음", + "hacker": "해커", + "profile": "프로필", + "no_name": "이름 없음", + "edit": "편집", + "user_profile": "사용자 프로필", + "count_plays": "{count} 재생", + "streaming_fees_hypothetical": "*이것은 Spotify의 스트림당 지급액\n$0.003에서 $0.005를 기준으로 계산된 것입니다.\n이것은 사용자가 Spotify에서 곡을 들었을 때\n아티스트에게 지불했을 금액에 대한 통찰을 제공하기 위한\n가상의 계산입니다.", + "count_mins": "{minutes} 분", + "summary_minutes": "분", + "summary_listened_to_music": "듣는 음악", + "summary_songs": "곡", + "summary_streamed_overall": "전체 스트리밍", + "summary_owed_to_artists": "이번 달 아티스트에게 지급해야 할 금액", + "summary_artists": "아티스트의", + "summary_music_reached_you": "음악이 도달함", + "summary_full_albums": "전체 앨범", + "summary_got_your_love": "당신의 사랑을 받음", + "summary_playlists": "플레이리스트", + "summary_were_on_repeat": "반복 재생됨", + "total_money": "총 {money}", + "minutes_listened": "청취한 시간", + "streamed_songs": "스트리밍된 곡", + "count_streams": "{count} 스트림", + "owned_by_you": "당신이 소유", + "copied_shareurl_to_clipboard": "{shareUrl}를 클립보드에 복사했습니다", + "spotify_hipotetical_calculation": "*Spotify의 스트림당 지불금 $0.003에서 $0.005까지의\n기준으로 계산되었습니다. 이는 사용자가 Spotify에서\n곡을 들을 때 아티스트에게 얼마를 지불했을지를\n알려주기 위한 가상의 계산입니다.", + "webview_not_found": "웹뷰를 찾을 수 없음", + "webview_not_found_description": "기기에 웹뷰 런타임이 설치되지 않았습니다.\n설치되어 있으면 environment PATH에 있는지 확인하십시오\n\n설치 후 앱을 다시 시작하세요", + "unsupported_platform": "지원되지 않는 플랫폼" +} \ No newline at end of file diff --git a/lib/l10n/app_ne.arb b/lib/l10n/app_ne.arb index 2d20fc9c..9bcfebad 100644 --- a/lib/l10n/app_ne.arb +++ b/lib/l10n/app_ne.arb @@ -286,5 +286,106 @@ "genres": "शैलीहरू", "explore_genres": "शैलीहरू अन्वेषण गर्नुहोस्", "friends": "साथीहरू", - "no_lyrics_available": "क्षमा गर्दैछौं, यस ट्र्याकका लागि गीतका शब्दहरू फेला परेन" + "no_lyrics_available": "क्षमा गर्दैछौं, यस ट्र्याकका लागि गीतका शब्दहरू फेला परेन", + "sort_duration": "अवधिको अनुसार क्रमबद्ध गर्नुहोस्", + "start_a_radio": "रेडियो सुरु गर्नुहोस्", + "how_to_start_radio": "तपाईं रेडियो कसरी सुरु गर्न चाहानुहुन्छ?", + "replace_queue_question": "के तपाईं वर्तमान कताक्ष कोट बदल्न चाहानुहुन्छ वा यसलाई थप्नुहुन्छ?", + "endless_playback": "अनन्त प्लेब्याक", + "delete_playlist": "प्लेलिस्ट मेटाउनुहोस्", + "delete_playlist_confirmation": "के तपाईं यो प्लेलिस्ट मेटाउन निश्चित हुनुहुन्छ?", + "local_tracks": "स्थानिय ट्र्याकहरू", + "song_link": "गीत लिंक", + "skip_this_nonsense": "यस अबश्यकता छोड्नुहोस्", + "freedom_of_music": "“संगीतको स्वतन्त्रता”", + "freedom_of_music_palm": "“तपाईंको हातमा संगीतको स्वतन्त्रता”", + "get_started": "आइयाँ प्रारम्भ गरौं", + "youtube_source_description": "सिफारिस गरिएको र बेस्ट काम गर्दछ।", + "piped_source_description": "मुक्त सुस्त? YouTube जस्तै तर धेरै मुक्त।", + "jiosaavn_source_description": "दक्षिण एशियाली क्षेत्रको लागि सर्वोत्तम।", + "highest_quality": "उच्चतम गुणस्तर: {quality}", + "select_audio_source": "आडियो स्रोत चयन गर्नुहोस्", + "endless_playback_description": "नयाँ गीतहरूलाई स्वचालित रूपमा कताक्षको अन्तमा जोड्नुहोस्", + "choose_your_region": "तपाईंको क्षेत्र छनौट गर्नुहोस्", + "choose_your_region_description": "यो Spotubeलाई तपाईंको स्थानका लागि सहि सामग्री देखाउने मद्दत गर्नेछ।", + "choose_your_language": "तपाईंको भाषा छनौट गर्नुहोस्", + "help_project_grow": "यस परियोजनामा वृद्धि गराउनुहोस्", + "help_project_grow_description": "Spotube एक खुला स्रोतको परियोजना हो। तपाईं परियोजनामा योगदान गरेर, त्रुटिहरू सूचिकै, वा नयाँ सुविधाहरू सुझाव दिएर यस परियोजनामा वृद्धि गर्न सक्नुहुन्छ।", + "contribute_on_github": "GitHubमा योगदान गर्नुहोस्", + "donate_on_open_collective": "खुला संगठनमा दान गर्नुहोस्", + "browse_anonymously": "अनामित रूपमा ब्राउज़ गर्नुहोस्", + "enable_connect": "कनेक्ट सक्रिय गर्नुहोस्", + "enable_connect_description": "अन्य उपकरणहरूबाट Spotube कन्ट्रोल गर्नुहोस्", + "devices": "उपकरणहरू", + "select": "चयन गर्नुहोस्", + "connect_client_alert": "तपाईंलाई {client} द्वारा नियन्त्रित गरिएको छ", + "this_device": "यो उपकरण", + "remote": "दूरसंचार", + "local_library": "स्थानिय पुस्तकालय", + "add_library_location": "पुस्तकालयमा थप्नुहोस्", + "remove_library_location": "पुस्तकालयबाट हटाउनुहोस्", + "local_tab": "स्थानिय", + "stats": "तथ्याङ्क", + "and_n_more": "राम्रो {count} थप", + "recently_played": "हालै खेलेको", + "browse_more": "थप हेर्नुहोस्", + "no_title": "शीर्षक छैन", + "not_playing": "खेलिरहेको छैन", + "epic_failure": "महाकवि असफलता!", + "added_num_tracks_to_queue": "{tracks_length} ट्र्याकहरू तालिकामा थपिएका छन्", + "spotube_has_an_update": "Spotube मा अपडेट छ", + "download_now": "अहिले डाउनलोड गर्नुहोस्", + "nightly_version": "Spotube Nightly {nightlyBuildNum} रिलिज गरिएको छ", + "release_version": "Spotube v{version} रिलिज गरिएको छ", + "read_the_latest": "अर्को ", + "release_notes": "रिलिज नोटहरू", + "pick_color_scheme": "रंग योजना चयन गर्नुहोस्", + "save": "सुरक्षित गर्नुहोस्", + "choose_the_device": "उपकरण चयन गर्नुहोस्:", + "multiple_device_connected": "धेरै उपकरण जडान गरिएको छ।\nयो क्रियाकलाप गर्ने उपकरण चयन गर्नुहोस्", + "nothing_found": "केही फेला परेन", + "the_box_is_empty": "बक्स खाली छ", + "top_artists": "शीर्ष कलाकारहरू", + "top_albums": "शीर्ष एल्बमहरू", + "this_week": "यो हप्ता", + "this_month": "यो महिना", + "last_6_months": "पछिल्लो ६ महिना", + "this_year": "यो वर्ष", + "last_2_years": "पछिल्लो २ वर्ष", + "all_time": "सबै समय", + "powered_by_provider": "{providerName} द्वारा शक्ति प्राप्त", + "email": "ईमेल", + "profile_followers": "अनुयायीहरू", + "birthday": "जन्मदिन", + "subscription": "सदस्यता", + "not_born": "जन्मिएको छैन", + "hacker": "ह्याकर", + "profile": "प्रोफाइल", + "no_name": "नाम छैन", + "edit": "सम्पादन गर्नुहोस्", + "user_profile": "प्रयोगकर्ता प्रोफाइल", + "count_plays": "{count} खेलाइन्छ", + "streaming_fees_hypothetical": "*यो Spotify को प्रति स्ट्रिमको आधारमा गणना गरिएको छ\n$0.003 देखि $0.005 बीचको भुक्तानी। यो एक काल्पनिक गणना हो\nउपयोगकर्तालाई यो थाहा दिनको लागि कि उनीहरूले अर्टिस्टहरूलाई\nSpotify मा गीत सुनेको भए कति भुक्तानी गर्ने थिए।", + "count_mins": "{minutes} मिनेट", + "summary_minutes": "मिनेट", + "summary_listened_to_music": "सङ्गीत सुन्नु", + "summary_songs": "गीतहरू", + "summary_streamed_overall": "सामान्य रूपले स्ट्रीम गरिएको", + "summary_owed_to_artists": "यस महिना कलाकारहरूलाई देन", + "summary_artists": "कलाकारको", + "summary_music_reached_you": "सङ्गीत तपाईंलाई पुग्यो", + "summary_full_albums": "पूर्ण एल्बमहरू", + "summary_got_your_love": "तपाईंको माया प्राप्त गरियो", + "summary_playlists": "प्लेइस्ट", + "summary_were_on_repeat": "पुनरावृत्ति गरियो", + "total_money": "कुल {money}", + "minutes_listened": "सुनिएका मिनेटहरू", + "streamed_songs": "स्ट्रीम गरिएका गीतहरू", + "count_streams": "{count} स्ट्रिम", + "owned_by_you": "तपाईंले स्वामित्व गरेको", + "copied_shareurl_to_clipboard": "{shareUrl} क्लिपबोर्डमा कपी गरियो", + "spotify_hipotetical_calculation": "*यो Spotify को प्रति स्ट्रीम भुगतानको आधारमा\n$0.003 देखि $0.005 को बीचमा गणना गरिएको हो। यो एक काल्पनिक\nगणना हो जसले प्रयोगकर्तालाई देखाउँछ कि उनीहरूले कति\nअर्टिस्टहरूलाई तिनीहरूका गीतहरू Spotify मा सुनेमा\nभुक्तान गर्नुपर्ने थियो।", + "webview_not_found": "वेबभ्यू फेला परेन", + "webview_not_found_description": "तपाईंको उपकरणमा कुनै वेबभ्यू रनटाइम स्थापना गरिएको छैन।\nयदि स्थापना गरिएको छ भने, environment PATH मा छ कि छैन भनेर सुनिश्चित गर्नुहोस्\n\nस्थापना पछि, अनुप्रयोग पुनः सुरु गर्नुहोस्", + "unsupported_platform": "असमर्थित प्लेटफार्म" } \ No newline at end of file diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 3bece8be..93ab02a1 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -286,5 +286,107 @@ "genres": "Genres", "explore_genres": "Genres verkennen", "friends": "Vrienden", - "no_lyrics_available": "Sorry, geen teksten gevonden voor dit nummer" -} + "no_lyrics_available": "Sorry, geen teksten gevonden voor dit nummer", + "sort_duration": "Sorteer op Duur", + "audio_source": "Audiobron", + "start_a_radio": "Start een Radio", + "how_to_start_radio": "Hoe wilt u de radio starten?", + "replace_queue_question": "Wilt u de huidige wachtrij vervangen of eraan toevoegen?", + "endless_playback": "Eindeloze Afspelen", + "delete_playlist": "Verwijder Afspeellijst", + "delete_playlist_confirmation": "Weet u zeker dat u deze afspeellijst wilt verwijderen?", + "local_tracks": "Lokale Nummers", + "song_link": "Nummer Link", + "skip_this_nonsense": "Sla deze onzin over", + "freedom_of_music": "“Vrijheid van Muziek”", + "freedom_of_music_palm": "“Vrijheid van Muziek in de palm van je hand”", + "get_started": "Laten we beginnen", + "youtube_source_description": "Aanbevolen en werkt het beste.", + "piped_source_description": "Voel je vrij? Hetzelfde als YouTube maar veel gratis.", + "jiosaavn_source_description": "Het beste voor de Zuid-Aziatische regio.", + "highest_quality": "Hoogste Kwaliteit: {quality}", + "select_audio_source": "Selecteer Audiobron", + "endless_playback_description": "Voeg automatisch nieuwe nummers toe aan het einde van de wachtrij", + "choose_your_region": "Kies uw regio", + "choose_your_region_description": "Dit zal Spotube helpen om de juiste inhoud voor uw locatie te tonen.", + "choose_your_language": "Kies uw taal", + "help_project_grow": "Help dit project groeien", + "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.", + "contribute_on_github": "Bijdragen op GitHub", + "donate_on_open_collective": "Doneren op Open Collective", + "browse_anonymously": "Anoniem Bladeren", + "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" +} \ No newline at end of file diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index b7ce8923..c003ef08 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -286,5 +286,106 @@ "step_3_steps": "Skopiuj wartość ciasteczka \"sp_dc\"", "step_4_steps": "Wklej skopiowaną wartość \"sp_dc\"", "friends": "Przyjaciele", - "no_lyrics_available": "Przepraszamy, nie można znaleźć tekstu dla tego utworu" + "no_lyrics_available": "Przepraszamy, nie można znaleźć tekstu dla tego utworu", + "sort_duration": "Sortuj według Czasu Trwania", + "start_a_radio": "Uruchom radio", + "how_to_start_radio": "Jak chcesz uruchomić radio?", + "replace_queue_question": "Czy chcesz zastąpić bieżącą kolejkę czy dodać do niej?", + "endless_playback": "Nieskończona Odtwarzanie", + "delete_playlist": "Usuń Playlistę", + "delete_playlist_confirmation": "Czy na pewno chcesz usunąć tę listę odtwarzania?", + "local_tracks": "Lokalne Utwory", + "song_link": "Link do Utworu", + "skip_this_nonsense": "Pomiń tę bzdurę", + "freedom_of_music": "“Wolność Muzyki”", + "freedom_of_music_palm": "“Wolność Muzyki w Twojej dłoni”", + "get_started": "Zacznijmy", + "youtube_source_description": "Polecane i działa najlepiej.", + "piped_source_description": "Czujesz się wolny? To samo co YouTube, ale dużo za darmo.", + "jiosaavn_source_description": "Najlepszy dla regionu Azji Południowej.", + "highest_quality": "Najwyższa Jakość: {quality}", + "select_audio_source": "Wybierz Źródło Audio", + "endless_playback_description": "Automatycznie dodaj nowe utwory na koniec kolejki", + "choose_your_region": "Wybierz swoją region", + "choose_your_region_description": "To pomoże Spotube pokazać Ci odpowiednią treść dla Twojej lokalizacji.", + "choose_your_language": "Wybierz swój język", + "help_project_grow": "Pomóż temu projektowi rosnąć", + "help_project_grow_description": "Spotube to projekt open-source. Możesz pomóc temu projektowi rosnąć, przyczyniając się do projektu, zgłaszając błędy lub sugerując nowe funkcje.", + "contribute_on_github": "Przyczyniaj się na GitHubie", + "donate_on_open_collective": "Dotuj na Open Collective", + "browse_anonymously": "Przeglądaj Anonimowo", + "enable_connect": "Włącz połączenie", + "enable_connect_description": "Kontroluj Spotube z innych urządzeń", + "devices": "Urządzenia", + "select": "Wybierz", + "connect_client_alert": "Jesteś sterowany przez {client}", + "this_device": "To urządzenie", + "remote": "Zdalny", + "local_library": "Biblioteka lokalna", + "add_library_location": "Dodaj do biblioteki", + "remove_library_location": "Usuń z biblioteki", + "local_tab": "Lokalny", + "stats": "Statystyki", + "and_n_more": "i {count} więcej", + "recently_played": "Ostatnio odtwarzane", + "browse_more": "Zobacz więcej", + "no_title": "Brak tytułu", + "not_playing": "Nie odtwarzane", + "epic_failure": "Epicka porażka!", + "added_num_tracks_to_queue": "Dodano {tracks_length} utworów do kolejki", + "spotube_has_an_update": "Spotube ma aktualizację", + "download_now": "Pobierz teraz", + "nightly_version": "Spotube Nightly {nightlyBuildNum} został wydany", + "release_version": "Spotube v{version} został wydany", + "read_the_latest": "Przeczytaj najnowsze ", + "release_notes": "notatki o wersji", + "pick_color_scheme": "Wybierz schemat kolorów", + "save": "Zapisz", + "choose_the_device": "Wybierz urządzenie:", + "multiple_device_connected": "Jest wiele urządzeń podłączonych.\nWybierz urządzenie, na którym chcesz wykonać tę akcję", + "nothing_found": "Nic nie znaleziono", + "the_box_is_empty": "Pudełko jest puste", + "top_artists": "Najlepsi artyści", + "top_albums": "Najlepsze albumy", + "this_week": "W tym tygodniu", + "this_month": "W tym miesiącu", + "last_6_months": "Ostatnie 6 miesięcy", + "this_year": "W tym roku", + "last_2_years": "Ostatnie 2 lata", + "all_time": "Wszystkie czasy", + "powered_by_provider": "Napędzane przez {providerName}", + "email": "E-mail", + "profile_followers": "Obserwujący", + "birthday": "Data urodzenia", + "subscription": "Subskrypcja", + "not_born": "Nie urodzony", + "hacker": "Haker", + "profile": "Profil", + "no_name": "Brak nazwy", + "edit": "Edytuj", + "user_profile": "Profil użytkownika", + "count_plays": "{count} odtworzeń", + "streaming_fees_hypothetical": "*Obliczone na podstawie wypłaty Spotify za stream\nod $0.003 do $0.005. Jest to hipotetyczne\nobliczenie, które ma na celu pokazanie, ile\nużytkownik zapłaciłby artystom, gdyby odsłuchał\ntych utworów na Spotify.", + "count_mins": "{minutes} min", + "summary_minutes": "minuty", + "summary_listened_to_music": "Słuchana muzyka", + "summary_songs": "utwory", + "summary_streamed_overall": "Ogółem streamowane", + "summary_owed_to_artists": "Do zapłaty artystom\nw tym miesiącu", + "summary_artists": "artystów", + "summary_music_reached_you": "Muzyka dotarła do Ciebie", + "summary_full_albums": "pełne albumy", + "summary_got_your_love": "Otrzymał Twoją miłość", + "summary_playlists": "playlisty", + "summary_were_on_repeat": "Były na powtarzaniu", + "total_money": "Łącznie {money}", + "minutes_listened": "Minuty odsłuchane", + "streamed_songs": "Strumieniowane utwory", + "count_streams": "{count} strumieni", + "owned_by_you": "Własność Twoja", + "copied_shareurl_to_clipboard": "{shareUrl} skopiowano do schowka", + "spotify_hipotetical_calculation": "*Obliczone na podstawie płatności Spotify za strumień\nw zakresie od $0.003 do $0.005. Jest to hipotetyczne\nobliczenie mające na celu pokazanie użytkownikowi, ile\nzapłaciliby artystom, gdyby słuchali ich utworów na Spotify.", + "webview_not_found": "Nie znaleziono Webview", + "webview_not_found_description": "Na twoim urządzeniu nie zainstalowano środowiska uruchomieniowego Webview.\nJeśli jest zainstalowany, upewnij się, że jest w environment PATH\n\nPo instalacji uruchom ponownie aplikację", + "unsupported_platform": "Nieobsługiwana platforma" } \ No newline at end of file diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 1c75f734..02772b1e 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -286,5 +286,106 @@ "step_3_steps": "Copie o valor do cookie \"sp_dc\"", "step_4_steps": "Cole o valor copiado de \"sp_dc\"", "friends": "Amigos", - "no_lyrics_available": "Desculpe, não foi possível encontrar a letra desta faixa" + "no_lyrics_available": "Desculpe, não foi possível encontrar a letra desta faixa", + "sort_duration": "Ordenar por Duração", + "start_a_radio": "Iniciar uma Rádio", + "how_to_start_radio": "Como você deseja iniciar a rádio?", + "replace_queue_question": "Você deseja substituir a fila atual ou acrescentar a ela?", + "endless_playback": "Reprodução sem fim", + "delete_playlist": "Excluir Lista de Reprodução", + "delete_playlist_confirmation": "Tem certeza de que deseja excluir esta lista de reprodução?", + "local_tracks": "Faixas Locais", + "song_link": "Link da Música", + "skip_this_nonsense": "Pular essa bobagem", + "freedom_of_music": "“Liberdade da Música”", + "freedom_of_music_palm": "“Liberdade da Música na palma da sua mão”", + "get_started": "Vamos começar", + "youtube_source_description": "Recomendado e funciona melhor.", + "piped_source_description": "Sentindo-se livre? Igual ao YouTube, mas muito mais grátis.", + "jiosaavn_source_description": "Melhor para a região da Ásia do Sul.", + "highest_quality": "Melhor Qualidade: {quality}", + "select_audio_source": "Selecionar Fonte de Áudio", + "endless_playback_description": "Adicionar automaticamente novas músicas\nao final da fila", + "choose_your_region": "Escolha sua região", + "choose_your_region_description": "Isso ajudará o Spotube a mostrar o conteúdo certo\npara sua localização.", + "choose_your_language": "Escolha seu idioma", + "help_project_grow": "Ajude este projeto a crescer", + "help_project_grow_description": "Spotube é um projeto de código aberto. Você pode ajudar este projeto a crescer contribuindo para o projeto, relatando bugs ou sugerindo novos recursos.", + "contribute_on_github": "Contribuir no GitHub", + "donate_on_open_collective": "Doar no Open Collective", + "browse_anonymously": "Navegar Anonimamente", + "enable_connect": "Ativar conexão", + "enable_connect_description": "Controle o Spotube a partir de outros dispositivos", + "devices": "Dispositivos", + "select": "Selecionar", + "connect_client_alert": "Você está sendo controlado por {client}", + "this_device": "Este dispositivo", + "remote": "Remoto", + "local_library": "Biblioteca local", + "add_library_location": "Adicionar à biblioteca", + "remove_library_location": "Remover da biblioteca", + "local_tab": "Local", + "stats": "Estatísticas", + "and_n_more": "e {count} mais", + "recently_played": "Reproduzido Recentemente", + "browse_more": "Ver Mais", + "no_title": "Sem Título", + "not_playing": "Não está a reproduzir", + "epic_failure": "Fracasso épico!", + "added_num_tracks_to_queue": "Adicionados {tracks_length} faixas à fila", + "spotube_has_an_update": "Spotube tem uma atualização", + "download_now": "Baixar Agora", + "nightly_version": "Spotube Nightly {nightlyBuildNum} foi lançado", + "release_version": "Spotube v{version} foi lançado", + "read_the_latest": "Leia o mais recente ", + "release_notes": "notas de versão", + "pick_color_scheme": "Escolha o esquema de cores", + "save": "Salvar", + "choose_the_device": "Escolha o dispositivo:", + "multiple_device_connected": "Há vários dispositivos conectados.\nEscolha o dispositivo no qual deseja executar esta ação", + "nothing_found": "Nada encontrado", + "the_box_is_empty": "A caixa está vazia", + "top_artists": "Principais Artistas", + "top_albums": "Principais Álbuns", + "this_week": "Esta semana", + "this_month": "Este mês", + "last_6_months": "Últimos 6 meses", + "this_year": "Este ano", + "last_2_years": "Últimos 2 anos", + "all_time": "De todos os tempos", + "powered_by_provider": "Desenvolvido por {providerName}", + "email": "E-mail", + "profile_followers": "Seguidores", + "birthday": "Aniversário", + "subscription": "Assinatura", + "not_born": "Não nascido", + "hacker": "Hacker", + "profile": "Perfil", + "no_name": "Sem Nome", + "edit": "Editar", + "user_profile": "Perfil do Usuário", + "count_plays": "{count} reproduzidos", + "streaming_fees_hypothetical": "*Calculado com base no pagamento por stream do Spotify\nque varia de $0.003 a $0.005. Isso é um cálculo hipotético\npara fornecer uma visão ao usuário sobre quanto eles\nteriam pago aos artistas se estivessem ouvindo\no seu som no Spotify.", + "count_mins": "{minutes} min", + "summary_minutes": "minutos", + "summary_listened_to_music": "Música ouvida", + "summary_songs": "faixas", + "summary_streamed_overall": "Total de streams", + "summary_owed_to_artists": "Devido aos artistas\neste mês", + "summary_artists": "artista", + "summary_music_reached_you": "A música chegou até você", + "summary_full_albums": "álbuns completos", + "summary_got_your_love": "Recebeu seu amor", + "summary_playlists": "playlists", + "summary_were_on_repeat": "Estavam em repetição", + "total_money": "Total {money}", + "minutes_listened": "Minutos ouvidos", + "streamed_songs": "Músicas transmitidas", + "count_streams": "{count} streams", + "owned_by_you": "De sua propriedade", + "copied_shareurl_to_clipboard": "{shareUrl} copiado para a área de transferência", + "spotify_hipotetical_calculation": "*Isso é calculado com base no pagamento por stream do Spotify\nque varia de $0.003 a $0.005. Esta é uma cálculo hipotético\npara dar ao usuário uma visão de quanto teriam pago aos artistas\nse eles ouvissem suas músicas no Spotify.", + "webview_not_found": "Webview não encontrado", + "webview_not_found_description": "Nenhum runtime Webview está instalado no seu dispositivo.\nSe estiver instalado, certifique-se de que está no environment PATH\n\nApós a instalação, reinicie o aplicativo", + "unsupported_platform": "Plataforma não suportada" } \ No newline at end of file diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 7ed67f4f..189e644f 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,8 +288,104 @@ "browse_all": "Просмотреть все", "genres": "Жанры", "explore_genres": "Исследовать жанры", - "step_3_steps": "Скопируйте значение файла cookie \"sp_dc\"", - "step_4_steps": "Вставьте скопированное значение \"sp_dc\"", "friends": "Друзья", - "no_lyrics_available": "Извините, не удается найти текст для этого трека" + "no_lyrics_available": "Извините, не удается найти текст для этого трека", + "start_a_radio": "Запустить радио", + "how_to_start_radio": "Как вы хотите запустить радио?", + "replace_queue_question": "Хотите заменить текущую очередь или добавить к ней?", + "endless_playback": "Бесконечное воспроизведение", + "delete_playlist": "Удалить плейлист", + "delete_playlist_confirmation": "Вы уверены, что хотите удалить этот плейлист?", + "local_tracks": "Локальные треки", + "local_tab": "Локальное", + "song_link": "Ссылка на песню", + "skip_this_nonsense": "Пропустить этот бред", + "freedom_of_music": "“Свобода музыки”", + "freedom_of_music_palm": "“Свобода музыки в вашей ладони”", + "get_started": "Начнем", + "youtube_source_description": "Рекомендуется и лучше всего работает.", + "piped_source_description": "Чувствуете себя свободно? То же самое, что и YouTube, но намного бесплатно.", + "jiosaavn_source_description": "Лучший для Южно-Азиатского региона.", + "highest_quality": "Наивысшее качество: {quality}", + "select_audio_source": "Выберите аудиоисточник", + "endless_playback_description": "Автоматически добавляйте новые песни\nв конец очереди", + "choose_your_region": "Выберите ваш регион", + "choose_your_region_description": "Это поможет Spotube показать вам правильный контент\nдля вашего местоположения.", + "choose_your_language": "Выберите ваш язык", + "help_project_grow": "Помогите этому проекту расти", + "help_project_grow_description": "Spotube - это проект с открытым исходным кодом. Вы можете помочь этому проекту развиваться, внося вклад в проект, сообщая ошибках или предлагая новые функции.", + "contribute_on_github": "Внести вклад на GitHub", + "donate_on_open_collective": "Пожертвовать на Open Collective", + "browse_anonymously": "Анонимно просматривать", + "enable_connect": "Включить подключение", + "enable_connect_description": "Управление Spotube с других устройств", + "devices": "Устройства", + "select": "Выбрать", + "connect_client_alert": "Вас контролирует {client}", + "this_device": "Это устройство", + "remote": "Дистанционное управление", + "stats": "Статистика", + "update_playlist": "Обновить плейлист", + "and_n_more": "и {count} еще", + "recently_played": "Недавно воспроизведено", + "browse_more": "Посмотреть больше", + "no_title": "Без названия", + "not_playing": "Не воспроизводится", + "epic_failure": "Эпическое фиаско!", + "added_num_tracks_to_queue": "Добавлено {tracks_length} треков в очередь", + "spotube_has_an_update": "В Spotube доступно обновление", + "download_now": "Скачать сейчас", + "nightly_version": "Spotube Nightly {nightlyBuildNum} выпущен", + "release_version": "Spotube v{version} выпущен", + "read_the_latest": "Читать последние ", + "release_notes": "заметки о версии", + "pick_color_scheme": "Выберите цветовую схему", + "save": "Сохранить", + "choose_the_device": "Выберите устройство:", + "multiple_device_connected": "Подключено несколько устройств.\nВыберите устройство, на котором вы хотите выполнить это действие", + "nothing_found": "Ничего не найдено", + "the_box_is_empty": "Коробка пуста", + "top_artists": "Лучшие артисты", + "top_albums": "Лучшие альбомы", + "this_week": "На этой неделе", + "this_month": "В этом месяце", + "last_6_months": "Последние 6 месяцев", + "this_year": "В этом году", + "last_2_years": "Последние 2 года", + "all_time": "Все время", + "powered_by_provider": "При поддержке {providerName}", + "email": "Электронная почта", + "profile_followers": "Подписчики", + "birthday": "День рождения", + "subscription": "Подписка", + "not_born": "Не рожден", + "hacker": "Хакер", + "profile": "Профиль", + "no_name": "Без имени", + "edit": "Редактировать", + "user_profile": "Профиль пользователя", + "count_plays": "{count} воспроизведений", + "streaming_fees_hypothetical": "*Рассчитано на основе выплат Spotify за стрим\nот $0.003 до $0.005. Это гипотетический\nрасчет, чтобы показать пользователю, сколько бы он\nзаплатил артистам, если бы слушал их песни на Spotify.", + "count_mins": "{minutes} мин", + "summary_minutes": "минуты", + "summary_listened_to_music": "Слушанная музыка", + "summary_songs": "песни", + "summary_streamed_overall": "Всего стримов", + "summary_owed_to_artists": "К выплате артистам\nв этом месяце", + "summary_artists": "артиста", + "summary_music_reached_you": "Музыка дошла до вас", + "summary_full_albums": "полные альбомы", + "summary_got_your_love": "Получил вашу любовь", + "summary_playlists": "плейлисты", + "summary_were_on_repeat": "Были на повторе", + "total_money": "Всего {money}", + "minutes_listened": "Минут прослушивания", + "streamed_songs": "Стримленные песни", + "count_streams": "{count} стримов", + "owned_by_you": "Ваша собственность", + "copied_shareurl_to_clipboard": "{shareUrl} скопировано в буфер обмена", + "spotify_hipotetical_calculation": "*Это рассчитано на основе выплат Spotify за стрим\nот $0.003 до $0.005. Это гипотетический расчет,\nчтобы дать пользователю представление о том, сколько бы он\nзаплатил артистам, если бы слушал их песни на Spotify.", + "webview_not_found": "Webview не найден", + "webview_not_found_description": "На вашем устройстве не установлена среда выполнения Webview.\nЕсли он установлен, убедитесь, что он находится в environment PATH\n\nПосле установки перезапустите приложение", + "unsupported_platform": "Платформа не поддерживается" } \ 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..27c05a5d --- /dev/null +++ b/lib/l10n/app_th.arb @@ -0,0 +1,392 @@ +{ + "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": "แพลตฟอร์มไม่รองรับ" +} \ No newline at end of file diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 4d9066fd..230f14e8 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,74 +220,172 @@ "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" + "no_lyrics_available": "Üzgünüz, bu parçanın sözleri bulunamıyor", + "start_a_radio": "Radyo başlat", + "how_to_start_radio": "Radyoyu nasıl başlatmak istersiniz?", + "replace_queue_question": "Mevcut kuyruğu değiştirmek mi yoksa eklemek mi istersiniz?", + "endless_playback": "Sonsuz olarak oynat", + "delete_playlist": "Oynatma listesini sil", + "delete_playlist_confirmation": "Bu oynatma listesini silmek istediğinizden emin misiniz?", + "local_tracks": "Yerel parçalar", + "song_link": "Şarkı bağlantısı", + "skip_this_nonsense": "Bu saçmalığı atla", + "freedom_of_music": "“Müzik özgürlüğü”", + "freedom_of_music_palm": "“Müzik özgürlüğü avucunuzun içinde”", + "get_started": "Haydi başlayalım", + "youtube_source_description": "Tavsiye edilir ve en iyi şekilde çalışır.", + "piped_source_description": "Özgür hissediyor musunuz? YouTube ile aynı, ama çok daha özgür.", + "jiosaavn_source_description": "Güney Asya bölgesi için en iyisi.", + "highest_quality": "En yüksek kalite: {quality}", + "select_audio_source": "Ses kaynağını seçin", + "endless_playback_description": "Yeni şarkıları otomatik olarak\nkuyruğun sonuna ekle", + "choose_your_region": "Bölgenizi seçin", + "choose_your_region_description": "Bu, Spotube'un konumunuza uygun içerikleri göstermesine yardımcı olacaktır.", + "choose_your_language": "Dilinizi seçin", + "help_project_grow": "Bu projenin büyümesine yardımcı olun", + "help_project_grow_description": "Spotube açık kaynaklı bir projedir. Projeye katkıda bulunarak, hataları bildirerek veya yeni özellikler önererek bu projenin büyümesine yardımcı olabilirsiniz.", + "contribute_on_github": "GitHub'da katkıda bulun", + "donate_on_open_collective": "Open Collective'de bağış yap", + "browse_anonymously": "Anonim olarak giriş yap", + "enable_connect": "Bağlanmayı etkinleştir", + "enable_connect_description": "Spotube'u diğer cihazlardan kontrol edin", + "devices": "Cihazlar", + "select": "Seç", + "connect_client_alert": "{client} tarafından kontrol ediliyorsun.", + "this_device": "Bu cihaz", + "remote": "Yönet", + "local_library": "Yerel kütüphane", + "add_library_location": "Kütüphaneye ekle", + "remove_library_location": "Kütüphaneden çıkar", + "local_tab": "Yerel", + "stats": "İstatistikler", + "and_n_more": "ve {count} daha", + "recently_played": "Son Çalınanlar", + "browse_more": "Daha Fazla Göz At", + "no_title": "Başlık Yok", + "not_playing": "Çalmıyor", + "epic_failure": "Efsanevi başarısızlık!", + "added_num_tracks_to_queue": "{tracks_length} şarkı sıraya eklendi", + "spotube_has_an_update": "Spotube bir güncelleme aldı", + "download_now": "Şimdi İndir", + "nightly_version": "Spotube Nightly {nightlyBuildNum} yayımlandı", + "release_version": "Spotube v{version} yayımlandı", + "read_the_latest": "Son haberleri oku", + "release_notes": "sürüm notları", + "pick_color_scheme": "Renk şeması seç", + "save": "Kaydet", + "choose_the_device": "Cihazı seçin:", + "multiple_device_connected": "Birden fazla cihaz bağlı.\nBu işlemi gerçekleştirmek istediğiniz cihazı seçin", + "nothing_found": "Hiçbir şey bulunamadı", + "the_box_is_empty": "Kutu boş", + "top_artists": "En İyi Sanatçılar", + "top_albums": "En İyi Albümler", + "this_week": "Bu hafta", + "this_month": "Bu ay", + "last_6_months": "Son 6 ay", + "this_year": "Bu yıl", + "last_2_years": "Son 2 yıl", + "all_time": "Tüm zamanlar", + "powered_by_provider": "{providerName} tarafından desteklenmektedir", + "email": "E-posta", + "profile_followers": "Takipçiler", + "birthday": "Doğum Günü", + "subscription": "Abonelik", + "not_born": "Henüz doğmadı", + "hacker": "Hacker", + "profile": "Profil", + "no_name": "İsim Yok", + "edit": "Düzenle", + "user_profile": "Kullanıcı Profili", + "count_plays": "{count} çalma", + "streaming_fees_hypothetical": "*Spotify'ın akış başına ödeme miktarına\n$0.003 ile $0.005 arasında hesaplanmıştır. Bu, kullanıcıya\nSpotify'da şarkılarını dinlerse sanatçılara ne kadar ödeme\nyapmış olabileceğini göstermek için hipotetik bir hesaplamadır.", + "count_mins": "{minutes} dk", + "summary_minutes": "dakika", + "summary_listened_to_music": "Dinlenen müzik", + "summary_songs": "şarkılar", + "summary_streamed_overall": "Genel olarak akış", + "summary_owed_to_artists": "Sanatçılara borç\nbu ay", + "summary_artists": "sanatçının", + "summary_music_reached_you": "Müzik sana ulaştı", + "summary_full_albums": "tam albümler", + "summary_got_your_love": "Sevgini aldı", + "summary_playlists": "çalma listeleri", + "summary_were_on_repeat": "Tekrarda vardı", + "total_money": "Toplam {money}", + "minutes_listened": "Dinlenilen Dakikalar", + "streamed_songs": "Yayınlanan Şarkılar", + "count_streams": "{count} yayın", + "owned_by_you": "Sahip olduğunuz", + "copied_shareurl_to_clipboard": "{shareUrl} panoya kopyalandı", + "spotify_hipotetical_calculation": "*Bu, Spotify'ın her yayın başına ödemenin\n$0.003 ile $0.005 arasında olduğu varsayımıyla hesaplanmıştır. Bu\nhipotetik bir hesaplamadır, kullanıcıya şarkılarını Spotify'da dinlediklerinde\nsanatçılara ne kadar ödeme yapacaklarını gösterir.", + "webview_not_found": "Webview bulunamadı", + "webview_not_found_description": "Cihazınızda herhangi bir Webview çalışma zamanı yüklü değil.\nEğer kuruluysa, ortam YOLUNDA olduğundan emin olun\n\nKurulumdan sonra uygulamayı yeniden başlatın", + "unsupported_platform": "Desteklenmeyen platform" } \ No newline at end of file diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index a4586a5e..0c65f756 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -286,5 +286,106 @@ "step_3_steps": "Скопіюйте значення cookie \"sp_dc\"", "step_4_steps": "Вставте скопійоване значення \"sp_dc\"", "friends": "Друзі", - "no_lyrics_available": "Вибачте, не вдалося знайти текст для цього треку" + "no_lyrics_available": "Вибачте, не вдалося знайти текст для цього треку", + "sort_duration": "Сортувати за тривалістю", + "start_a_radio": "Запустити радіо", + "how_to_start_radio": "Як ви хочете запустити радіо?", + "replace_queue_question": "Ви хочете замінити поточну чергу чи додати до неї?", + "endless_playback": "Безкінечне відтворення", + "delete_playlist": "Видалити плейлист", + "delete_playlist_confirmation": "Ви впевнені, що хочете видалити цей плейлист?", + "local_tracks": "Місцеві треки", + "song_link": "Посилання на пісню", + "skip_this_nonsense": "Пропустити цей бред", + "freedom_of_music": "“Свобода музики”", + "freedom_of_music_palm": "“Свобода музики у вашій долоні”", + "get_started": "Давайте почнемо", + "youtube_source_description": "Рекомендовано та працює краще за все.", + "piped_source_description": "Чи почуваєте себе вільно? Те саме, що і на YouTube, але набагато безкоштовно.", + "jiosaavn_source_description": "Найкраще для регіону Південної Азії.", + "highest_quality": "Найвища якість: {quality}", + "select_audio_source": "Виберіть джерело аудіо", + "endless_playback_description": "Автоматично додавати нові пісні\nв кінець черги", + "choose_your_region": "Виберіть ваш регіон", + "choose_your_region_description": "Це допоможе Spotube показати вам правильний контент\nдля вашого місцезнаходження.", + "choose_your_language": "Виберіть свою мову", + "help_project_grow": "Допоможіть цьому проекту рости", + "help_project_grow_description": "Spotube - це проект з відкритим кодом. Ви можете допомогти цьому проекту зростати, вносячи свій внесок у проект, повідомляючи про помилки або пропонуючи нові функції.", + "contribute_on_github": "Долучайтесь на GitHub", + "donate_on_open_collective": "Пожертвуйте на Open Collective", + "browse_anonymously": "Анонімно переглядати", + "enable_connect": "Увімкнути підключення", + "enable_connect_description": "Керуйте Spotube з інших пристроїв", + "devices": "Пристрої", + "select": "Вибрати", + "connect_client_alert": "Вас керує {client}", + "this_device": "Цей пристрій", + "remote": "Віддалений", + "local_library": "Місцева бібліотека", + "add_library_location": "Додати до бібліотеки", + "remove_library_location": "Видалити з бібліотеки", + "local_tab": "Місцевий", + "stats": "Статистика", + "and_n_more": "і {count} більше", + "recently_played": "Нещодавно Відтворене", + "browse_more": "Переглянути Більше", + "no_title": "Без Назви", + "not_playing": "Не Відтворюється", + "epic_failure": "Епічний провал!", + "added_num_tracks_to_queue": "Додано {tracks_length} треків до черги", + "spotube_has_an_update": "Spotube має оновлення", + "download_now": "Завантажити Зараз", + "nightly_version": "Spotube Nightly {nightlyBuildNum} було випущено", + "release_version": "Spotube v{version} було випущено", + "read_the_latest": "Читати останні новини", + "release_notes": "ноти про випуск", + "pick_color_scheme": "Оберіть кольорову схему", + "save": "Зберегти", + "choose_the_device": "Виберіть пристрій:", + "multiple_device_connected": "Підключено кілька пристроїв.\nВиберіть пристрій, на якому ви хочете виконати цю дію", + "nothing_found": "Нічого не знайдено", + "the_box_is_empty": "Коробка порожня", + "top_artists": "Топ Артисти", + "top_albums": "Топ Альбоми", + "this_week": "Цього тижня", + "this_month": "Цього місяця", + "last_6_months": "Останні 6 місяців", + "this_year": "Цього року", + "last_2_years": "Останні 2 роки", + "all_time": "Усі часи", + "powered_by_provider": "Забезпечено {providerName}", + "email": "Електронна пошта", + "profile_followers": "Підписники", + "birthday": "День народження", + "subscription": "Підписка", + "not_born": "Ще не народжений", + "hacker": "Хакер", + "profile": "Профіль", + "no_name": "Без імені", + "edit": "Редагувати", + "user_profile": "Профіль користувача", + "count_plays": "{count} відтворень", + "streaming_fees_hypothetical": "*Розраховано на основі виплат Spotify за стримінг\nвід $0.003 до $0.005. Це гіпотетичний\nрозрахунок, щоб дати уявлення користувачу про те, скільки б він\nзаплатив артистам, якби слухав їхні пісні на Spotify.", + "count_mins": "{minutes} хв", + "summary_minutes": "хвилини", + "summary_listened_to_music": "Прослухана музика", + "summary_songs": "пісні", + "summary_streamed_overall": "Загалом стримів", + "summary_owed_to_artists": "Заборгованість артистам\nцього місяця", + "summary_artists": "артистів", + "summary_music_reached_you": "Музика досягла вас", + "summary_full_albums": "повні альбоми", + "summary_got_your_love": "Отримав вашу любов", + "summary_playlists": "плейлисти", + "summary_were_on_repeat": "Були на повторі", + "total_money": "Загалом {money}", + "minutes_listened": "Хвилини прослуховування", + "streamed_songs": "Стримлені пісні", + "count_streams": "{count} стримів", + "owned_by_you": "Ваша власність", + "copied_shareurl_to_clipboard": "{shareUrl} скопійовано в буфер обміну", + "spotify_hipotetical_calculation": "*Це розраховано на основі виплат Spotify за стрім\nвід $0.003 до $0.005. Це гіпотетичний розрахунок,\nщоб дати користувачеві уявлення про те, скільки б він заплатив\nартистам, якби слухав їхні пісні на Spotify.", + "webview_not_found": "Webview не знайдено", + "webview_not_found_description": "На вашому пристрої не встановлено виконуване середовище Webview.\nЯкщо воно встановлено, переконайтеся, що воно знаходиться в environment PATH\n\nПісля встановлення перезапустіть програму", + "unsupported_platform": "Непідтримувана платформа" } \ No newline at end of file diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index d8d337c2..75dc1532 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -284,5 +284,108 @@ "discord_rich_presence": "Hiển thị trạng thái Discord", "browse_all": "Duyệt tất cả", "genres": "Thể loại", - "explore_genres": "Khám phá Thể loại" -} + "explore_genres": "Khám phá Thể loại", + "sort_duration": "Sắp xếp theo Thời lượng", + "start_a_radio": "Bắt đầu Một Đài phát thanh", + "how_to_start_radio": "Bạn muốn bắt đầu đài phát thanh như thế nào?", + "replace_queue_question": "Bạn muốn thay thế hàng đợi hiện tại hay thêm vào?", + "endless_playback": "Phát không giới hạn", + "delete_playlist": "Xóa Danh sách phát", + "delete_playlist_confirmation": "Bạn có chắc chắn muốn xóa danh sách phát này không?", + "local_tracks": "Bài hát Địa phương", + "song_link": "Liên kết Bài hát", + "skip_this_nonsense": "Bỏ qua bớt rối này", + "freedom_of_music": "“Sự Tự do của Âm nhạc”", + "freedom_of_music_palm": "“Sự Tự do của Âm nhạc trong lòng bàn tay của bạn”", + "get_started": "Bắt đầu thôi", + "youtube_source_description": "Được đề xuất và hoạt động tốt nhất.", + "piped_source_description": "Cảm thấy tự do? Giống như YouTube nhưng miễn phí hơn rất nhiều.", + "jiosaavn_source_description": "Tốt nhất cho khu vực Nam Á.", + "highest_quality": "Chất lượng Tốt nhất: {quality}", + "select_audio_source": "Chọn Nguồn Âm thanh", + "endless_playback_description": "Tự động thêm các bài hát mới\nvào cuối hàng đợi", + "choose_your_region": "Chọn khu vực của bạn", + "choose_your_region_description": "Điều này sẽ giúp Spotube hiển thị nội dung phù hợp cho vị trí của bạn.", + "choose_your_language": "Chọn ngôn ngữ của bạn", + "help_project_grow": "Hãy giúp dự án này phát triển", + "help_project_grow_description": "Spotube là một dự án mã nguồn mở. Bạn có thể giúp dự án này phát triển bằng cách đóng góp vào dự án, báo cáo lỗi hoặc đề xuất tính năng mới.", + "contribute_on_github": "Đóng góp trên GitHub", + "donate_on_open_collective": "Quyên góp trên Open Collective", + "browse_anonymously": "Duyệt Anonymously", + "friends": "Bạn bè", + "no_lyrics_available": "Xin lỗi, không tìm thấy lời cho bài hát này", + "enable_connect": "Kích hoạt kết nối", + "enable_connect_description": "Điều khiển Spotube từ các thiết bị khác", + "devices": "Thiết bị", + "select": "Chọn", + "connect_client_alert": "Bạn đang được điều khiển bởi {client}", + "this_device": "Thiết bị này", + "remote": "Từ xa", + "local_library": "Thư viện địa phương", + "add_library_location": "Thêm vào thư viện", + "remove_library_location": "Xóa khỏi thư viện", + "local_tab": "Địa phương", + "stats": "Thống kê", + "and_n_more": "và {count} cái khác", + "recently_played": "Gần đây đã phát", + "browse_more": "Xem thêm", + "no_title": "Không có tiêu đề", + "not_playing": "Không phát", + "epic_failure": "Thất bại hoàn toàn!", + "added_num_tracks_to_queue": "Đã thêm {tracks_length} bài hát vào danh sách phát", + "spotube_has_an_update": "Spotube có bản cập nhật", + "download_now": "Tải về ngay", + "nightly_version": "Spotube Nightly {nightlyBuildNum} đã được phát hành", + "release_version": "Spotube v{version} đã được phát hành", + "read_the_latest": "Đọc tin mới nhất", + "release_notes": "ghi chú phát hành", + "pick_color_scheme": "Chọn chủ đề màu sắc", + "save": "Lưu", + "choose_the_device": "Chọn thiết bị:", + "multiple_device_connected": "Có nhiều thiết bị kết nối.\nChọn thiết bị mà bạn muốn thực hiện hành động này", + "nothing_found": "Không tìm thấy gì", + "the_box_is_empty": "Hộp trống", + "top_artists": "Những Nghệ Sĩ Hàng Đầu", + "top_albums": "Những Album Hàng Đầu", + "this_week": "Tuần này", + "this_month": "Tháng này", + "last_6_months": "6 tháng qua", + "this_year": "Năm nay", + "last_2_years": "2 năm qua", + "all_time": "Mọi thời đại", + "powered_by_provider": "Cung cấp bởi {providerName}", + "email": "Email", + "profile_followers": "Người theo dõi", + "birthday": "Ngày sinh", + "subscription": "Gói cước", + "not_born": "Chưa sinh", + "hacker": "Tin tặc", + "profile": "Hồ sơ", + "no_name": "Không có tên", + "edit": "Chỉnh sửa", + "user_profile": "Hồ sơ người dùng", + "count_plays": "{count} lần phát", + "streaming_fees_hypothetical": "*Tính toán dựa trên thanh toán của Spotify cho mỗi lần phát\ntừ $0.003 đến $0.005. Đây là một tính toán giả định để\ngive người dùng cái nhìn về số tiền họ sẽ chi trả cho các nghệ sĩ nếu họ nghe\nbài hát của họ trên Spotify.", + "count_mins": "{minutes} phút", + "summary_minutes": "phút", + "summary_listened_to_music": "Đã nghe nhạc", + "summary_songs": "bài hát", + "summary_streamed_overall": "Stream tổng cộng", + "summary_owed_to_artists": "Nợ nghệ sĩ\ntrong tháng này", + "summary_artists": "nghệ sĩ", + "summary_music_reached_you": "Âm nhạc đã đến với bạn", + "summary_full_albums": "album đầy đủ", + "summary_got_your_love": "Nhận được tình yêu của bạn", + "summary_playlists": "danh sách phát", + "summary_were_on_repeat": "Đã được phát lại", + "total_money": "Tổng cộng {money}", + "minutes_listened": "Thời gian nghe", + "streamed_songs": "Bài hát đã phát", + "count_streams": "{count} lượt phát", + "owned_by_you": "Thuộc sở hữu của bạn", + "copied_shareurl_to_clipboard": "{shareUrl} đã sao chép vào bảng tạm", + "spotify_hipotetical_calculation": "*Được tính toán dựa trên khoản thanh toán của Spotify cho mỗi lượt phát\ntừ $0.003 đến $0.005. Đây là một tính toán giả định để\ncung cấp cho người dùng cái nhìn về số tiền họ sẽ phải trả\ncho các nghệ sĩ nếu họ nghe bài hát của họ trên Spotify.", + "webview_not_found": "Không tìm thấy Webview", + "webview_not_found_description": "Không có runtime Webview nào được cài đặt trên thiết bị của bạn.\nNếu đã cài đặt, hãy đảm bảo rằng nó nằm trong environment PATH\n\nSau khi cài đặt, hãy khởi động lại ứng dụng", + "unsupported_platform": "Nền tảng không được hỗ trợ" +} \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 20fdb329..c9bf35df 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -286,5 +286,106 @@ "step_3_steps": "复制\"sp_dc\" Cookie的值", "step_4_steps": "粘贴复制的\"sp_dc\"值", "friends": "朋友", - "no_lyrics_available": "抱歉,无法找到此曲的歌词" + "no_lyrics_available": "抱歉,无法找到此曲的歌词", + "sort_duration": "按时长排序", + "start_a_radio": "开始收听电台", + "how_to_start_radio": "您想如何开始收听电台?", + "replace_queue_question": "您想要替换当前队列还是追加到队列?", + "endless_playback": "无尽播放", + "delete_playlist": "删除播放列表", + "delete_playlist_confirmation": "您确定要删除此播放列表吗?", + "local_tracks": "本地音轨", + "song_link": "歌曲链接", + "skip_this_nonsense": "跳过此无聊内容", + "freedom_of_music": "“音乐的自由”", + "freedom_of_music_palm": "“音乐的自由掌握在您手中”", + "get_started": "让我们开始吧", + "youtube_source_description": "推荐并且效果最佳。", + "piped_source_description": "感觉自由?与YouTube一样但更自由。", + "jiosaavn_source_description": "最适合南亚地区。", + "highest_quality": "最高音质:{quality}", + "select_audio_source": "选择音频源", + "endless_playback_description": "自动将新歌曲添加到队列的末尾", + "choose_your_region": "选择您的地区", + "choose_your_region_description": "这将帮助Spotube为您的位置显示正确的内容。", + "choose_your_language": "选择您的语言", + "help_project_grow": "帮助这个项目成长", + "help_project_grow_description": "Spotube是一个开源项目。您可以通过为项目做出贡献、报告错误或建议新功能来帮助该项目成长。", + "contribute_on_github": "在GitHub上做出贡献", + "donate_on_open_collective": "在Open Collective上捐款", + "browse_anonymously": "匿名浏览", + "enable_connect": "启用连接", + "enable_connect_description": "从其他设备控制Spotube", + "devices": "设备", + "select": "选择", + "connect_client_alert": "您正在被 {client} 控制", + "this_device": "此设备", + "remote": "远程", + "local_library": "本地图书馆", + "add_library_location": "添加到图书馆", + "remove_library_location": "从图书馆中删除", + "local_tab": "本地", + "stats": "统计", + "and_n_more": "和 {count} 更多", + "recently_played": "最近播放", + "browse_more": "浏览更多", + "no_title": "没有标题", + "not_playing": "未播放", + "epic_failure": "史诗级失败!", + "added_num_tracks_to_queue": "已将 {tracks_length} 首曲目添加到队列", + "spotube_has_an_update": "Spotube 有更新", + "download_now": "立即下载", + "nightly_version": "Spotube Nightly {nightlyBuildNum} 已发布", + "release_version": "Spotube v{version} 已发布", + "read_the_latest": "阅读最新", + "release_notes": "版本说明", + "pick_color_scheme": "选择配色方案", + "save": "保存", + "choose_the_device": "选择设备:", + "multiple_device_connected": "已连接多个设备。\n选择您希望执行此操作的设备", + "nothing_found": "未找到任何内容", + "the_box_is_empty": "箱子为空", + "top_artists": "热门艺术家", + "top_albums": "热门专辑", + "this_week": "本周", + "this_month": "本月", + "last_6_months": "过去6个月", + "this_year": "今年", + "last_2_years": "过去2年", + "all_time": "所有时间", + "powered_by_provider": "由 {providerName} 提供支持", + "email": "电子邮件", + "profile_followers": "关注者", + "birthday": "生日", + "subscription": "订阅", + "not_born": "尚未出生", + "hacker": "黑客", + "profile": "个人资料", + "no_name": "无名", + "edit": "编辑", + "user_profile": "用户资料", + "count_plays": "{count} 次播放", + "streaming_fees_hypothetical": "*基于 Spotify 每次播放的支付金额\n从 $0.003 到 $0.005 计算。这是一个假设性的\n计算,旨在让用户了解如果他们在 Spotify 上收听\n这些歌曲,可能会付给艺术家的金额。", + "count_mins": "{minutes} 分钟", + "summary_minutes": "分钟", + "summary_listened_to_music": "听音乐", + "summary_songs": "歌曲", + "summary_streamed_overall": "总体流媒体", + "summary_owed_to_artists": "本月欠艺术家的", + "summary_artists": "艺术家的", + "summary_music_reached_you": "音乐触及了你", + "summary_full_albums": "完整专辑", + "summary_got_your_love": "获得了你的爱", + "summary_playlists": "播放列表", + "summary_were_on_repeat": "已重复播放", + "total_money": "总计 {money}", + "minutes_listened": "听的分钟数", + "streamed_songs": "已流媒体歌曲", + "count_streams": "{count} 次流媒体", + "owned_by_you": "由您拥有", + "copied_shareurl_to_clipboard": "{shareUrl} 已复制到剪贴板", + "spotify_hipotetical_calculation": "*根据 Spotify 每次流媒体的支付金额\n$0.003 到 $0.005 进行计算。这是一个假设性的\n计算,用于给用户了解他们如果在 Spotify 上\n收听歌曲会支付给艺术家的金额。", + "webview_not_found": "未找到 Webview", + "webview_not_found_description": "您的设备中未安装 Webview 运行时。\n如果已安装,请确保它在 environment PATH 中\n\n安装后,重新启动应用程序", + "unsupported_platform": "不支持的平台" } \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 7aec682a..ebdc4b61 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -7,10 +7,15 @@ /// 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 +/// watchakorn-18k@github => Thai +/// Microsoft Copilot, Tutislav@github => Czech + +library l10n; + import 'package:flutter/material.dart'; class L10n { @@ -19,22 +24,28 @@ 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('uk', 'UA'), + const Locale('th', 'TH'), 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 31c1da57..f13991e2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,184 +1,139 @@ -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/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: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/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/provider/audio_player/audio_player_streams.dart'; +import 'package:spotube/provider/database/database.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/connect/clients.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/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/services/logger/logger.dart'; +import 'package:spotube/services/wm_tools/wm_tools.dart'; import 'package:spotube/themes/theme.dart'; -import 'package:spotube/utils/persisted_state_notifier.dart'; +import 'package:spotube/utils/migrations/hive.dart'; +import 'package:spotube/utils/migrations/sandbox.dart'; +import 'package:spotube/utils/platform.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: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'; 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(); + } - if (!kIsWeb) { - MetadataGod.initialize(); - } + if (kIsDesktop) { + await windowManager.setPreventClose(true); + } - if (DesktopTools.platform.isWindows || DesktopTools.platform.isLinux) { - DiscordRPC.initialize(); - } + await SystemTheme.accentColor.load(); - await KVStoreService.initialize(); - KVStoreService.doneGettingStarted = false; + if (!kIsWeb) { + MetadataGod.initialize(); + } - final hiveCacheDir = - kIsWeb ? null : (await getApplicationSupportDirectory()).path; + if (kIsDesktop) { + await FlutterDiscordRPC.initialize(Env.discordAppId); + } - await QueryClient.initialize( - cachePrefix: "oss.krtirtho.spotube", - cacheDir: hiveCacheDir, - connectivity: FlQueryInternetConnectionCheckerAdapter(), - ); + if (kIsWindows) { + await SMTCWindows.initialize(); + } - Hive.registerAdapter(SkipSegmentAdapter()); + await KVStoreService.initialize(); + await EncryptedKvStoreService.initialize(); - Hive.registerAdapter(SourceMatchAdapter()); - Hive.registerAdapter(SourceTypeAdapter()); + final hiveCacheDir = + kIsWeb ? null : (await getApplicationSupportDirectory()).path; - // Cache versioning entities with Adapter - SourceMatch.version = 'v1'; - SkipSegment.version = 'v1'; + Hive.init(hiveCacheDir); - await Hive.openLazyBox( - SourceMatch.boxName, - path: hiveCacheDir, - ); - await Hive.openLazyBox( - SkipSegment.boxName, - path: hiveCacheDir, - ); - await PersistedStateNotifier.initializeBoxes( - path: hiveCacheDir, - ); + final database = AppDatabase(); - await DesktopTools.ensureInitialized( - DesktopWindowOptions( - hideTitleBar: true, - title: "Spotube", - backgroundColor: Colors.transparent, - minimumSize: const Size(300, 700), - ), - ); + await migrateFromHiveToDrift(database); - 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(), - ), - ); - }, - ), - ); - }, - ); + if (kIsDesktop) { + await localNotifier.setup(appName: "Spotube"); + await WindowManagerTools.initialize(); + } + + 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 accentMaterialColor = @@ -189,15 +144,23 @@ class SpotubeState extends ConsumerState { final paletteColor = ref.watch(paletteProvider.select((s) => s?.dominantColor?.color)); final router = ref.watch(routerProvider); + final hasTouchSupport = useHasTouch(); + ref.listen(audioPlayerStreamListenersProvider, (_, __) {}); + ref.listen(bonsoirProvider, (_, __) {}); + ref.listen(connectClientsProvider, (_, __) {}); + ref.listen(serverProvider, (_, __) {}); + ref.listen(trayManagerProvider, (_, __) {}); + + useFixWindowStretching(); useDisableBatteryOptimizations(); - useInitSysTray(ref); useDeepLinking(ref); useCloseBehavior(ref); useGetStoragePermissions(ref); useEffect(() { FlutterNativeSplash.remove(); + return () { /// For enabling hot reload for audio player if (!kDebugMode) return; @@ -231,12 +194,22 @@ class SpotubeState extends ConsumerState { 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 (kIsDesktop && !kIsMacOS) child = DragToResizeArea(child: child); + + return child; }, themeMode: themeMode, theme: lightTheme, 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..088cfbd1 --- /dev/null +++ b/lib/models/connect/connect.freezed.dart @@ -0,0 +1,610 @@ +// 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; + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $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; + + @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); + + @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(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, + const DeepCollectionEquality().hash(_tracks), collection, initialIndex); + + @JsonKey(ignore: true) + @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; + @override + @JsonKey(ignore: true) + _$$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); + + @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(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, + const DeepCollectionEquality().hash(_tracks), collection, initialIndex); + + @JsonKey(ignore: true) + @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; + @override + @JsonKey(ignore: true) + _$$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..f297024b --- /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 int?, + $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 int?, + $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..412e6868 --- /dev/null +++ b/lib/models/database/database.dart @@ -0,0 +1,85 @@ +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:spotify/spotify.dart' hide Playlist; +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/material.dart' hide Table, Key, View; +import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; +import 'package:drift/native.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 => 1; +} + +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..1e585fa8 --- /dev/null +++ b/lib/models/database/database.g.dart @@ -0,0 +1,5841 @@ +// 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, + ); + @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, + ); + @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("Blue:0xFF2196F3")) + .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 _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 _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)); + @override + List get $columns => [ + id, + audioQuality, + albumColorSync, + amoledDarkTheme, + checkUpdate, + normalizeAudio, + showSystemTrayIcon, + systemTitleBar, + skipNonMusic, + closeBehavior, + accentColorScheme, + layoutMode, + locale, + market, + searchMode, + downloadLocation, + localLibraryLocation, + pipedInstance, + themeMode, + audioSource, + streamMusicCodec, + downloadMusicCodec, + discordPresence, + endlessPlayback, + enableConnect + ]; + @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)); + } + context.handle(_themeModeMeta, const VerificationResult.success()); + context.handle(_audioSourceMeta, 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)); + } + 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'])!, + 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'])!), + 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'])!, + ); + } + + @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 + $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 ThemeMode themeMode; + final AudioSource audioSource; + final SourceCodecs streamMusicCodec; + final SourceCodecs downloadMusicCodec; + final bool discordPresence; + final bool endlessPlayback; + final bool enableConnect; + 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.themeMode, + required this.audioSource, + required this.streamMusicCodec, + required this.downloadMusicCodec, + required this.discordPresence, + required this.endlessPlayback, + required this.enableConnect}); + @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['theme_mode'] = Variable( + $PreferencesTableTable.$converterthemeMode.toSql(themeMode)); + } + { + map['audio_source'] = Variable( + $PreferencesTableTable.$converteraudioSource.toSql(audioSource)); + } + { + 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); + 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), + themeMode: Value(themeMode), + audioSource: Value(audioSource), + streamMusicCodec: Value(streamMusicCodec), + downloadMusicCodec: Value(downloadMusicCodec), + discordPresence: Value(discordPresence), + endlessPlayback: Value(endlessPlayback), + enableConnect: Value(enableConnect), + ); + } + + 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']), + themeMode: $PreferencesTableTable.$converterthemeMode + .fromJson(serializer.fromJson(json['themeMode'])), + audioSource: $PreferencesTableTable.$converteraudioSource + .fromJson(serializer.fromJson(json['audioSource'])), + 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']), + ); + } + @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), + 'themeMode': serializer.toJson( + $PreferencesTableTable.$converterthemeMode.toJson(themeMode)), + 'audioSource': serializer.toJson( + $PreferencesTableTable.$converteraudioSource.toJson(audioSource)), + '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), + }; + } + + 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, + ThemeMode? themeMode, + AudioSource? audioSource, + SourceCodecs? streamMusicCodec, + SourceCodecs? downloadMusicCodec, + bool? discordPresence, + bool? endlessPlayback, + bool? enableConnect}) => + 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, + themeMode: themeMode ?? this.themeMode, + audioSource: audioSource ?? this.audioSource, + streamMusicCodec: streamMusicCodec ?? this.streamMusicCodec, + downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, + discordPresence: discordPresence ?? this.discordPresence, + endlessPlayback: endlessPlayback ?? this.endlessPlayback, + enableConnect: enableConnect ?? this.enableConnect, + ); + @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('themeMode: $themeMode, ') + ..write('audioSource: $audioSource, ') + ..write('streamMusicCodec: $streamMusicCodec, ') + ..write('downloadMusicCodec: $downloadMusicCodec, ') + ..write('discordPresence: $discordPresence, ') + ..write('endlessPlayback: $endlessPlayback, ') + ..write('enableConnect: $enableConnect') + ..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, + themeMode, + audioSource, + streamMusicCodec, + downloadMusicCodec, + discordPresence, + endlessPlayback, + enableConnect + ]); + @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.themeMode == this.themeMode && + other.audioSource == this.audioSource && + other.streamMusicCodec == this.streamMusicCodec && + other.downloadMusicCodec == this.downloadMusicCodec && + other.discordPresence == this.discordPresence && + other.endlessPlayback == this.endlessPlayback && + other.enableConnect == this.enableConnect); +} + +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 themeMode; + final Value audioSource; + final Value streamMusicCodec; + final Value downloadMusicCodec; + final Value discordPresence; + final Value endlessPlayback; + final Value enableConnect; + 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.themeMode = const Value.absent(), + this.audioSource = 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(), + }); + 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.themeMode = const Value.absent(), + this.audioSource = 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(), + }); + 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? themeMode, + Expression? audioSource, + Expression? streamMusicCodec, + Expression? downloadMusicCodec, + Expression? discordPresence, + Expression? endlessPlayback, + Expression? enableConnect, + }) { + 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 (themeMode != null) 'theme_mode': themeMode, + if (audioSource != null) 'audio_source': audioSource, + 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, + }); + } + + 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? themeMode, + Value? audioSource, + Value? streamMusicCodec, + Value? downloadMusicCodec, + Value? discordPresence, + Value? endlessPlayback, + Value? enableConnect}) { + 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, + themeMode: themeMode ?? this.themeMode, + audioSource: audioSource ?? this.audioSource, + streamMusicCodec: streamMusicCodec ?? this.streamMusicCodec, + downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, + discordPresence: discordPresence ?? this.discordPresence, + endlessPlayback: endlessPlayback ?? this.endlessPlayback, + enableConnect: enableConnect ?? this.enableConnect, + ); + } + + @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 (themeMode.present) { + map['theme_mode'] = Variable( + $PreferencesTableTable.$converterthemeMode.toSql(themeMode.value)); + } + if (audioSource.present) { + map['audio_source'] = Variable($PreferencesTableTable + .$converteraudioSource + .toSql(audioSource.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); + } + 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('themeMode: $themeMode, ') + ..write('audioSource: $audioSource, ') + ..write('streamMusicCodec: $streamMusicCodec, ') + ..write('downloadMusicCodec: $downloadMusicCodec, ') + ..write('discordPresence: $discordPresence, ') + ..write('endlessPlayback: $endlessPlayback, ') + ..write('enableConnect: $enableConnect') + ..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, + ); + @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, + ); + @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, + ); + @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, + ); + @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, + ); + @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, + ); + @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, + ); + @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, + ); + @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 $$AuthenticationTableTableInsertCompanionBuilder + = 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 $$AuthenticationTableTableTableManager extends RootTableManager< + _$AppDatabase, + $AuthenticationTableTable, + AuthenticationTableData, + $$AuthenticationTableTableFilterComposer, + $$AuthenticationTableTableOrderingComposer, + $$AuthenticationTableTableProcessedTableManager, + $$AuthenticationTableTableInsertCompanionBuilder, + $$AuthenticationTableTableUpdateCompanionBuilder> { + $$AuthenticationTableTableTableManager( + _$AppDatabase db, $AuthenticationTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: $$AuthenticationTableTableFilterComposer( + ComposerState(db, table)), + orderingComposer: $$AuthenticationTableTableOrderingComposer( + ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$AuthenticationTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + 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, + ), + getInsertCompanionBuilder: ({ + Value id = const Value.absent(), + required DecryptedText cookie, + required DecryptedText accessToken, + required DateTime expiration, + }) => + AuthenticationTableCompanion.insert( + id: id, + cookie: cookie, + accessToken: accessToken, + expiration: expiration, + ), + )); +} + +class $$AuthenticationTableTableProcessedTableManager + extends ProcessedTableManager< + _$AppDatabase, + $AuthenticationTableTable, + AuthenticationTableData, + $$AuthenticationTableTableFilterComposer, + $$AuthenticationTableTableOrderingComposer, + $$AuthenticationTableTableProcessedTableManager, + $$AuthenticationTableTableInsertCompanionBuilder, + $$AuthenticationTableTableUpdateCompanionBuilder> { + $$AuthenticationTableTableProcessedTableManager(super.$state); +} + +class $$AuthenticationTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $AuthenticationTableTable> { + $$AuthenticationTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get cookie => $state.composableBuilder( + column: $state.table.cookie, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get accessToken => $state.composableBuilder( + column: $state.table.accessToken, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get expiration => $state.composableBuilder( + column: $state.table.expiration, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $$AuthenticationTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $AuthenticationTableTable> { + $$AuthenticationTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get cookie => $state.composableBuilder( + column: $state.table.cookie, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get accessToken => $state.composableBuilder( + column: $state.table.accessToken, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get expiration => $state.composableBuilder( + column: $state.table.expiration, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +typedef $$BlacklistTableTableInsertCompanionBuilder = 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 $$BlacklistTableTableTableManager extends RootTableManager< + _$AppDatabase, + $BlacklistTableTable, + BlacklistTableData, + $$BlacklistTableTableFilterComposer, + $$BlacklistTableTableOrderingComposer, + $$BlacklistTableTableProcessedTableManager, + $$BlacklistTableTableInsertCompanionBuilder, + $$BlacklistTableTableUpdateCompanionBuilder> { + $$BlacklistTableTableTableManager( + _$AppDatabase db, $BlacklistTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$BlacklistTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$BlacklistTableTableOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$BlacklistTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + 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, + ), + getInsertCompanionBuilder: ({ + Value id = const Value.absent(), + required String name, + required BlacklistedType elementType, + required String elementId, + }) => + BlacklistTableCompanion.insert( + id: id, + name: name, + elementType: elementType, + elementId: elementId, + ), + )); +} + +class $$BlacklistTableTableProcessedTableManager extends ProcessedTableManager< + _$AppDatabase, + $BlacklistTableTable, + BlacklistTableData, + $$BlacklistTableTableFilterComposer, + $$BlacklistTableTableOrderingComposer, + $$BlacklistTableTableProcessedTableManager, + $$BlacklistTableTableInsertCompanionBuilder, + $$BlacklistTableTableUpdateCompanionBuilder> { + $$BlacklistTableTableProcessedTableManager(super.$state); +} + +class $$BlacklistTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $BlacklistTableTable> { + $$BlacklistTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get name => $state.composableBuilder( + column: $state.table.name, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get elementType => $state.composableBuilder( + column: $state.table.elementType, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get elementId => $state.composableBuilder( + column: $state.table.elementId, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $$BlacklistTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $BlacklistTableTable> { + $$BlacklistTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get name => $state.composableBuilder( + column: $state.table.name, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get elementType => $state.composableBuilder( + column: $state.table.elementType, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get elementId => $state.composableBuilder( + column: $state.table.elementId, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +typedef $$PreferencesTableTableInsertCompanionBuilder + = 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 themeMode, + Value audioSource, + Value streamMusicCodec, + Value downloadMusicCodec, + Value discordPresence, + Value endlessPlayback, + Value enableConnect, +}); +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 themeMode, + Value audioSource, + Value streamMusicCodec, + Value downloadMusicCodec, + Value discordPresence, + Value endlessPlayback, + Value enableConnect, +}); + +class $$PreferencesTableTableTableManager extends RootTableManager< + _$AppDatabase, + $PreferencesTableTable, + PreferencesTableData, + $$PreferencesTableTableFilterComposer, + $$PreferencesTableTableOrderingComposer, + $$PreferencesTableTableProcessedTableManager, + $$PreferencesTableTableInsertCompanionBuilder, + $$PreferencesTableTableUpdateCompanionBuilder> { + $$PreferencesTableTableTableManager( + _$AppDatabase db, $PreferencesTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$PreferencesTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$PreferencesTableTableOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$PreferencesTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + 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 themeMode = const Value.absent(), + Value audioSource = 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(), + }) => + 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, + themeMode: themeMode, + audioSource: audioSource, + streamMusicCodec: streamMusicCodec, + downloadMusicCodec: downloadMusicCodec, + discordPresence: discordPresence, + endlessPlayback: endlessPlayback, + enableConnect: enableConnect, + ), + getInsertCompanionBuilder: ({ + 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 themeMode = const Value.absent(), + Value audioSource = 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(), + }) => + 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, + themeMode: themeMode, + audioSource: audioSource, + streamMusicCodec: streamMusicCodec, + downloadMusicCodec: downloadMusicCodec, + discordPresence: discordPresence, + endlessPlayback: endlessPlayback, + enableConnect: enableConnect, + ), + )); +} + +class $$PreferencesTableTableProcessedTableManager + extends ProcessedTableManager< + _$AppDatabase, + $PreferencesTableTable, + PreferencesTableData, + $$PreferencesTableTableFilterComposer, + $$PreferencesTableTableOrderingComposer, + $$PreferencesTableTableProcessedTableManager, + $$PreferencesTableTableInsertCompanionBuilder, + $$PreferencesTableTableUpdateCompanionBuilder> { + $$PreferencesTableTableProcessedTableManager(super.$state); +} + +class $$PreferencesTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $PreferencesTableTable> { + $$PreferencesTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get audioQuality => $state.composableBuilder( + column: $state.table.audioQuality, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get albumColorSync => $state.composableBuilder( + column: $state.table.albumColorSync, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get amoledDarkTheme => $state.composableBuilder( + column: $state.table.amoledDarkTheme, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get checkUpdate => $state.composableBuilder( + column: $state.table.checkUpdate, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get normalizeAudio => $state.composableBuilder( + column: $state.table.normalizeAudio, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get showSystemTrayIcon => $state.composableBuilder( + column: $state.table.showSystemTrayIcon, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get systemTitleBar => $state.composableBuilder( + column: $state.table.systemTitleBar, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get skipNonMusic => $state.composableBuilder( + column: $state.table.skipNonMusic, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get closeBehavior => $state.composableBuilder( + column: $state.table.closeBehavior, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get accentColorScheme => $state.composableBuilder( + column: $state.table.accentColorScheme, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get layoutMode => $state.composableBuilder( + column: $state.table.layoutMode, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters get locale => + $state.composableBuilder( + column: $state.table.locale, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters get market => + $state.composableBuilder( + column: $state.table.market, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get searchMode => $state.composableBuilder( + column: $state.table.searchMode, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get downloadLocation => $state.composableBuilder( + column: $state.table.downloadLocation, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters, List, String> + get localLibraryLocation => $state.composableBuilder( + column: $state.table.localLibraryLocation, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get pipedInstance => $state.composableBuilder( + column: $state.table.pipedInstance, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters get themeMode => + $state.composableBuilder( + column: $state.table.themeMode, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get audioSource => $state.composableBuilder( + column: $state.table.audioSource, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get streamMusicCodec => $state.composableBuilder( + column: $state.table.streamMusicCodec, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get downloadMusicCodec => $state.composableBuilder( + column: $state.table.downloadMusicCodec, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get discordPresence => $state.composableBuilder( + column: $state.table.discordPresence, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get endlessPlayback => $state.composableBuilder( + column: $state.table.endlessPlayback, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get enableConnect => $state.composableBuilder( + column: $state.table.enableConnect, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $$PreferencesTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $PreferencesTableTable> { + $$PreferencesTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get audioQuality => $state.composableBuilder( + column: $state.table.audioQuality, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get albumColorSync => $state.composableBuilder( + column: $state.table.albumColorSync, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get amoledDarkTheme => $state.composableBuilder( + column: $state.table.amoledDarkTheme, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get checkUpdate => $state.composableBuilder( + column: $state.table.checkUpdate, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get normalizeAudio => $state.composableBuilder( + column: $state.table.normalizeAudio, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get showSystemTrayIcon => $state.composableBuilder( + column: $state.table.showSystemTrayIcon, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get systemTitleBar => $state.composableBuilder( + column: $state.table.systemTitleBar, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get skipNonMusic => $state.composableBuilder( + column: $state.table.skipNonMusic, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get closeBehavior => $state.composableBuilder( + column: $state.table.closeBehavior, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get accentColorScheme => $state.composableBuilder( + column: $state.table.accentColorScheme, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get layoutMode => $state.composableBuilder( + column: $state.table.layoutMode, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get locale => $state.composableBuilder( + column: $state.table.locale, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get market => $state.composableBuilder( + column: $state.table.market, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get searchMode => $state.composableBuilder( + column: $state.table.searchMode, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get downloadLocation => $state.composableBuilder( + column: $state.table.downloadLocation, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get localLibraryLocation => $state.composableBuilder( + column: $state.table.localLibraryLocation, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get pipedInstance => $state.composableBuilder( + column: $state.table.pipedInstance, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get themeMode => $state.composableBuilder( + column: $state.table.themeMode, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get audioSource => $state.composableBuilder( + column: $state.table.audioSource, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get streamMusicCodec => $state.composableBuilder( + column: $state.table.streamMusicCodec, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get downloadMusicCodec => $state.composableBuilder( + column: $state.table.downloadMusicCodec, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get discordPresence => $state.composableBuilder( + column: $state.table.discordPresence, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get endlessPlayback => $state.composableBuilder( + column: $state.table.endlessPlayback, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get enableConnect => $state.composableBuilder( + column: $state.table.enableConnect, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +typedef $$ScrobblerTableTableInsertCompanionBuilder = 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 $$ScrobblerTableTableTableManager extends RootTableManager< + _$AppDatabase, + $ScrobblerTableTable, + ScrobblerTableData, + $$ScrobblerTableTableFilterComposer, + $$ScrobblerTableTableOrderingComposer, + $$ScrobblerTableTableProcessedTableManager, + $$ScrobblerTableTableInsertCompanionBuilder, + $$ScrobblerTableTableUpdateCompanionBuilder> { + $$ScrobblerTableTableTableManager( + _$AppDatabase db, $ScrobblerTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$ScrobblerTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$ScrobblerTableTableOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$ScrobblerTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + 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, + ), + getInsertCompanionBuilder: ({ + 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, + ), + )); +} + +class $$ScrobblerTableTableProcessedTableManager extends ProcessedTableManager< + _$AppDatabase, + $ScrobblerTableTable, + ScrobblerTableData, + $$ScrobblerTableTableFilterComposer, + $$ScrobblerTableTableOrderingComposer, + $$ScrobblerTableTableProcessedTableManager, + $$ScrobblerTableTableInsertCompanionBuilder, + $$ScrobblerTableTableUpdateCompanionBuilder> { + $$ScrobblerTableTableProcessedTableManager(super.$state); +} + +class $$ScrobblerTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $ScrobblerTableTable> { + $$ScrobblerTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get username => $state.composableBuilder( + column: $state.table.username, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get passwordHash => $state.composableBuilder( + column: $state.table.passwordHash, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); +} + +class $$ScrobblerTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $ScrobblerTableTable> { + $$ScrobblerTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get username => $state.composableBuilder( + column: $state.table.username, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get passwordHash => $state.composableBuilder( + column: $state.table.passwordHash, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +typedef $$SkipSegmentTableTableInsertCompanionBuilder + = 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 $$SkipSegmentTableTableTableManager extends RootTableManager< + _$AppDatabase, + $SkipSegmentTableTable, + SkipSegmentTableData, + $$SkipSegmentTableTableFilterComposer, + $$SkipSegmentTableTableOrderingComposer, + $$SkipSegmentTableTableProcessedTableManager, + $$SkipSegmentTableTableInsertCompanionBuilder, + $$SkipSegmentTableTableUpdateCompanionBuilder> { + $$SkipSegmentTableTableTableManager( + _$AppDatabase db, $SkipSegmentTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$SkipSegmentTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$SkipSegmentTableTableOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$SkipSegmentTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + 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, + ), + getInsertCompanionBuilder: ({ + 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, + ), + )); +} + +class $$SkipSegmentTableTableProcessedTableManager + extends ProcessedTableManager< + _$AppDatabase, + $SkipSegmentTableTable, + SkipSegmentTableData, + $$SkipSegmentTableTableFilterComposer, + $$SkipSegmentTableTableOrderingComposer, + $$SkipSegmentTableTableProcessedTableManager, + $$SkipSegmentTableTableInsertCompanionBuilder, + $$SkipSegmentTableTableUpdateCompanionBuilder> { + $$SkipSegmentTableTableProcessedTableManager(super.$state); +} + +class $$SkipSegmentTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $SkipSegmentTableTable> { + $$SkipSegmentTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get start => $state.composableBuilder( + column: $state.table.start, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get end => $state.composableBuilder( + column: $state.table.end, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get trackId => $state.composableBuilder( + column: $state.table.trackId, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $$SkipSegmentTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $SkipSegmentTableTable> { + $$SkipSegmentTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get start => $state.composableBuilder( + column: $state.table.start, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get end => $state.composableBuilder( + column: $state.table.end, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get trackId => $state.composableBuilder( + column: $state.table.trackId, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +typedef $$SourceMatchTableTableInsertCompanionBuilder + = 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 $$SourceMatchTableTableTableManager extends RootTableManager< + _$AppDatabase, + $SourceMatchTableTable, + SourceMatchTableData, + $$SourceMatchTableTableFilterComposer, + $$SourceMatchTableTableOrderingComposer, + $$SourceMatchTableTableProcessedTableManager, + $$SourceMatchTableTableInsertCompanionBuilder, + $$SourceMatchTableTableUpdateCompanionBuilder> { + $$SourceMatchTableTableTableManager( + _$AppDatabase db, $SourceMatchTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$SourceMatchTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$SourceMatchTableTableOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$SourceMatchTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + 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, + ), + getInsertCompanionBuilder: ({ + 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, + ), + )); +} + +class $$SourceMatchTableTableProcessedTableManager + extends ProcessedTableManager< + _$AppDatabase, + $SourceMatchTableTable, + SourceMatchTableData, + $$SourceMatchTableTableFilterComposer, + $$SourceMatchTableTableOrderingComposer, + $$SourceMatchTableTableProcessedTableManager, + $$SourceMatchTableTableInsertCompanionBuilder, + $$SourceMatchTableTableUpdateCompanionBuilder> { + $$SourceMatchTableTableProcessedTableManager(super.$state); +} + +class $$SourceMatchTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $SourceMatchTableTable> { + $$SourceMatchTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get trackId => $state.composableBuilder( + column: $state.table.trackId, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get sourceId => $state.composableBuilder( + column: $state.table.sourceId, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get sourceType => $state.composableBuilder( + column: $state.table.sourceType, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $$SourceMatchTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $SourceMatchTableTable> { + $$SourceMatchTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get trackId => $state.composableBuilder( + column: $state.table.trackId, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get sourceId => $state.composableBuilder( + column: $state.table.sourceId, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get sourceType => $state.composableBuilder( + column: $state.table.sourceType, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +typedef $$AudioPlayerStateTableTableInsertCompanionBuilder + = 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, +}); + +class $$AudioPlayerStateTableTableTableManager extends RootTableManager< + _$AppDatabase, + $AudioPlayerStateTableTable, + AudioPlayerStateTableData, + $$AudioPlayerStateTableTableFilterComposer, + $$AudioPlayerStateTableTableOrderingComposer, + $$AudioPlayerStateTableTableProcessedTableManager, + $$AudioPlayerStateTableTableInsertCompanionBuilder, + $$AudioPlayerStateTableTableUpdateCompanionBuilder> { + $$AudioPlayerStateTableTableTableManager( + _$AppDatabase db, $AudioPlayerStateTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: $$AudioPlayerStateTableTableFilterComposer( + ComposerState(db, table)), + orderingComposer: $$AudioPlayerStateTableTableOrderingComposer( + ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$AudioPlayerStateTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + 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, + ), + getInsertCompanionBuilder: ({ + 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, + ), + )); +} + +class $$AudioPlayerStateTableTableProcessedTableManager + extends ProcessedTableManager< + _$AppDatabase, + $AudioPlayerStateTableTable, + AudioPlayerStateTableData, + $$AudioPlayerStateTableTableFilterComposer, + $$AudioPlayerStateTableTableOrderingComposer, + $$AudioPlayerStateTableTableProcessedTableManager, + $$AudioPlayerStateTableTableInsertCompanionBuilder, + $$AudioPlayerStateTableTableUpdateCompanionBuilder> { + $$AudioPlayerStateTableTableProcessedTableManager(super.$state); +} + +class $$AudioPlayerStateTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $AudioPlayerStateTableTable> { + $$AudioPlayerStateTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get playing => $state.composableBuilder( + column: $state.table.playing, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get loopMode => $state.composableBuilder( + column: $state.table.loopMode, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get shuffled => $state.composableBuilder( + column: $state.table.shuffled, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters, List, String> + get collections => $state.composableBuilder( + column: $state.table.collections, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ComposableFilter playlistTableRefs( + ComposableFilter Function($$PlaylistTableTableFilterComposer f) f) { + final $$PlaylistTableTableFilterComposer composer = $state.composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $state.db.playlistTable, + getReferencedColumn: (t) => t.audioPlayerStateId, + builder: (joinBuilder, parentComposers) => + $$PlaylistTableTableFilterComposer(ComposerState($state.db, + $state.db.playlistTable, joinBuilder, parentComposers))); + return f(composer); + } +} + +class $$AudioPlayerStateTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $AudioPlayerStateTableTable> { + $$AudioPlayerStateTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get playing => $state.composableBuilder( + column: $state.table.playing, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get loopMode => $state.composableBuilder( + column: $state.table.loopMode, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get shuffled => $state.composableBuilder( + column: $state.table.shuffled, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get collections => $state.composableBuilder( + column: $state.table.collections, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +typedef $$PlaylistTableTableInsertCompanionBuilder = PlaylistTableCompanion + Function({ + Value id, + required int audioPlayerStateId, + required int index, +}); +typedef $$PlaylistTableTableUpdateCompanionBuilder = PlaylistTableCompanion + Function({ + Value id, + Value audioPlayerStateId, + Value index, +}); + +class $$PlaylistTableTableTableManager extends RootTableManager< + _$AppDatabase, + $PlaylistTableTable, + PlaylistTableData, + $$PlaylistTableTableFilterComposer, + $$PlaylistTableTableOrderingComposer, + $$PlaylistTableTableProcessedTableManager, + $$PlaylistTableTableInsertCompanionBuilder, + $$PlaylistTableTableUpdateCompanionBuilder> { + $$PlaylistTableTableTableManager(_$AppDatabase db, $PlaylistTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$PlaylistTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$PlaylistTableTableOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$PlaylistTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value id = const Value.absent(), + Value audioPlayerStateId = const Value.absent(), + Value index = const Value.absent(), + }) => + PlaylistTableCompanion( + id: id, + audioPlayerStateId: audioPlayerStateId, + index: index, + ), + getInsertCompanionBuilder: ({ + Value id = const Value.absent(), + required int audioPlayerStateId, + required int index, + }) => + PlaylistTableCompanion.insert( + id: id, + audioPlayerStateId: audioPlayerStateId, + index: index, + ), + )); +} + +class $$PlaylistTableTableProcessedTableManager extends ProcessedTableManager< + _$AppDatabase, + $PlaylistTableTable, + PlaylistTableData, + $$PlaylistTableTableFilterComposer, + $$PlaylistTableTableOrderingComposer, + $$PlaylistTableTableProcessedTableManager, + $$PlaylistTableTableInsertCompanionBuilder, + $$PlaylistTableTableUpdateCompanionBuilder> { + $$PlaylistTableTableProcessedTableManager(super.$state); +} + +class $$PlaylistTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $PlaylistTableTable> { + $$PlaylistTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get index => $state.composableBuilder( + column: $state.table.index, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + $$AudioPlayerStateTableTableFilterComposer get audioPlayerStateId { + final $$AudioPlayerStateTableTableFilterComposer composer = + $state.composerBuilder( + composer: this, + getCurrentColumn: (t) => t.audioPlayerStateId, + referencedTable: $state.db.audioPlayerStateTable, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, parentComposers) => + $$AudioPlayerStateTableTableFilterComposer(ComposerState( + $state.db, + $state.db.audioPlayerStateTable, + joinBuilder, + parentComposers))); + return composer; + } + + ComposableFilter playlistMediaTableRefs( + ComposableFilter Function($$PlaylistMediaTableTableFilterComposer f) f) { + final $$PlaylistMediaTableTableFilterComposer composer = $state + .composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $state.db.playlistMediaTable, + getReferencedColumn: (t) => t.playlistId, + builder: (joinBuilder, parentComposers) => + $$PlaylistMediaTableTableFilterComposer(ComposerState( + $state.db, + $state.db.playlistMediaTable, + joinBuilder, + parentComposers))); + return f(composer); + } +} + +class $$PlaylistTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $PlaylistTableTable> { + $$PlaylistTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get index => $state.composableBuilder( + column: $state.table.index, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + $$AudioPlayerStateTableTableOrderingComposer get audioPlayerStateId { + final $$AudioPlayerStateTableTableOrderingComposer composer = + $state.composerBuilder( + composer: this, + getCurrentColumn: (t) => t.audioPlayerStateId, + referencedTable: $state.db.audioPlayerStateTable, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, parentComposers) => + $$AudioPlayerStateTableTableOrderingComposer(ComposerState( + $state.db, + $state.db.audioPlayerStateTable, + joinBuilder, + parentComposers))); + return composer; + } +} + +typedef $$PlaylistMediaTableTableInsertCompanionBuilder + = 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, +}); + +class $$PlaylistMediaTableTableTableManager extends RootTableManager< + _$AppDatabase, + $PlaylistMediaTableTable, + PlaylistMediaTableData, + $$PlaylistMediaTableTableFilterComposer, + $$PlaylistMediaTableTableOrderingComposer, + $$PlaylistMediaTableTableProcessedTableManager, + $$PlaylistMediaTableTableInsertCompanionBuilder, + $$PlaylistMediaTableTableUpdateCompanionBuilder> { + $$PlaylistMediaTableTableTableManager( + _$AppDatabase db, $PlaylistMediaTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$PlaylistMediaTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: $$PlaylistMediaTableTableOrderingComposer( + ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$PlaylistMediaTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + 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, + ), + getInsertCompanionBuilder: ({ + 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, + ), + )); +} + +class $$PlaylistMediaTableTableProcessedTableManager + extends ProcessedTableManager< + _$AppDatabase, + $PlaylistMediaTableTable, + PlaylistMediaTableData, + $$PlaylistMediaTableTableFilterComposer, + $$PlaylistMediaTableTableOrderingComposer, + $$PlaylistMediaTableTableProcessedTableManager, + $$PlaylistMediaTableTableInsertCompanionBuilder, + $$PlaylistMediaTableTableUpdateCompanionBuilder> { + $$PlaylistMediaTableTableProcessedTableManager(super.$state); +} + +class $$PlaylistMediaTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $PlaylistMediaTableTable> { + $$PlaylistMediaTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get uri => $state.composableBuilder( + column: $state.table.uri, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters?, Map, + String> + get extras => $state.composableBuilder( + column: $state.table.extras, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters?, Map, + String> + get httpHeaders => $state.composableBuilder( + column: $state.table.httpHeaders, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + $$PlaylistTableTableFilterComposer get playlistId { + final $$PlaylistTableTableFilterComposer composer = $state.composerBuilder( + composer: this, + getCurrentColumn: (t) => t.playlistId, + referencedTable: $state.db.playlistTable, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, parentComposers) => + $$PlaylistTableTableFilterComposer(ComposerState($state.db, + $state.db.playlistTable, joinBuilder, parentComposers))); + return composer; + } +} + +class $$PlaylistMediaTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $PlaylistMediaTableTable> { + $$PlaylistMediaTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get uri => $state.composableBuilder( + column: $state.table.uri, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get extras => $state.composableBuilder( + column: $state.table.extras, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get httpHeaders => $state.composableBuilder( + column: $state.table.httpHeaders, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + $$PlaylistTableTableOrderingComposer get playlistId { + final $$PlaylistTableTableOrderingComposer composer = + $state.composerBuilder( + composer: this, + getCurrentColumn: (t) => t.playlistId, + referencedTable: $state.db.playlistTable, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, parentComposers) => + $$PlaylistTableTableOrderingComposer(ComposerState($state.db, + $state.db.playlistTable, joinBuilder, parentComposers))); + return composer; + } +} + +typedef $$HistoryTableTableInsertCompanionBuilder = 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 $$HistoryTableTableTableManager extends RootTableManager< + _$AppDatabase, + $HistoryTableTable, + HistoryTableData, + $$HistoryTableTableFilterComposer, + $$HistoryTableTableOrderingComposer, + $$HistoryTableTableProcessedTableManager, + $$HistoryTableTableInsertCompanionBuilder, + $$HistoryTableTableUpdateCompanionBuilder> { + $$HistoryTableTableTableManager(_$AppDatabase db, $HistoryTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$HistoryTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$HistoryTableTableOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$HistoryTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + 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, + ), + getInsertCompanionBuilder: ({ + 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, + ), + )); +} + +class $$HistoryTableTableProcessedTableManager extends ProcessedTableManager< + _$AppDatabase, + $HistoryTableTable, + HistoryTableData, + $$HistoryTableTableFilterComposer, + $$HistoryTableTableOrderingComposer, + $$HistoryTableTableProcessedTableManager, + $$HistoryTableTableInsertCompanionBuilder, + $$HistoryTableTableUpdateCompanionBuilder> { + $$HistoryTableTableProcessedTableManager(super.$state); +} + +class $$HistoryTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $HistoryTableTable> { + $$HistoryTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get type => $state.composableBuilder( + column: $state.table.type, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get itemId => $state.composableBuilder( + column: $state.table.itemId, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters, Map, + String> + get data => $state.composableBuilder( + column: $state.table.data, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); +} + +class $$HistoryTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $HistoryTableTable> { + $$HistoryTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get type => $state.composableBuilder( + column: $state.table.type, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get itemId => $state.composableBuilder( + column: $state.table.itemId, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get data => $state.composableBuilder( + column: $state.table.data, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +typedef $$LyricsTableTableInsertCompanionBuilder = LyricsTableCompanion + Function({ + Value id, + required String trackId, + required SubtitleSimple data, +}); +typedef $$LyricsTableTableUpdateCompanionBuilder = LyricsTableCompanion + Function({ + Value id, + Value trackId, + Value data, +}); + +class $$LyricsTableTableTableManager extends RootTableManager< + _$AppDatabase, + $LyricsTableTable, + LyricsTableData, + $$LyricsTableTableFilterComposer, + $$LyricsTableTableOrderingComposer, + $$LyricsTableTableProcessedTableManager, + $$LyricsTableTableInsertCompanionBuilder, + $$LyricsTableTableUpdateCompanionBuilder> { + $$LyricsTableTableTableManager(_$AppDatabase db, $LyricsTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$LyricsTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$LyricsTableTableOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$LyricsTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value id = const Value.absent(), + Value trackId = const Value.absent(), + Value data = const Value.absent(), + }) => + LyricsTableCompanion( + id: id, + trackId: trackId, + data: data, + ), + getInsertCompanionBuilder: ({ + Value id = const Value.absent(), + required String trackId, + required SubtitleSimple data, + }) => + LyricsTableCompanion.insert( + id: id, + trackId: trackId, + data: data, + ), + )); +} + +class $$LyricsTableTableProcessedTableManager extends ProcessedTableManager< + _$AppDatabase, + $LyricsTableTable, + LyricsTableData, + $$LyricsTableTableFilterComposer, + $$LyricsTableTableOrderingComposer, + $$LyricsTableTableProcessedTableManager, + $$LyricsTableTableInsertCompanionBuilder, + $$LyricsTableTableUpdateCompanionBuilder> { + $$LyricsTableTableProcessedTableManager(super.$state); +} + +class $$LyricsTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $LyricsTableTable> { + $$LyricsTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get trackId => $state.composableBuilder( + column: $state.table.trackId, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get data => $state.composableBuilder( + column: $state.table.data, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); +} + +class $$LyricsTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $LyricsTableTable> { + $$LyricsTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get trackId => $state.composableBuilder( + column: $state.table.trackId, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get data => $state.composableBuilder( + column: $state.table.data, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +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/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..ae4ec1e8 --- /dev/null +++ b/lib/models/database/tables/preferences.dart @@ -0,0 +1,125 @@ +part of '../database.dart'; + +enum LayoutMode { + compact, + extended, + adaptive, +} + +enum CloseBehavior { + minimizeToTray, + close, +} + +enum AudioSource { + youtube, + piped, + jiosaavn; + + String get label => name[0].toUpperCase() + name.substring(1); +} + +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("Blue:0xFF2196F3")) + .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 themeMode => + textEnum().withDefault(Constant(ThemeMode.system.name))(); + TextColumn get audioSource => + textEnum().withDefault(Constant(AudioSource.youtube.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))(); + + // 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.blue.value, name: "Blue"), + layoutMode: LayoutMode.adaptive, + locale: const Locale("system", "system"), + market: Market.US, + searchMode: SearchMode.youtube, + downloadLocation: "", + localLibraryLocation: [], + pipedInstance: "https://pipedapi.kavin.rocks", + themeMode: ThemeMode.system, + audioSource: AudioSource.youtube, + streamMusicCodec: SourceCodecs.weba, + downloadMusicCodec: SourceCodecs.m4a, + discordPresence: true, + endlessPlayback: true, + enableConnect: false, + ); + } +} 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..70c27374 --- /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.value; + } +} + +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/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..e5c2f666 --- /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 = "Spotify") + ..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..c2bb2aba --- /dev/null +++ b/lib/models/spotify/home_feed.freezed.dart @@ -0,0 +1,1666 @@ +// 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; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $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; + + @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); + + @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(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, description, format, + const DeepCollectionEquality().hash(_images), name, owner, uri); + + @JsonKey(ignore: true) + @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; + @override + @JsonKey(ignore: true) + _$$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; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $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; + + @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); + + @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(ignore: true) + @override + int get hashCode => Object.hash( + runtimeType, name, uri, const DeepCollectionEquality().hash(_images)); + + @JsonKey(ignore: true) + @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; + @override + @JsonKey(ignore: true) + _$$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; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $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; + + @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); + + @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(ignore: true) + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(_artists), + const DeepCollectionEquality().hash(_images), + name, + uri); + + @JsonKey(ignore: true) + @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; + @override + @JsonKey(ignore: true) + _$$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; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $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; + + @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); + + @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(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, name, uri); + + @JsonKey(ignore: true) + @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; + @override + @JsonKey(ignore: true) + _$$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; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $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; + + @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); + + @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(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, height, url, width); + + @JsonKey(ignore: true) + @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; + @override + @JsonKey(ignore: true) + _$$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; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $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; + + @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); + } + + @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); + }); + } + + @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); + }); + } + + @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); + + @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(ignore: true) + @override + int get hashCode => + Object.hash(runtimeType, typename, playlist, artist, album); + + @JsonKey(ignore: true) + @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; + @override + @JsonKey(ignore: true) + _$$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; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $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; + + @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); + + @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(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, typename, title, uri, + const DeepCollectionEquality().hash(_items)); + + @JsonKey(ignore: true) + @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; + @override + @JsonKey(ignore: true) + _$$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; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $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; + + @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); + + @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(ignore: true) + @override + int get hashCode => Object.hash( + runtimeType, greeting, const DeepCollectionEquality().hash(_sections)); + + @JsonKey(ignore: true) + @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; + @override + @JsonKey(ignore: true) + _$$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..adf4aab8 --- /dev/null +++ b/lib/models/spotify/recommendation_seeds.freezed.dart @@ -0,0 +1,756 @@ +// 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; + + @JsonKey(ignore: true) + $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; + + @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); + } + + @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); + }); + } + + @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); + }); + } + + @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); + + @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); + + @JsonKey(ignore: true) + @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; + @override + @JsonKey(ignore: true) + _$$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; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $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; + + @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); + + @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(ignore: true) + @override + int get hashCode => Object.hash( + runtimeType, + acousticness, + danceability, + durationMs, + energy, + instrumentalness, + key, + liveness, + loudness, + mode, + popularity, + speechiness, + tempo, + timeSignature, + valence); + + @JsonKey(ignore: true) + @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; + @override + @JsonKey(ignore: true) + _$$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/components/album/album_card.dart b/lib/modules/album/album_card.dart similarity index 53% rename from lib/components/album/album_card.dart rename to lib/modules/album/album_card.dart index c7ae2f9a..dd914fad 100644 --- a/lib/components/album/album_card.dart +++ b/lib/modules/album/album_card.dart @@ -1,17 +1,22 @@ -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/components/dialogs/select_device_dialog.dart'; +import 'package:spotube/components/playbutton_card.dart'; +import 'package:spotube/extensions/artist_simple.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/extensions/image.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/pages/album/album.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: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()); @@ -21,17 +26,17 @@ class AlbumCard extends HookConsumerWidget { final AlbumSimple album; const AlbumCard( this.album, { - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlist = ref.watch(audioPlayerProvider); final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - - final queryClient = useQueryClient(); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + final historyNotifier = ref.read(playbackHistoryActionsProvider); + final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); bool isPlaylistPlaying = useMemoized( () => playlist.containsCollection(album.id!), @@ -39,50 +44,37 @@ class AlbumCard extends HookConsumerWidget { ); 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(); + return album.tracks!.map((track) => track.asTrack(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(); - }, - ); + await ref.read(albumTracksProvider(album).future); + return ref.read(albumTracksProvider(album).notifier).fetchAll(); } return PlaybuttonCard( - imageUrl: TypeConversionUtils.image_X_UrlString( - album.images, + imageUrl: album.images.asUrlString( placeholder: ImagePlaceholder.collection, ), margin: const EdgeInsets.symmetric(horizontal: 10), isPlaying: isPlaylistPlaying, - isLoading: (isPlaylistPlaying && playlist.isFetching == true) || - updating.value, + isLoading: + (isPlaylistPlaying && isFetchingActiveTrack) || updating.value, title: album.name!, description: - "${album.albumType?.formatted} • ${TypeConversionUtils.artists_X_String(album.artists ?? [])}", + "${album.albumType?.formatted} • ${album.artists?.asString() ?? ""}", onTap: () { - ServiceUtils.push(context, "/album/${album.id}", extra: album); + ServiceUtils.pushNamed( + context, + AlbumPage.name, + pathParameters: { + "id": album.id!, + }, + extra: album, + ); }, onPlaybuttonPressed: () async { updating.value = true; @@ -93,10 +85,22 @@ class AlbumCard extends HookConsumerWidget { final fetchedTracks = await fetchAllTrack(); - if (fetchedTracks.isEmpty) return; + if (fetchedTracks.isEmpty || !context.mounted) return; - await playlistNotifier.load(fetchedTracks, autoPlay: true); - playlistNotifier.addCollection(album.id!); + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + 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; } @@ -113,6 +117,7 @@ class AlbumCard extends HookConsumerWidget { if (fetchedTracks.isEmpty) return; playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addCollection(album.id!); + historyNotifier.addAlbums([album]); if (context.mounted) { final snackbar = SnackBar( content: Text( diff --git a/lib/modules/artist/artist_album_list.dart b/lib/modules/artist/artist_album_list.dart new file mode 100644 index 00000000..a2dd8006 --- /dev/null +++ b/lib/modules/artist/artist_album_list.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart' hide Page; +import 'package:hooks_riverpod/hooks_riverpod.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.textTheme.headlineSmall, + ), + ); + } +} diff --git a/lib/components/artist/artist_card.dart b/lib/modules/artist/artist_card.dart similarity index 83% rename from lib/components/artist/artist_card.dart rename to lib/modules/artist/artist_card.dart index 3526e88f..add2608d 100644 --- a/lib/components/artist/artist_card.dart +++ b/lib/modules/artist/artist_card.dart @@ -4,31 +4,31 @@ 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/components/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; +import 'package:spotube/pages/artist/artist.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); + const ArtistCard(this.artist, {super.key}); @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); final backgroundImage = UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - artist.images, + artist.images.asUrlString( placeholder: ImagePlaceholder.artist, ), ); final isBlackListed = ref.watch( - BlackListNotifier.provider.select( - (blacklist) => blacklist.contains( - BlacklistedElement.artist(artist.id!, artist.name!), + blacklistProvider.select( + (blacklist) => blacklist.asData?.value.any( + (element) => element.elementId == artist.id, ), ), ); @@ -46,16 +46,16 @@ class ArtistCard extends HookConsumerWidget { width: size, margin: const EdgeInsets.symmetric(vertical: 5), child: Material( - shadowColor: theme.colorScheme.background, + shadowColor: theme.colorScheme.surface, color: Color.lerp( - theme.colorScheme.surfaceVariant, + theme.colorScheme.surfaceContainerHighest, theme.colorScheme.surface, useBrightnessValue(.9, .7), ), elevation: 3, shape: RoundedRectangleBorder( borderRadius: radius, - side: isBlackListed + side: isBlackListed == true ? const BorderSide( color: Colors.red, width: 2, @@ -64,7 +64,13 @@ class ArtistCard extends HookConsumerWidget { ), child: InkWell( onTap: () { - ServiceUtils.push(context, "/artist/${artist.id}"); + ServiceUtils.pushNamed( + context, + ArtistPage.name, + pathParameters: { + "id": artist.id!, + }, + ); }, borderRadius: radius, child: Padding( diff --git a/lib/modules/connect/connect_device.dart b/lib/modules/connect/connect_device.dart new file mode 100644 index 00000000..f4888534 --- /dev/null +++ b/lib/modules/connect/connect_device.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/connect/connect.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/utils/service_utils.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 ThemeData(:colorScheme) = Theme.of(context); + final pixelRatio = MediaQuery.of(context).devicePixelRatio; + final connectClients = ref.watch(connectClientsProvider); + + if (_sidebar) { + return SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () { + ServiceUtils.pushNamed(context, ConnectPage.name); + }, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(5), + ), + child: Row( + children: [ + Text(context.l10n.devices), + if (connectClients.asData?.value.services.isNotEmpty == true) + Text( + " (${connectClients.asData?.value.services.length})", + ), + const Spacer(), + const Icon(SpotubeIcons.speaker), + const Gap(5), + ], + ), + ), + ); + } + + return SizedBox( + height: 40 * pixelRatio, + child: Stack( + alignment: Alignment.centerRight, + fit: StackFit.loose, + children: [ + Material( + type: MaterialType.transparency, + child: Center( + child: ClipRect( + clipBehavior: Clip.hardEdge, + child: InkWell( + onTap: () { + ServiceUtils.pushNamed(context, ConnectPage.name); + }, + borderRadius: BorderRadius.circular(50), + child: Ink( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50), + color: colorScheme.primaryContainer, + ), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (connectClients.asData?.value.resolvedService != + null) ...[ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: Colors.greenAccent, + borderRadius: BorderRadius.circular(50), + ), + ), + const Gap(5), + ], + Text(context.l10n.devices), + if (connectClients.asData?.value.services.isNotEmpty == + true) + Text( + " (${connectClients.asData?.value.services.length})", + style: TextStyle( + color: colorScheme.onPrimaryContainer + .withOpacity(0.5), + ), + ), + const Gap(35), + ], + ), + ), + ), + ), + ), + ), + Positioned( + right: -3, + child: IconButton.filled( + icon: const Icon(SpotubeIcons.speaker), + style: IconButton.styleFrom( + visualDensity: VisualDensity.standard, + foregroundColor: colorScheme.onPrimary, + ), + onPressed: () { + ServiceUtils.pushNamed(context, ConnectPage.name); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/modules/connect/local_devices.dart b/lib/modules/connect/local_devices.dart new file mode 100644 index 00000000..dd7db971 --- /dev/null +++ b/lib/modules/connect/local_devices.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:spotube/collections/spotube_icons.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(:textTheme) = 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: textTheme.titleMedium, + ), + ), + ), + const SliverGap(10), + SliverList.separated( + itemCount: devices.length, + separatorBuilder: (context, index) => const Gap(10), + itemBuilder: (context, index) { + final device = devices[index]; + + return Card( + child: ListTile( + leading: const Icon(SpotubeIcons.speaker), + title: Text(device.description), + subtitle: Text(device.name), + selected: selectedDevice == device, + onTap: () => audioPlayer.setAudioDevice(device), + ), + ); + }, + ), + ], + ); + } +} diff --git a/lib/components/getting_started/blur_card.dart b/lib/modules/getting_started/blur_card.dart similarity index 100% rename from lib/components/getting_started/blur_card.dart rename to lib/modules/getting_started/blur_card.dart diff --git a/lib/modules/home/sections/featured.dart b/lib/modules/home/sections/featured.dart new file mode 100644 index 00000000..4f30c342 --- /dev/null +++ b/lib/modules/home/sections/featured.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart' hide Page; +import 'package:hooks_riverpod/hooks_riverpod.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); + + 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..8685fe19 --- /dev/null +++ b/lib/modules/home/sections/feed.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/home/feed/feed_section.dart'; +import 'package:spotube/provider/spotify/views/home.dart'; +import 'package:spotube/utils/service_utils.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: Directionality( + textDirection: TextDirection.rtl, + child: TextButton.icon( + label: Text(context.l10n.browse_more), + icon: const Icon(SpotubeIcons.angleRight), + onPressed: () => ServiceUtils.pushNamed( + context, + HomeFeedSectionPage.name, + pathParameters: { + "feedId": section.uri, + }, + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/components/home/sections/friends.dart b/lib/modules/home/sections/friends.dart similarity index 61% rename from lib/components/home/sections/friends.dart rename to lib/modules/home/sections/friends.dart index 6382f6fd..6f59c209 100644 --- a/lib/components/home/sections/friends.dart +++ b/lib/modules/home/sections/friends.dart @@ -1,22 +1,26 @@ -import 'dart:ffi'; import 'dart:ui'; 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:spotube/collections/fake.dart'; -import 'package:spotube/components/home/sections/friends/friend_item.dart'; +import 'package:spotube/modules/home/sections/friends/friend_item.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/models/spotify_friends.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HomePageFriendsSection extends HookConsumerWidget { - const HomePageFriendsSection({Key? key}) : super(key: key); + const HomePageFriendsSection({super.key}); @override Widget build(BuildContext context, ref) { - final friendsQuery = useQueries.user.friendActivity(ref); - final friends = friendsQuery.data?.friends ?? FakeData.friends.friends; + final auth = ref.watch(authenticationProvider); + final friendsQuery = ref.watch(friendsProvider); + final friends = + friendsQuery.asData?.value.friends ?? FakeData.friends.friends; final groupCount = useBreakpointValue( sm: 3, @@ -27,32 +31,36 @@ class HomePageFriendsSection extends HookConsumerWidget { xxl: 7, ); - final friendGroup = friends.fold>>( - [], - (previousValue, element) { - if (previousValue.isEmpty) { + final friendGroup = useMemoized( + () => 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] ]; - } - - final lastGroup = previousValue.last; - if (lastGroup.length < groupCount) { - return [ - ...previousValue.sublist(0, previousValue.length - 1), - [...lastGroup, element] - ]; - } - - return [ - ...previousValue, - [element] - ]; - }, + }, + ), + [friends, groupCount], ); - if (!friendsQuery.isLoading && - (!friendsQuery.hasData || friendsQuery.data!.friends.isEmpty)) { + if (friendsQuery.isLoading || + friendsQuery.asData?.value.friends.isEmpty == true || + auth.asData?.value == null) { return const SliverToBoxAdapter( child: SizedBox.shrink(), ); @@ -66,7 +74,7 @@ class HomePageFriendsSection extends HookConsumerWidget { child: Padding( padding: const EdgeInsets.all(8.0), child: Text( - 'Friends', + context.l10n.friends, style: Theme.of(context).textTheme.titleMedium, ), ), diff --git a/lib/components/home/sections/friends/friend_item.dart b/lib/modules/home/sections/friends/friend_item.dart similarity index 71% rename from lib/components/home/sections/friends/friend_item.dart rename to lib/modules/home/sections/friends/friend_item.dart index fcdadab7..773a4a8c 100644 --- a/lib/components/home/sections/friends/friend_item.dart +++ b/lib/modules/home/sections/friends/friend_item.dart @@ -1,21 +1,22 @@ -import 'package:fl_query_hooks/fl_query_hooks.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: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/pages/album/album.dart'; +import 'package:spotube/pages/artist/artist.dart'; +import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/spotify_provider.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) { @@ -24,13 +25,12 @@ class FriendItem extends HookConsumerWidget { colorScheme: colorScheme, ) = Theme.of(context); - final queryClient = useQueryClient(); final spotify = ref.watch(spotifyProvider); return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surfaceVariant.withOpacity(0.3), + color: colorScheme.surfaceContainerHighest.withOpacity(0.3), borderRadius: BorderRadius.circular(15), ), constraints: const BoxConstraints( @@ -60,7 +60,9 @@ class FriendItem extends HookConsumerWidget { text: friend.track.name, recognizer: TapGestureRecognizer() ..onTap = () { - context.push("/track/${friend.track.id}"); + context.pushNamed(TrackPage.name, pathParameters: { + "id": friend.track.id, + }); }, ), const TextSpan(text: " • "), @@ -74,8 +76,12 @@ class FriendItem extends HookConsumerWidget { text: " ${friend.track.artist.name}", recognizer: TapGestureRecognizer() ..onTap = () { - context.push( - "/artist/${friend.track.artist.id}", + context.pushNamed( + ArtistPage.name, + pathParameters: { + "id": friend.track.artist.id, + }, + extra: friend.track.artist, ); }, ), @@ -86,15 +92,11 @@ class FriendItem extends HookConsumerWidget { ..onTap = () async { context.push( "/${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), ); }, ), @@ -110,15 +112,13 @@ class FriendItem extends HookConsumerWidget { recognizer: TapGestureRecognizer() ..onTap = () async { final album = - await queryClient.fetchQuery( - "album/${friend.track.album.id}", - () => spotify.albums.get( - friend.track.album.id, - ), - ); + await spotify.albums.get(friend.track.album.id); if (context.mounted) { - context.push( - "/album/${friend.track.album.id}", + context.pushNamed( + AlbumPage.name, + pathParameters: { + "id": friend.track.album.id, + }, extra: album, ); } diff --git a/lib/components/home/sections/genres.dart b/lib/modules/home/sections/genres.dart similarity index 81% rename from lib/components/home/sections/genres.dart rename to lib/modules/home/sections/genres.dart index 41ba235c..5f2dfa5e 100644 --- a/lib/components/home/sections/genres.dart +++ b/lib/modules/home/sections/genres.dart @@ -10,31 +10,31 @@ 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/components/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'; +import 'package:spotube/pages/home/genres/genre_playlists.dart'; +import 'package:spotube/pages/home/genres/genres.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HomeGenresSection extends HookConsumerWidget { - const HomeGenresSection({Key? key}) : super(key: key); + const HomeGenresSection({super.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 = ref.watch(categoriesProvider); + final categories = useMemoized( + () => + categoriesQuery.asData?.value + .where((c) => (c.icons?.length ?? 0) > 0) + .take(mediaQuery.mdAndDown ? 6 : 10) + .toList() ?? + [], + [mediaQuery.mdAndDown, categoriesQuery.asData?.value], ); - 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: [ @@ -52,11 +52,11 @@ class HomeGenresSection extends HookConsumerWidget { textDirection: TextDirection.rtl, child: TextButton.icon( onPressed: () { - context.push('/genres'); + context.pushNamed(GenrePage.name); }, icon: const Icon(SpotubeIcons.angleRight), label: Text( - "Browse All", + context.l10n.browse_all, style: textTheme.bodyMedium?.copyWith( color: colorScheme.secondary, ), @@ -112,7 +112,13 @@ class HomeGenresSection extends HookConsumerWidget { return InkWell( onTap: () { - context.push('/genre/${category.id}', extra: category); + context.pushNamed( + GenrePlaylistsPage.name, + pathParameters: { + "categoryId": category.id!, + }, + extra: category, + ); }, borderRadius: BorderRadius.circular(8), child: Ink( @@ -128,7 +134,7 @@ class HomeGenresSection extends HookConsumerWidget { child: Ink( decoration: BoxDecoration( borderRadius: BorderRadius.circular(5), - color: colorScheme.surfaceVariant, + color: colorScheme.surfaceContainerHighest, gradient: categoriesQuery.isLoading ? null : gradient, ), padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/lib/components/home/sections/made_for_user.dart b/lib/modules/home/sections/made_for_user.dart similarity index 67% rename from lib/components/home/sections/made_for_user.dart rename to lib/modules/home/sections/made_for_user.dart index a3f96899..1b9854d3 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: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..e2b32741 --- /dev/null +++ b/lib/modules/home/sections/new_releases.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart' hide Page; +import 'package:hooks_riverpod/hooks_riverpod.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..43c0459d --- /dev/null +++ b/lib/modules/home/sections/recent.dart @@ -0,0 +1,40 @@ +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/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/local_folder_item.dart b/lib/modules/library/local_folder/local_folder_item.dart new file mode 100644 index 00000000..02e47a53 --- /dev/null +++ b/lib/modules/library/local_folder/local_folder_item.dart @@ -0,0 +1,199 @@ +import 'dart:math'; + +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:path/path.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/hooks/utils/use_brightness_value.dart'; +import 'package:spotube/pages/library/local_folder.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 lerpValue = useBrightnessValue(.9, .7); + + final downloadFolder = + ref.watch(userPreferencesProvider.select((s) => s.downloadLocation)); + + final isDownloadFolder = folder == downloadFolder; + + 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 InkWell( + onTap: () { + context.goNamed( + LocalLibraryPage.name, + queryParameters: { + if (isDownloadFolder) "downloads": "true", + }, + extra: folder, + ); + }, + borderRadius: BorderRadius.circular(8), + child: Ink( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Color.lerp( + colorScheme.surfaceContainerHighest, + colorScheme.surface, + lerpValue, + ), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (tracks.isEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Icon( + SpotubeIcons.folder, + size: mediaQuery.smAndDown + ? 95 + : mediaQuery.mdAndDown + ? 100 + : 142, + ), + ), + ) + else + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: max((tracks.length / 2).ceil(), 2), + ), + itemCount: tracks.length, + itemBuilder: (context, index) { + final track = tracks[index]; + return UniversalImage( + path: (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + fit: BoxFit.cover, + ); + }, + ), + ), + const Gap(8), + Stack( + children: [ + Center( + child: Text( + isDownloadFolder + ? context.l10n.downloads + : basename(folder), + style: const TextStyle(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + ), + if (!isDownloadFolder) + Align( + alignment: Alignment.topRight, + child: PopupMenuButton( + child: const Padding( + padding: EdgeInsets.all(3), + child: Icon(Icons.more_vert), + ), + itemBuilder: (context) { + return [ + PopupMenuItem( + child: ListTile( + leading: const Icon(SpotubeIcons.folderRemove), + iconColor: colorScheme.error, + title: + Text(context.l10n.remove_library_location), + onTap: () { + final libraryLocations = ref + .read(userPreferencesProvider) + .localLibraryLocation; + ref + .read(userPreferencesProvider.notifier) + .setLocalLibraryLocation( + libraryLocations + .where((e) => e != folder) + .toList(), + ); + }, + ), + ) + ]; + }, + ), + ), + ], + ), + const Spacer(), + Wrap( + spacing: 2, + runSpacing: 2, + children: [ + for (final MapEntry(key: index, value: segment) + in segments.asMap().entries) + Text.rich( + TextSpan( + children: [ + if (index != 0) + TextSpan( + text: "/ ", + style: TextStyle(color: colorScheme.primary), + ), + TextSpan(text: segment), + ], + ), + style: TextStyle( + fontSize: 10, + color: colorScheme.tertiary, + ), + ), + ], + ), + 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..7118d57d 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( @@ -71,7 +71,7 @@ class MultiSelectField extends HookWidget { : theme.colorScheme.onSurface.withOpacity(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/components/library/playlist_generate/recommendation_attribute_dials.dart b/lib/modules/library/playlist_generate/recommendation_attribute_dials.dart similarity index 99% rename from lib/components/library/playlist_generate/recommendation_attribute_dials.dart rename to lib/modules/library/playlist_generate/recommendation_attribute_dials.dart index 87f7cb1b..d7f51ffb 100644 --- a/lib/components/library/playlist_generate/recommendation_attribute_dials.dart +++ b/lib/modules/library/playlist_generate/recommendation_attribute_dials.dart @@ -20,12 +20,12 @@ class RecommendationAttributeDials extends HookWidget { final double base; const RecommendationAttributeDials({ - Key? key, + super.key, required this.values, required this.onChanged, required this.title, this.base = 1, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/library/playlist_generate/recommendation_attribute_fields.dart b/lib/modules/library/playlist_generate/recommendation_attribute_fields.dart similarity index 97% rename from lib/components/library/playlist_generate/recommendation_attribute_fields.dart rename to lib/modules/library/playlist_generate/recommendation_attribute_fields.dart index de169147..7feff03a 100644 --- a/lib/components/library/playlist_generate/recommendation_attribute_fields.dart +++ b/lib/modules/library/playlist_generate/recommendation_attribute_fields.dart @@ -1,6 +1,6 @@ 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/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'; @@ -12,12 +12,12 @@ class RecommendationAttributeFields extends HookWidget { final Map? presets; const RecommendationAttributeFields({ - Key? key, + super.key, required this.values, required this.onChanged, required this.title, this.presets, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart b/lib/modules/library/playlist_generate/seeds_multi_autocomplete.dart similarity index 99% rename from lib/components/library/playlist_generate/seeds_multi_autocomplete.dart rename to lib/modules/library/playlist_generate/seeds_multi_autocomplete.dart index b1665d32..73c58deb 100644 --- a/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart +++ b/lib/modules/library/playlist_generate/seeds_multi_autocomplete.dart @@ -26,7 +26,7 @@ class SeedsMultiAutocomplete extends HookWidget { final SelectedItemDisplayType selectedItemDisplayType; const SeedsMultiAutocomplete({ - Key? key, + super.key, required this.seeds, required this.fetchSeeds, required this.autocompleteOptionBuilder, @@ -35,7 +35,7 @@ class SeedsMultiAutocomplete extends HookWidget { this.inputDecoration, this.enabled = true, this.selectedItemDisplayType = SelectedItemDisplayType.wrap, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/library/playlist_generate/simple_track_tile.dart b/lib/modules/library/playlist_generate/simple_track_tile.dart similarity index 80% rename from lib/components/library/playlist_generate/simple_track_tile.dart rename to lib/modules/library/playlist_generate/simple_track_tile.dart index 86800d06..e6cc281f 100644 --- a/lib/components/library/playlist_generate/simple_track_tile.dart +++ b/lib/modules/library/playlist_generate/simple_track_tile.dart @@ -3,17 +3,17 @@ import 'package:flutter_hooks/flutter_hooks.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/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) { @@ -21,8 +21,7 @@ class SimpleTrackTile extends HookWidget { 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, diff --git a/lib/modules/library/user_albums.dart b/lib/modules/library/user_albums.dart new file mode 100644 index 00000000..c2c91293 --- /dev/null +++ b/lib/modules/library/user_albums.dart @@ -0,0 +1,118 @@ +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:gap/gap.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/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/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'; + +class UserAlbums extends HookConsumerWidget { + const UserAlbums({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( + child: Scaffold( + body: RefreshIndicator( + onRefresh: () async { + ref.invalidate(favoriteAlbumsProvider); + }, + child: InterScrollbar( + controller: controller, + child: CustomScrollView( + controller: controller, + slivers: [ + SliverAppBar( + floating: true, + flexibleSpace: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: SearchBar( + onChanged: (value) => searchText.value = value, + leading: const Icon(SpotubeIcons.filter), + hintText: context.l10n.filter_albums, + ), + ), + ), + const SliverGap(10), + Skeletonizer.sliver( + enabled: albumsQuery.isLoading, + child: SliverLayoutBuilder(builder: (context, constrains) { + return SliverGrid.builder( + itemCount: albums.isEmpty ? 6 : albums.length + 1, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: constrains.smAndDown ? 225 : 250, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) { + if (albums.isNotEmpty && index == albums.length) { + if (albumsQuery.asData?.value.hasMore != true) { + return const SizedBox.shrink(); + } + + return Waypoint( + controller: controller, + isGrid: true, + onTouchEdge: albumsQueryNotifier.fetchMore, + child: Skeletonizer( + enabled: true, + child: AlbumCard(FakeData.albumSimple), + ), + ); + } + + return AlbumCard( + albums.elementAtOrNull(index) ?? FakeData.albumSimple, + ); + }, + ); + }), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/modules/library/user_artists.dart b/lib/modules/library/user_artists.dart new file mode 100644 index 00000000..dd097080 --- /dev/null +++ b/lib/modules/library/user_artists.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:collection/collection.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: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'; + +class UserArtists extends HookConsumerWidget { + const UserArtists({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( + child: Scaffold( + body: RefreshIndicator( + onRefresh: () async { + ref.invalidate(followedArtistsProvider); + }, + child: InterScrollbar( + controller: controller, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: CustomScrollView( + controller: controller, + slivers: [ + SliverAppBar( + floating: true, + flexibleSpace: SearchBar( + onChanged: (value) => searchText.value = value, + leading: const Icon(SpotubeIcons.filter), + hintText: context.l10n.filter_artist, + ), + ), + const SliverGap(10), + Skeletonizer.sliver( + enabled: artistQuery.isLoading, + child: SliverLayoutBuilder(builder: (context, constrains) { + return SliverGrid.builder( + itemCount: filteredArtists.isEmpty + ? 6 + : 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 ArtistCard( + filteredArtists.elementAtOrNull(index) ?? + FakeData.artist, + ); + }, + ); + }), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/components/library/user_downloads.dart b/lib/modules/library/user_downloads.dart similarity index 90% rename from lib/components/library/user_downloads.dart rename to lib/modules/library/user_downloads.dart index c8ceee66..7fe9800c 100644 --- a/lib/components/library/user_downloads.dart +++ b/lib/modules/library/user_downloads.dart @@ -2,12 +2,12 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.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'; class UserDownloads extends HookConsumerWidget { - const UserDownloads({Key? key}) : super(key: key); + const UserDownloads({super.key}); @override Widget build(BuildContext context, ref) { @@ -31,7 +31,7 @@ class UserDownloads extends HookConsumerWidget { context.l10n .currently_downloading(downloadManager.$downloadCount), maxLines: 1, - style: Theme.of(context).textTheme.headlineMedium, + style: Theme.of(context).textTheme.titleMedium, ), ), const SizedBox(width: 10), diff --git a/lib/components/library/user_downloads/download_item.dart b/lib/modules/library/user_downloads/download_item.dart similarity index 89% rename from lib/components/library/user_downloads/download_item.dart rename to lib/modules/library/user_downloads/download_item.dart index 10dec410..c4bd7bce 100644 --- a/lib/components/library/user_downloads/download_item.dart +++ b/lib/modules/library/user_downloads/download_item.dart @@ -3,19 +3,22 @@ import 'package:flutter_hooks/flutter_hooks.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/components/links/artist_link.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/track/track.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'; +import 'package:spotube/utils/service_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) { @@ -51,17 +54,23 @@ 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: () => ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": track.id!, + }, + ), ), trailing: isQueryingSourceInfo ? Text( diff --git a/lib/modules/library/user_local_tracks.dart b/lib/modules/library/user_local_tracks.dart new file mode 100644 index 00000000..926b4e80 --- /dev/null +++ b/lib/modules/library/user_local_tracks.dart @@ -0,0 +1,101 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:file_selector/file_selector.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: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, +} + +class UserLocalTracks extends HookConsumerWidget { + const UserLocalTracks({super.key}); + + @override + Widget build(BuildContext context, ref) { + 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); + + return LayoutBuilder(builder: (context, constrains) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Column( + children: [ + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + icon: const Icon(SpotubeIcons.folderAdd), + label: Text(context.l10n.add_library_location), + onPressed: addLocalLibraryLocation, + ), + ), + const Gap(8), + Expanded( + child: GridView.builder( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: constrains.isXs + ? 210 + : constrains.mdAndDown + ? 280 + : 250, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemCount: preferences.localLibraryLocation.length + 1, + itemBuilder: (context, index) { + return LocalFolderItem( + folder: index == 0 + ? preferences.downloadLocation + : preferences.localLibraryLocation[index - 1], + ); + }, + ), + ), + ], + ), + ); + }); + } +} diff --git a/lib/components/library/user_playlists.dart b/lib/modules/library/user_playlists.dart similarity index 56% rename from lib/components/library/user_playlists.dart rename to lib/modules/library/user_playlists.dart index 32e91ed6..577f9655 100644 --- a/lib/components/library/user_playlists.dart +++ b/lib/modules/library/user_playlists.dart @@ -2,39 +2,38 @@ 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: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/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/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/components/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'; +import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:spotube/utils/service_utils.dart'; class UserPlaylists extends HookConsumerWidget { - const UserPlaylists({Key? key}) : super(key: key); + const UserPlaylists({super.key}); @override Widget build(BuildContext context, ref) { final searchText = useState(''); - final auth = ref.watch(AuthenticationNotifier.provider); + final auth = ref.watch(authenticationProvider); - final playlistsQuery = useQueries.playlist.ofMine(ref); - - final pagePlaylists = useMemoized( - () => playlistsQuery.pages - .expand((page) => page.items?.toList() ?? []), - [playlistsQuery.pages], - ); + final playlistsQuery = ref.watch(favoritePlaylistsProvider); + final playlistsQueryNotifier = + ref.watch(favoritePlaylistsProvider.notifier); final likedTracksPlaylist = useMemoized( () => PlaylistSimple() @@ -58,12 +57,12 @@ class UserPlaylists extends HookConsumerWidget { if (searchText.value.isEmpty) { return [ likedTracksPlaylist, - ...pagePlaylists, + ...?playlistsQuery.asData?.value.items, ]; } return [ likedTracksPlaylist, - ...pagePlaylists, + ...?playlistsQuery.asData?.value.items, ] .map((e) => (weightedRatio(e.name!, searchText.value), e)) .sorted((a, b) => b.$1.compareTo(a.$1)) @@ -71,56 +70,57 @@ class UserPlaylists extends HookConsumerWidget { .map((e) => e.$2) .toList(); }, - [pagePlaylists, searchText.value], + [playlistsQuery, searchText.value], ); final controller = useScrollController(); - if (auth == null) { + if (auth.asData?.value == null) { return const AnonymousFallback(); } return RefreshIndicator( - onRefresh: playlistsQuery.refresh, + onRefresh: () async { + ref.invalidate(favoritePlaylistsProvider); + }, 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), + SliverAppBar( + floating: true, + flexibleSpace: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: SearchBar( + onChanged: (value) => searchText.value = value, + hintText: context.l10n.filter_playlists, + leading: const Icon(SpotubeIcons.filter), + ), + ), + bottom: PreferredSize( + preferredSize: + Size.fromHeight(kIsDesktop ? 35 : kToolbarHeight), + child: Row( + children: [ + const Gap(10), + const PlaylistCreateDialogButton(), + const Gap(10), + ElevatedButton.icon( + icon: const Icon(SpotubeIcons.magic), + label: Text(context.l10n.generate_playlist), + onPressed: () { + ServiceUtils.pushNamed( + context, PlaylistGeneratorPage.name); + }, ), - ), - 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 Gap(10), + ], + ), ), ), - const SliverToBoxAdapter( - child: SizedBox(height: 10), - ), + const SliverGap(10), SliverLayoutBuilder(builder: (context, constrains) { return SliverGrid.builder( itemCount: playlists.isEmpty ? 6 : playlists.length + 1, @@ -132,14 +132,14 @@ class UserPlaylists extends HookConsumerWidget { ), itemBuilder: (context, index) { if (playlists.isNotEmpty && index == playlists.length) { - if (!playlistsQuery.hasNextPage) { + if (playlistsQuery.asData?.value.hasMore != true) { return const SizedBox.shrink(); } return Waypoint( controller: controller, isGrid: true, - onTouchEdge: playlistsQuery.fetchNext, + onTouchEdge: playlistsQueryNotifier.fetchMore, child: Skeletonizer( enabled: true, child: PlaylistCard(FakeData.playlistSimple), 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 98% rename from lib/components/lyrics/zoom_controls.dart rename to lib/modules/lyrics/zoom_controls.dart index f50ea71d..73beb4ae 100644 --- a/lib/components/lyrics/zoom_controls.dart +++ b/lib/modules/lyrics/zoom_controls.dart @@ -17,7 +17,7 @@ class ZoomControls extends HookWidget { final String unit; const ZoomControls({ - Key? key, + super.key, required this.value, required this.onChanged, this.min, @@ -27,7 +27,7 @@ class ZoomControls extends HookWidget { this.decreaseIcon = const Icon(SpotubeIcons.zoomOut), this.direction = Axis.horizontal, this.unit = "%", - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/player/player.dart b/lib/modules/player/player.dart similarity index 73% rename from lib/components/player/player.dart rename to lib/modules/player/player.dart index 01e38670..925afadc 100644 --- a/lib/components/player/player.dart +++ b/lib/modules/player/player.dart @@ -4,48 +4,55 @@ 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/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/player_queue.dart'; +import 'package:spotube/modules/player/volume_slider.dart'; +import 'package:spotube/components/animated_gradient.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/components/panels/sliding_up_panel.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/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/utils/type_conversion_utils.dart'; +import 'package:spotube/pages/track/track.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/service_utils.dart'; + import 'package:url_launcher/url_launcher_string.dart'; class PlayerView extends HookConsumerWidget { final PanelController panelController; final ScrollController scrollController; const PlayerView({ - Key? key, + super.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 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.of(context); useEffect(() { @@ -58,8 +65,7 @@ class PlayerView extends HookConsumerWidget { }, [mediaQuery.lgAndUp]); String albumArt = useMemoized( - () => TypeConversionUtils.image_X_UrlString( - currentTrack?.album?.images, + () => (currentTrack?.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, ), [currentTrack?.album?.images], @@ -95,10 +101,10 @@ class PlayerView extends HookConsumerWidget { final topPadding = MediaQueryData.fromView(View.of(context)).padding.top; - return WillPopScope( - onWillPop: () async { + return AppPopScope( + canPop: context.canPop(), + onPopInvoked: (didPop) async { await panelController.close(); - return false; }, child: IconTheme( data: theme.iconTheme.copyWith(color: bodyTextColor), @@ -138,26 +144,25 @@ class PlayerView extends HookConsumerWidget { onPressed: panelController.close, ), actions: [ - 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: currentTrack == null - ? null - : () { - final url = - "https://song.link/s/${currentTrack.id}"; + 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: const EdgeInsets.symmetric(horizontal: 10), + ), + onPressed: () { + final url = + "https://song.link/s/${currentTrack.id}"; - launchUrlString(url); - }, - ), + launchUrlString(url); + }, + ), IconButton( icon: const Icon(SpotubeIcons.info, size: 18), tooltip: context.l10n.details, @@ -228,7 +233,8 @@ class PlayerView extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ AutoSizeText( - currentTrack?.name ?? "Not playing", + currentTrack?.name ?? + context.l10n.not_playing, style: TextStyle( color: titleTextColor, fontSize: 22, @@ -239,19 +245,15 @@ class PlayerView extends HookConsumerWidget { ), if (isLocalTrack) Text( - TypeConversionUtils.artists_X_String< - Artist>( - currentTrack?.artists ?? [], - ), + currentTrack.artists?.asString() ?? "", style: theme.textTheme.bodyMedium!.copyWith( fontWeight: FontWeight.bold, color: bodyTextColor, ), ) else - TypeConversionUtils - .artists_X_ClickableArtists( - currentTrack?.artists ?? [], + ArtistLink( + artists: currentTrack?.artists ?? [], textStyle: theme.textTheme.bodyMedium!.copyWith( fontWeight: FontWeight.bold, @@ -261,6 +263,14 @@ class PlayerView extends HookConsumerWidget { panelController.close(); GoRouter.of(context).push(route); }, + onOverflowArtistClick: () => + ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": currentTrack!.id!, + }, + ), ), ], ), @@ -268,7 +278,7 @@ class PlayerView extends HookConsumerWidget { const SizedBox(height: 10), PlayerControls(palette: palette), const SizedBox(height: 25), - PlayerActions( + const PlayerActions( mainAxisAlignment: MainAxisAlignment.spaceEvenly, showQueue: false, ), @@ -307,16 +317,29 @@ class PlayerView extends HookConsumerWidget { .height * .7, ), - builder: (context) { - return const PlayerQueue( - floating: false); - }, + builder: (context) => Consumer( + builder: (context, ref, _) { + final playlist = ref.watch( + audioPlayerProvider, + ); + final playlistNotifier = ref + .read(audioPlayerProvider + .notifier); + return PlayerQueue + .fromAudioPlayerNotifier( + floating: false, + playlist: playlist, + notifier: playlistNotifier, + ); + }, + ), ); } : null), ), - if (auth != null) const SizedBox(width: 10), - if (auth != null) + if (auth.asData?.value != null) + const SizedBox(width: 10), + if (auth.asData?.value != null) Expanded( child: OutlinedButton.icon( label: Text(context.l10n.lyrics), @@ -368,11 +391,21 @@ class PlayerView extends HookConsumerWidget { enabledThumbRadius: 8, ), ), - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: VolumeSlider( - fullWidth: true, - ), + child: 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/components/player/player_actions.dart b/lib/modules/player/player_actions.dart similarity index 82% rename from lib/components/player/player_actions.dart rename to lib/modules/player/player_actions.dart index 7a248aa5..a47c992d 100644 --- a/lib/components/player/player_actions.dart +++ b/lib/modules/player/player_actions.dart @@ -3,40 +3,36 @@ 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/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/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/authentication/authentication.dart'; +import 'package:spotube/provider/audio_player/audio_player.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({ + + const PlayerActions({ this.mainAxisAlignment = MainAxisAlignment.center, this.floatingQueue = true, this.showQueue = true, this.extraActions, - Key? key, - }) : super(key: key); - final logger = getLogger(PlayerActions); + super.key, + }); @override Widget build(BuildContext context, ref) { - final mediaQuery = MediaQuery.of(context); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlist = ref.watch(audioPlayerProvider); final isLocalTrack = playlist.activeTrack is LocalTrack; ref.watch(downloadManagerProvider); final downloader = ref.watch(downloadManagerProvider.notifier); @@ -49,19 +45,17 @@ class PlayerActions extends HookConsumerWidget { ]); 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 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 && - TypeConversionUtils.artists_X_String( - element.artists ?? []) == - TypeConversionUtils.artists_X_String( - playlist.activeTrack?.artists ?? []), + element.artists?.asString() == + playlist.activeTrack?.artists?.asString(), ) == true; }, [localTracks, playlist.activeTrack]); @@ -134,7 +128,9 @@ class PlayerActions extends HookConsumerWidget { ? () => downloader.addToQueue(playlist.activeTrack!) : null, ), - if (playlist.activeTrack != null && !isLocalTrack && auth != null) + if (playlist.activeTrack != null && + !isLocalTrack && + auth.asData?.value != null) TrackHeartButton(track: playlist.activeTrack!), AdaptivePopSheetList( offset: Offset(0, -50 * (sleepTimerEntries.values.length + 2)), diff --git a/lib/components/player/player_controls.dart b/lib/modules/player/player_controls.dart similarity index 65% rename from lib/components/player/player_controls.dart rename to lib/modules/player/player_controls.dart index 1000af18..12288a3d 100644 --- a/lib/components/player/player_controls.dart +++ b/lib/modules/player/player_controls.dart @@ -2,29 +2,27 @@ 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:media_kit/media_kit.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/modules/player/use_progress.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/audio_player/loop_mode.dart'; class PlayerControls extends HookConsumerWidget { final PaletteGenerator? palette; final bool compact; - PlayerControls({ + const PlayerControls({ this.palette, this.compact = false, - Key? key, - }) : super(key: key); - - final logger = getLogger(PlayerControls); + super.key, + }); static FocusNode focusNode = FocusNode(); @@ -43,8 +41,7 @@ class PlayerControls extends HookConsumerWidget { SeekIntent: SeekAction(), }, []); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; @@ -132,7 +129,7 @@ class PlayerControls extends HookConsumerWidget { // than total duration. Keeping it resolved value: progress.value.toDouble(), secondaryTrackValue: bufferProgress, - onChanged: playlist.isFetching == true + onChanged: isFetchingActiveTrack ? null : (v) { progress.value = v; @@ -173,40 +170,39 @@ class PlayerControls extends HookConsumerWidget { 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); - } - }, - ); - }), + Consumer(builder: (context, ref, _) { + final shuffled = ref + .watch(audioPlayerProvider.select((s) => s.shuffled)); + return IconButton( + tooltip: shuffled + ? context.l10n.unshuffle_playlist + : context.l10n.shuffle_playlist, + icon: const Icon(SpotubeIcons.shuffle), + style: shuffled ? activeButtonStyle : buttonStyle, + onPressed: isFetchingActiveTrack + ? 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 + onPressed: isFetchingActiveTrack ? null - : playlistNotifier.previous, + : audioPlayer.skipToPrevious, ), IconButton( tooltip: playing ? context.l10n.pause_playback : context.l10n.resume_playback, - icon: playlist.isFetching == true + icon: isFetchingActiveTrack ? SizedBox( height: 20, width: 20, @@ -219,7 +215,7 @@ class PlayerControls extends HookConsumerWidget { playing ? SpotubeIcons.pause : SpotubeIcons.play, ), style: resumePauseStyle, - onPressed: playlist.isFetching == true + onPressed: isFetchingActiveTrack ? null : Actions.handler( context, @@ -230,49 +226,41 @@ class PlayerControls extends HookConsumerWidget { tooltip: context.l10n.next_track, icon: const Icon(SpotubeIcons.skipForward), style: buttonStyle, - onPressed: playlist.isFetching == true - ? null - : playlistNotifier.next, + onPressed: + isFetchingActiveTrack ? null : audioPlayer.skipToNext, ), - 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; - } + Consumer(builder: (context, ref, _) { + final loopMode = ref + .watch(audioPlayerProvider.select((s) => s.loopMode)); + + return IconButton( + tooltip: loopMode == PlaylistMode.single + ? context.l10n.loop_track + : loopMode == PlaylistMode.loop + ? context.l10n.repeat_playlist + : null, + icon: Icon( + loopMode == PlaylistMode.single + ? SpotubeIcons.repeatOne + : SpotubeIcons.repeat, + ), + style: loopMode == PlaylistMode.single || + loopMode == PlaylistMode.loop + ? activeButtonStyle + : buttonStyle, + 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/components/player/player_overlay.dart b/lib/modules/player/player_overlay.dart similarity index 86% rename from lib/components/player/player_overlay.dart rename to lib/modules/player/player_overlay.dart index 2d63811e..2322bcba 100644 --- a/lib/components/player/player_overlay.dart +++ b/lib/modules/player/player_overlay.dart @@ -4,14 +4,15 @@ 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/modules/player/player_track_details.dart'; +import 'package:spotube/modules/root/spotube_navigation_bar.dart'; +import 'package:spotube/components/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/modules/player/use_progress.dart'; +import 'package:spotube/modules/player/player.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 PlayerOverlay extends HookConsumerWidget { @@ -19,16 +20,15 @@ class PlayerOverlay extends HookConsumerWidget { const PlayerOverlay({ required this.albumArt, - Key? key, - }) : super(key: key); + super.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 isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); + final playlist = ref.watch(audioPlayerProvider); + final canShow = playlist.activeTrack != null; + final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; @@ -115,7 +115,7 @@ class PlayerOverlay extends HookConsumerWidget { width: double.infinity, color: Colors.transparent, child: PlayerTrackDetails( - albumArt: albumArt, + track: playlist.activeTrack, color: textColor, ), ), @@ -128,14 +128,14 @@ class PlayerOverlay extends HookConsumerWidget { SpotubeIcons.skipBack, color: textColor, ), - onPressed: playlist.isFetching + onPressed: isFetchingActiveTrack ? null - : playlistNotifier.previous, + : audioPlayer.skipToPrevious, ), Consumer( builder: (context, ref, _) { return IconButton( - icon: playlist.isFetching + icon: isFetchingActiveTrack ? const SizedBox( height: 20, width: 20, @@ -159,9 +159,9 @@ class PlayerOverlay extends HookConsumerWidget { SpotubeIcons.skipForward, color: textColor, ), - onPressed: playlist.isFetching + onPressed: isFetchingActiveTrack ? null - : playlistNotifier.next, + : audioPlayer.skipToNext, ), ], ), diff --git a/lib/modules/player/player_queue.dart b/lib/modules/player/player_queue.dart new file mode 100644 index 00000000..369b95d2 --- /dev/null +++ b/lib/modules/player/player_queue.dart @@ -0,0 +1,308 @@ +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:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/spotube_icons.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.of(context); + + 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 headlineColor = theme.textTheme.headlineSmall?.color; + + 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], + ); + + useEffect(() { + if (playlist.activeTrack == null) return null; + + controller.scrollToIndex( + playlist.playlist.index, + preferPosition: AutoScrollPosition.middle, + ); + return null; + }, []); + + if (tracks.isEmpty) { + return const NotFound(vertical: true); + } + + return LayoutBuilder( + builder: (context, constrains) { + 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.surfaceContainerHighest.withOpacity(0.5), + borderRadius: borderRadius, + ), + child: CallbackShortcuts( + bindings: { + LogicalKeySet(LogicalKeyboardKey.escape): () { + if (!isSearching.value) { + Navigator.of(context).pop(); + } + isSearching.value = false; + searchText.value = ''; + } + }, + child: InterScrollbar( + controller: controller, + child: CustomScrollView( + controller: controller, + slivers: [ + if (!floating) + SliverToBoxAdapter( + child: Center( + child: Container( + height: 5, + width: 100, + margin: const EdgeInsets.only(bottom: 5, top: 2), + decoration: BoxDecoration( + color: headlineColor, + borderRadius: BorderRadius.circular(20), + ), + ), + ), + ), + SliverAppBar( + floating: true, + pinned: false, + snap: false, + backgroundColor: Colors.transparent, + elevation: 0, + automaticallyImplyLeading: false, + title: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 10, + sigmaY: 10, + ), + child: SizedBox( + height: kToolbarHeight, + child: mediaQuery.mdAndUp || !isSearching.value + ? Align( + alignment: Alignment.centerLeft, + child: Text( + context.l10n + .tracks_in_queue(tracks.length), + style: TextStyle( + color: headlineColor, + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + ) + : null, + ), + ), + actions: [ + 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: () { + onStop(); + Navigator.of(context).pop(); + }, + ), + const SizedBox(width: 10), + ], + ], + ), + 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: Material( + color: Colors.transparent, + 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 SliverGap(100), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/components/player/player_track_details.dart b/lib/modules/player/player_track_details.dart similarity index 69% rename from lib/components/player/player_track_details.dart rename to lib/modules/player/player_track_details.dart index 66cb9ef5..8d3b99fa 100644 --- a/lib/components/player/player_track_details.dart +++ b/lib/modules/player/player_track_details.dart @@ -3,24 +3,26 @@ 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/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/extensions/image.dart'; +import 'package:spotube/pages/track/track.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.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 +36,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, ), ), @@ -55,9 +58,7 @@ class PlayerTrackDetails extends HookConsumerWidget { ), ), Text( - TypeConversionUtils.artists_X_String( - playback.activeTrack?.artists ?? [], - ), + playback.activeTrack?.artists?.asString() ?? "", overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall!.copyWith(color: color), ) @@ -76,11 +77,18 @@ class PlayerTrackDetails extends HookConsumerWidget { 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); }, + onOverflowArtistClick: () => ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": track!.id!, + }, + ), ) ], ), diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/modules/player/sibling_tracks_sheet.dart similarity index 83% rename from lib/components/player/sibling_tracks_sheet.dart rename to lib/modules/player/sibling_tracks_sheet.dart index 58b1ca8c..b58a5894 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/modules/player/sibling_tracks_sheet.dart @@ -4,19 +4,22 @@ 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/components/image/universal_image.dart'; +import 'package:spotube/components/inter_scrollbar/inter_scrollbar.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/utils/use_debounce.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.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/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'; @@ -24,7 +27,6 @@ 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)), @@ -45,29 +47,31 @@ final sourceInfoToIconMap = { class SiblingTracksSheet extends HookConsumerWidget { final bool floating; const SiblingTracksSheet({ - Key? key, + super.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 playlist = ref.watch(audioPlayerProvider); + final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); final preferences = ref.watch(userPreferencesProvider); 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( - playlist.activeTrack?.name ?? "", - artists: - playlist.activeTrack?.artists?.map((e) => e.name!).toList() ?? [], + activeTrack?.name ?? "", + artists: activeTrack?.artists?.map((e) => e.name!).toList() ?? [], onlyCleanArtist: true, ).trim(); final defaultSearchTerm = - "$title - ${TypeConversionUtils.artists_X_String(playlist.activeTrack?.artists ?? [])}"; + "$title - ${activeTrack?.artists?.asString() ?? ""}"; final searchController = useTextEditingController( text: defaultSearchTerm, ); @@ -91,8 +95,7 @@ class SiblingTracksSheet extends HookConsumerWidget { return siblingType.info; })); - final activeSourceInfo = - (playlist.activeTrack! as SourcedTrack).sourceInfo; + final activeSourceInfo = (activeTrack! as SourcedTrack).sourceInfo; return results ..removeWhere((element) => element.id == activeSourceInfo.id) @@ -112,8 +115,7 @@ class SiblingTracksSheet extends HookConsumerWidget { return siblingType.info; }), ); - final activeSourceInfo = - (playlist.activeTrack! as SourcedTrack).sourceInfo; + final activeSourceInfo = (activeTrack! as SourcedTrack).sourceInfo; return searchResults ..removeWhere((element) => element.id == activeSourceInfo.id) ..insert( @@ -124,18 +126,18 @@ class SiblingTracksSheet extends HookConsumerWidget { }, [ searchTerm, searchMode.value, - playlist.activeTrack, + activeTrack, preferences.audioSource, ]); final siblings = useMemoized( - () => playlist.isFetching == false + () => !isFetchingActiveTrack ? [ - (playlist.activeTrack as SourcedTrack).sourceInfo, - ...(playlist.activeTrack as SourcedTrack).siblings, + (activeTrack as SourcedTrack).sourceInfo, + ...activeTrack.siblings, ] : [], - [playlist.isFetching, playlist.activeTrack], + [activeTrack, isFetchingActiveTrack], ); final borderRadius = floating @@ -146,12 +148,11 @@ class SiblingTracksSheet extends HookConsumerWidget { ); useEffect(() { - if (playlist.activeTrack is SourcedTrack && - (playlist.activeTrack as SourcedTrack).siblings.isEmpty) { - playlistNotifier.populateSibling(); + if (activeTrack is SourcedTrack && activeTrack.siblings.isEmpty) { + activeTrackNotifier.populateSibling(); } return null; - }, [playlist.activeTrack]); + }, [activeTrack]); final itemBuilder = useCallback( (SourceInfo sourceInfo) { @@ -176,22 +177,20 @@ class SiblingTracksSheet extends HookConsumerWidget { Text(" • ${sourceInfo.artist}"), ], ), - enabled: playlist.isFetching != true, - selected: playlist.isFetching != true && - sourceInfo.id == - (playlist.activeTrack as SourcedTrack).sourceInfo.id, + enabled: !isFetchingActiveTrack, + selected: !isFetchingActiveTrack && + sourceInfo.id == (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); + if (!isFetchingActiveTrack && + sourceInfo.id != (activeTrack as SourcedTrack).sourceInfo.id) { + activeTrackNotifier.swapSibling(sourceInfo); Navigator.of(context).pop(); } }, ); }, - [playlist.isFetching, playlist.activeTrack, siblings], + [activeTrack, siblings], ); final mediaQuery = MediaQuery.of(context); @@ -212,7 +211,8 @@ class SiblingTracksSheet extends HookConsumerWidget { : mediaQuery.size.height * .6, decoration: BoxDecoration( borderRadius: borderRadius, - color: theme.colorScheme.surfaceVariant.withOpacity(.5), + color: + theme.colorScheme.surfaceContainerHighest.withOpacity(.5), ), child: Scaffold( backgroundColor: Colors.transparent, 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 58% rename from lib/components/player/volume_slider.dart rename to lib/modules/player/volume_slider.dart index 75445125..8483143b 100644 --- a/lib/components/player/volume_slider.dart +++ b/lib/modules/player/volume_slider.dart @@ -1,39 +1,46 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; - import 'package:hooks_riverpod/hooks_riverpod.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: SliderTheme( + data: const SliderThemeData( + showValueIndicator: ShowValueIndicator.always, + ), + child: Slider( + min: 0, + max: 1, + label: (value * 100).toStringAsFixed(0), + value: value, + onChanged: onChanged, + ), ), ); return Row( @@ -42,20 +49,20 @@ class VolumeSlider extends HookConsumerWidget { children: [ IconButton( 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..df683a80 --- /dev/null +++ b/lib/modules/playlist/playlist_card.dart @@ -0,0 +1,155 @@ +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/dialogs/select_device_dialog.dart'; +import 'package:spotube/components/playbutton_card.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/pages/playlist/playlist.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:spotube/utils/service_utils.dart'; + +class PlaylistCard extends HookConsumerWidget { + final PlaylistSimple playlist; + const PlaylistCard( + this.playlist, { + super.key, + }); + @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(); + } + + return PlaybuttonCard( + margin: const EdgeInsets.symmetric(horizontal: 10), + title: playlist.name!, + description: playlist.description, + imageUrl: playlist.images.asUrlString( + placeholder: ImagePlaceholder.collection, + ), + isPlaying: isPlaylistPlaying, + isLoading: (isPlaylistPlaying && isFetchingActiveTrack) || updating.value, + isOwner: playlist.owner?.id == me.asData?.value.id && + me.asData?.value.id != null, + onTap: () { + ServiceUtils.pushNamed( + context, + PlaylistPage.name, + pathParameters: { + "id": playlist.id!, + }, + extra: playlist, + ); + }, + 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) { + 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; + } + } + }, + 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) { + final snackbar = SnackBar( + content: Text(context.l10n + .added_num_tracks_to_queue(fetchedInitialTracks.length)), + action: SnackBarAction( + label: "Undo", + onPressed: () { + playlistNotifier + .removeTracks(fetchedInitialTracks.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/modules/playlist/playlist_create_dialog.dart similarity index 86% rename from lib/components/playlist/playlist_create_dialog.dart rename to lib/modules/playlist/playlist_create_dialog.dart index 2e11a209..78680a1c 100644 --- a/lib/components/playlist/playlist_create_dialog.dart +++ b/lib/modules/playlist/playlist_create_dialog.dart @@ -5,29 +5,29 @@ 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:go_router/go_router.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/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/spotify/spotify.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, + super.key, this.trackIds = const [], this.playlistId, - }) : super(key: key); + }); final formKey = GlobalKey(); @@ -37,13 +37,16 @@ class PlaylistCreateDialog extends HookConsumerWidget { child: Scaffold( backgroundColor: Colors.transparent, body: HookBuilder(builder: (context) { - final userPlaylists = useQueries.playlist.ofMine(ref); + final userPlaylists = ref.watch(favoritePlaylistsProvider); + final playlist = ref.watch(playlistProvider(playlistId ?? "")); + final playlistNotifier = + ref.watch(playlistProvider(playlistId ?? "").notifier); + final updatingPlaylist = useMemoized( - () => userPlaylists.pages - .expand((p) => p.items ?? []) + () => userPlaylists.asData?.value.items .firstWhereOrNull((playlist) => playlist.id == playlistId), [ - userPlaylists.pages, + userPlaylists.asData?.value.items, playlistId, ], ); @@ -52,7 +55,7 @@ class PlaylistCreateDialog extends HookConsumerWidget { text: updatingPlaylist?.name, ); final description = useTextEditingController( - text: updatingPlaylist?.description, + text: updatingPlaylist?.description?.unescapeHtml(), ); final public = useState( updatingPlaylist?.public ?? false, @@ -73,7 +76,7 @@ class PlaylistCreateDialog extends HookConsumerWidget { scaffold.showSnackBar( SnackBar( content: Text( - l10n.error(error.message ?? "Epic failure!"), + l10n.error(error.message ?? context.l10n.epic_failure), style: theme.textTheme.bodyMedium!.copyWith( color: theme.colorScheme.onError, ), @@ -84,28 +87,10 @@ class PlaylistCreateDialog extends HookConsumerWidget { } }, [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 = ( + final PlaylistInput payload = ( playlistName: playlistName.text, collaborative: collaborative.value, public: public.value, @@ -118,9 +103,14 @@ class PlaylistCreateDialog extends HookConsumerWidget { ); if (isUpdatingPlaylist) { - await playlistUpdateMutation.mutate(payload); + await playlistNotifier.modify(payload, onError); } else { - await playlistCreateMutation.mutate(payload); + await playlistNotifier.create(payload, onError); + } + + if (context.mounted && + !ref.read(playlistProvider(playlistId ?? "")).hasError) { + context.pop(); } } @@ -138,7 +128,7 @@ class PlaylistCreateDialog extends HookConsumerWidget { }, ), FilledButton( - onPressed: onCreate, + onPressed: playlist.isLoading ? null : onCreate, child: Text( isUpdatingPlaylist ? context.l10n.update @@ -174,8 +164,7 @@ class PlaylistCreateDialog extends HookConsumerWidget { children: [ UniversalImage( path: field.value?.path ?? - TypeConversionUtils.image_X_UrlString( - updatingPlaylist?.images, + (updatingPlaylist?.images).asUrlString( placeholder: ImagePlaceholder.collection, ), height: 200, @@ -275,7 +264,7 @@ class PlaylistCreateDialog extends HookConsumerWidget { } class PlaylistCreateDialogButton extends HookConsumerWidget { - const PlaylistCreateDialogButton({Key? key}) : super(key: key); + const PlaylistCreateDialogButton({super.key}); showPlaylistDialog(BuildContext context, SpotifyApi spotify) { showDialog( diff --git a/lib/modules/root/bottom_player.dart b/lib/modules/root/bottom_player.dart new file mode 100644 index 00000000..a2f45449 --- /dev/null +++ b/lib/modules/root/bottom_player.dart @@ -0,0 +1,144 @@ +import 'dart:ui'; + +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/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/hooks/utils/use_brightness_value.dart'; +import 'package:flutter/material.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], + ); + + final theme = Theme.of(context); + final bg = theme.colorScheme.surfaceContainerHighest; + + 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(track: playlist.activeTrack), + ), + // controls + const Flexible( + flex: 3, + child: Padding( + padding: EdgeInsets.only(top: 5), + child: PlayerControls(), + ), + ), + // add to saved tracks + Column( + children: [ + PlayerActions( + extraActions: [ + IconButton( + tooltip: context.l10n.mini_player, + 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 { + GoRouter.of(context).go( + '/mini-player', + extra: 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/components/root/sidebar.dart b/lib/modules/root/sidebar.dart similarity index 57% rename from lib/components/root/sidebar.dart rename to lib/modules/root/sidebar.dart index a55ef947..f29644fb 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/modules/root/sidebar.dart @@ -1,5 +1,6 @@ import 'package:collection/collection.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:flutter/material.dart'; @@ -8,31 +9,33 @@ 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/models/database/database.dart'; +import 'package:spotube/modules/connect/connect_device.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/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart'; +import 'package:spotube/pages/profile/profile.dart'; +import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/provider/download_manager_provider.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/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'; +import 'package:spotube/utils/service_utils.dart'; +import 'package:window_manager/window_manager.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); + super.key, + }); static Widget brandLogo() { return Container( @@ -44,12 +47,9 @@ class Sidebar extends HookConsumerWidget { ); } - static void goToSettings(BuildContext context) { - GoRouter.of(context).go("/settings"); - } - @override Widget build(BuildContext context, WidgetRef ref) { + final routerState = GoRouterState.of(context); final mediaQuery = MediaQuery.of(context); final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; @@ -57,41 +57,27 @@ class Sidebar extends HookConsumerWidget { 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]); + final selectedIndex = sidebarTileList.indexWhere( + (e) => routerState.namedLocation(e.name) == routerState.matchedLocation, + ); - useEffect(() { - void listener() { - onSelectedIndexChanged(controller.selectedIndex); - } + final controller = useSidebarXController( + selectedIndex: selectedIndex, + extended: mediaQuery.lgAndUp, + ); - controller.addListener(listener); - return () { - controller.removeListener(listener); - }; - }, [controller]); + final theme = Theme.of(context); + final bg = theme.colorScheme.surfaceContainerHighest; + + final bgColor = useBrightnessValue( + Color.lerp(bg, Colors.white, 0.6), + Color.lerp(bg, Colors.black, 0.45)!, + ); useEffect(() { if (!context.mounted) return; @@ -103,6 +89,13 @@ class Sidebar extends HookConsumerWidget { return null; }, [mediaQuery, controller]); + useEffect(() { + if (controller.selectedIndex != selectedIndex) { + controller.selectIndex(selectedIndex); + } + return null; + }, [selectedIndex]); + if (layoutMode == LayoutMode.compact || (mediaQuery.smAndDown && layoutMode == LayoutMode.adaptive)) { return Scaffold(body: child); @@ -116,23 +109,28 @@ class Sidebar extends HookConsumerWidget { 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, + onTap: () { + context.goNamed(e.name); + }, + iconBuilder: (selected, hovered) { + return 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, - ), - ), + child: Icon( + e.icon, + color: selected || hovered + ? theme.colorScheme.primary + : null, + ), + ); + }, label: e.title, ); }, @@ -195,7 +193,7 @@ class Sidebar extends HookConsumerWidget { } class SidebarHeader extends HookWidget { - const SidebarHeader({Key? key}) : super(key: key); + const SidebarHeader({super.key}); @override Widget build(BuildContext context) { @@ -211,18 +209,106 @@ class SidebarHeader extends HookWidget { ); } - return Padding( - padding: const EdgeInsets.all(8.0), + return DragToMoveArea( + child: 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({ + super.key, + }); + + @override + Widget build(BuildContext context, ref) { + final theme = Theme.of(context); + final mediaQuery = MediaQuery.of(context); + final me = ref.watch(meProvider); + final data = me.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 IconButton( + icon: const Icon(SpotubeIcons.settings), + onPressed: () => ServiceUtils.navigateNamed(context, SettingsPage.name), + ); + } + + return Container( + padding: const EdgeInsets.only(left: 12), + width: 250, child: Column( children: [ - if (kIsMacOS) const SizedBox(height: 25), + const ConnectDeviceButton.sidebar(), + const Gap(10), Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Sidebar.brandLogo(), - const SizedBox(width: 10), - Text( - "Spotube", - style: theme.textTheme.titleLarge, + if (auth.asData?.value != null && data == null) + const CircularProgressIndicator() + else if (data != null) + Flexible( + child: InkWell( + onTap: () { + ServiceUtils.pushNamed(context, ProfilePage.name); + }, + borderRadius: BorderRadius.circular(30), + 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: () { + ServiceUtils.pushNamed(context, SettingsPage.name); + }, ), ], ), @@ -231,77 +317,3 @@ class SidebarHeader extends HookWidget { ); } } - -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/modules/root/spotube_navigation_bar.dart similarity index 74% rename from lib/components/root/spotube_navigation_bar.dart rename to lib/modules/root/spotube_navigation_bar.dart index 0853c60c..978891b8 100644 --- a/lib/components/root/spotube_navigation_bar.dart +++ b/lib/modules/root/spotube_navigation_bar.dart @@ -3,55 +3,55 @@ 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:go_router/go_router.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/models/database/database.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'; + +import 'package:spotube/utils/service_utils.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); + super.key, + }); @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); + final routerState = GoRouterState.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 navbarTileList = useMemoized( + () => getNavbarTileList(context.l10n), + [context.l10n], + ); final panelHeight = ref.watch(navigationPanelHeight); - useEffect(() { - if (selectedIndex != null) { - insideSelectedIndex.value = selectedIndex!; - } - return null; - }, [selectedIndex]); + final selectedIndex = useMemoized(() { + final index = navbarTileList.indexWhere( + (e) => routerState.namedLocation(e.name) == routerState.matchedLocation, + ); + + return index == -1 ? 0 : index; + }, [navbarTileList, routerState.matchedLocation]); if (layoutMode == LayoutMode.extended || (mediaQuery.mdAndUp && layoutMode == LayoutMode.adaptive) || @@ -69,7 +69,7 @@ class SpotubeNavigationBar extends HookConsumerWidget { backgroundColor: theme.colorScheme.secondaryContainer.withOpacity(0.72), buttonBackgroundColor: buttonColor, - color: theme.colorScheme.background, + color: theme.colorScheme.surface, height: panelHeight, animationDuration: const Duration(milliseconds: 350), items: navbarTileList.map( @@ -91,14 +91,9 @@ class SpotubeNavigationBar extends HookConsumerWidget { }); }, ).toList(), - index: insideSelectedIndex.value, + index: selectedIndex, onTap: (i) { - insideSelectedIndex.value = i; - if (navbarTileList[i].id == "settings") { - Sidebar.goToSettings(context); - return; - } - onSelectedIndexChanged(i); + ServiceUtils.navigateNamed(context, navbarTileList[i].name); }, ), ), diff --git a/lib/modules/root/update_dialog.dart b/lib/modules/root/update_dialog.dart new file mode 100644 index 00000000..27b857df --- /dev/null +++ b/lib/modules/root/update_dialog.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.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: [ + FilledButton( + 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/components/settings/color_scheme_picker_dialog.dart b/lib/modules/settings/color_scheme_picker_dialog.dart similarity index 92% rename from lib/components/settings/color_scheme_picker_dialog.dart rename to lib/modules/settings/color_scheme_picker_dialog.dart index e0c3d618..f2933505 100644 --- a/lib/components/settings/color_scheme_picker_dialog.dart +++ b/lib/modules/settings/color_scheme_picker_dialog.dart @@ -1,6 +1,7 @@ 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/user_preferences/user_preferences_provider.dart'; import 'package:system_theme/system_theme.dart'; @@ -8,9 +9,9 @@ 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(super.color, {required this.name}); - const SpotubeColor.from(int value, {required this.name}) : super(value); + const SpotubeColor.from(super.value, {required this.name}); factory SpotubeColor.fromString(String string) { final slices = string.split(":"); @@ -44,7 +45,7 @@ final Set colorsMap = { }; class ColorSchemePickerDialog extends HookConsumerWidget { - const ColorSchemePickerDialog({Key? key}) : super(key: key); + const ColorSchemePickerDialog({super.key}); @override Widget build(BuildContext context, ref) { @@ -69,17 +70,17 @@ class ColorSchemePickerDialog extends HookConsumerWidget { } return AlertDialog( - title: const Text("Pick color scheme"), + title: Text(context.l10n.pick_color_scheme), actions: [ OutlinedButton( - child: const Text("Cancel"), + child: Text(context.l10n.cancel), onPressed: () { Navigator.pop(context); }, ), FilledButton( onPressed: onOk, - child: const Text("Save"), + child: Text(context.l10n.save), ), ], content: SizedBox( @@ -119,8 +120,8 @@ class ColorTile extends StatelessWidget { this.onPressed, this.tooltip = "", this.isCompact = false, - Key? key, - }) : super(key: key); + super.key, + }); factory ColorTile.compact({ required Color color, @@ -179,9 +180,9 @@ class ColorTile extends StatelessWidget { colorScheme.primaryContainer, colorScheme.secondary, colorScheme.secondaryContainer, - colorScheme.background, colorScheme.surface, - colorScheme.surfaceVariant, + colorScheme.surface, + colorScheme.surfaceContainerHighest, colorScheme.onPrimary, colorScheme.onSurface, ]; diff --git a/lib/components/settings/section_card_with_heading.dart b/lib/modules/settings/section_card_with_heading.dart similarity index 100% rename from lib/components/settings/section_card_with_heading.dart rename to lib/modules/settings/section_card_with_heading.dart diff --git a/lib/modules/stats/common/album_item.dart b/lib/modules/stats/common/album_item.dart new file mode 100644 index 00000000..eec68717 --- /dev/null +++ b/lib/modules/stats/common/album_item.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.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'; +import 'package:spotube/pages/album/album.dart'; +import 'package:spotube/utils/service_utils.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 ListTile( + horizontalTitleGap: 8, + 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: () => ServiceUtils.pushNamed( + context, + AlbumPage.name, + pathParameters: { + "id": album.id!, + }, + ), + ), + ), + ], + ), + trailing: info, + onTap: () { + ServiceUtils.pushNamed( + context, + AlbumPage.name, + pathParameters: {"id": album.id!}, + extra: 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..7e7281da --- /dev/null +++ b/lib/modules/stats/common/artist_item.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/artist/artist.dart'; +import 'package:spotube/utils/service_utils.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 ListTile( + title: Text(artist.name!), + horizontalTitleGap: 8, + leading: CircleAvatar( + backgroundImage: UniversalImage.imageProvider( + (artist.images).asUrlString( + placeholder: ImagePlaceholder.artist, + ), + ), + ), + trailing: info, + onTap: () { + ServiceUtils.pushNamed( + context, + ArtistPage.name, + pathParameters: {"id": 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..515c97b3 --- /dev/null +++ b/lib/modules/stats/common/playlist_item.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/extensions/string.dart'; +import 'package:spotube/pages/playlist/playlist.dart'; +import 'package:spotube/utils/service_utils.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 ListTile( + horizontalTitleGap: 8, + 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, + onTap: () { + ServiceUtils.pushNamed( + context, + PlaylistPage.name, + pathParameters: {"id": playlist.id!}, + extra: 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..44e81340 --- /dev/null +++ b/lib/modules/stats/common/track_item.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/links/artist_link.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/track/track.dart'; +import 'package:spotube/utils/service_utils.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 ListTile( + horizontalTitleGap: 8, + 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: () => ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": track.id!, + }, + ), + ), + trailing: info, + onTap: () { + ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": track.id!, + }, + ); + }, + ); + } +} diff --git a/lib/modules/stats/summary/summary.dart b/lib/modules/stats/summary/summary.dart new file mode 100644 index 00000000..46068fec --- /dev/null +++ b/lib/modules/stats/summary/summary.dart @@ -0,0 +1,107 @@ +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/collections/formatters.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/pages/stats/albums/albums.dart'; +import 'package:spotube/pages/stats/artists/artists.dart'; +import 'package:spotube/pages/stats/fees/fees.dart'; +import 'package:spotube/pages/stats/minutes/minutes.dart'; +import 'package:spotube/pages/stats/playlists/playlists.dart'; +import 'package:spotube/pages/stats/streams/streams.dart'; +import 'package:spotube/provider/history/summary.dart'; +import 'package:spotube/utils/service_utils.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.purple, + onTap: () { + ServiceUtils.pushNamed(context, StatsMinutesPage.name); + }, + ), + SummaryCard( + title: summaryData.tracks.toDouble(), + unit: context.l10n.summary_songs, + description: context.l10n.summary_streamed_overall, + color: Colors.lightBlue, + onTap: () { + ServiceUtils.pushNamed(context, StatsStreamsPage.name); + }, + ), + SummaryCard.unformatted( + title: usdFormatter.format(summaryData.fees.toDouble()), + unit: "", + description: context.l10n.summary_owed_to_artists, + color: Colors.green, + onTap: () { + ServiceUtils.pushNamed(context, StatsStreamFeesPage.name); + }, + ), + SummaryCard( + title: summaryData.artists.toDouble(), + unit: context.l10n.summary_artists, + description: context.l10n.summary_music_reached_you, + color: Colors.yellow, + onTap: () { + ServiceUtils.pushNamed(context, StatsArtistsPage.name); + }, + ), + SummaryCard( + title: summaryData.albums.toDouble(), + unit: context.l10n.summary_full_albums, + description: context.l10n.summary_got_your_love, + color: Colors.pink, + onTap: () { + ServiceUtils.pushNamed(context, StatsAlbumsPage.name); + }, + ), + SummaryCard( + title: summaryData.playlists.toDouble(), + unit: context.l10n.summary_playlists, + description: context.l10n.summary_were_on_repeat, + color: Colors.teal, + onTap: () { + ServiceUtils.pushNamed(context, StatsPlaylistsPage.name); + }, + ), + ]), + ); + }), + ), + ); + } +} diff --git a/lib/modules/stats/summary/summary_card.dart b/lib/modules/stats/summary/summary_card.dart new file mode 100644 index 00000000..243c50e8 --- /dev/null +++ b/lib/modules/stats/summary/summary_card.dart @@ -0,0 +1,86 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:spotube/collections/formatters.dart'; + +class SummaryCard extends StatelessWidget { + final String title; + final String unit; + final String description; + final VoidCallback? onTap; + + final MaterialColor 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(:textTheme, :brightness) = Theme.of(context); + + final descriptionNewLines = description.split("").where((s) => s == "\n"); + + return Card( + color: brightness == Brightness.dark ? color.shade100 : color.shade50, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: 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: textTheme.headlineLarge?.copyWith( + color: color.shade900, + ), + ), + TextSpan( + text: " $unit", + style: textTheme.titleMedium?.copyWith( + color: color.shade900, + ), + ), + ], + ), + maxLines: 1, + ), + const Gap(5), + AutoSizeText( + description, + maxLines: description.contains("\n") + ? descriptionNewLines.length + 1 + : 1, + minFontSize: 9, + style: textTheme.labelMedium!.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..e401340e --- /dev/null +++ b/lib/modules/stats/top/albums.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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, + 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..3e4e098d --- /dev/null +++ b/lib/modules/stats/top/artists.dart @@ -0,0 +1,51 @@ +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: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]); + + 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, + 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..643064aa --- /dev/null +++ b/lib/modules/stats/top/top.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/themed_button_tab_bar.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 tabController = useTabController(initialLength: 3); + final historyDuration = ref.watch(playbackHistoryTopDurationProvider); + final historyDurationNotifier = + ref.watch(playbackHistoryTopDurationProvider.notifier); + + return SliverMainAxisGroup( + slivers: [ + SliverAppBar( + floating: true, + flexibleSpace: ThemedButtonsTabBar( + controller: tabController, + tabs: [ + Tab( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(context.l10n.top_tracks), + ), + ), + Tab( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(context.l10n.top_artists), + ), + ), + Tab( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(context.l10n.top_albums), + ), + ), + ], + ), + ), + SliverToBoxAdapter( + child: Align( + alignment: Alignment.centerRight, + child: DropdownButton( + style: Theme.of(context).textTheme.bodySmall!, + isDense: true, + padding: const EdgeInsets.all(4), + borderRadius: BorderRadius.circular(4), + underline: const SizedBox(), + value: historyDuration, + onChanged: (value) { + if (value == null) return; + historyDurationNotifier.update((_) => value); + }, + icon: const Icon(Icons.arrow_drop_down), + items: [ + DropdownMenuItem( + value: HistoryDuration.days7, + child: Text(context.l10n.this_week), + ), + DropdownMenuItem( + value: HistoryDuration.days30, + child: Text(context.l10n.this_month), + ), + DropdownMenuItem( + value: HistoryDuration.months6, + child: Text(context.l10n.last_6_months), + ), + DropdownMenuItem( + value: HistoryDuration.year, + child: Text(context.l10n.this_year), + ), + DropdownMenuItem( + value: HistoryDuration.years2, + child: Text(context.l10n.last_2_years), + ), + DropdownMenuItem( + value: HistoryDuration.allTime, + child: Text(context.l10n.all_time), + ), + ], + ), + ), + ), + ListenableBuilder( + listenable: tabController, + builder: (context, _) { + return switch (tabController.index) { + 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..7fba220d --- /dev/null +++ b/lib/modules/stats/top/tracks.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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, + 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 6cba99f6..0c6cfd69 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -1,18 +1,15 @@ -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/tracks_view/track_view.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/components/tracks_view/track_view.dart'; +import 'package:spotube/components/tracks_view/track_view_props.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'; class AlbumPage extends HookConsumerWidget { + static const name = "album"; + final AlbumSimple album; const AlbumPage({ super.key, @@ -21,60 +18,47 @@ class AlbumPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final spotify = ref.watch(spotifyProvider); - final tracksQuery = useQueries.album.tracksOf(ref, album); - - 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"); - }, - ); + 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!)); return InheritedTrackView( - collectionId: album.id!, - image: TypeConversionUtils.image_X_UrlString( - album.images, + collection: album, + image: album.images.asUrlString( placeholder: ImagePlaceholder.albumArt, - index: 0, ), 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(); - }); + tracks: tracks.asData?.value.items ?? [], + pagination: PaginationProps( + hasNextPage: tracks.asData?.value.hasMore ?? false, + 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!, - isLiked: isLiked, - onHeart: albumIsSaved.hasData - ? () async { - await toggleAlbumLike.mutate(isLiked); + shareUrl: album.externalUrls?.spotify ?? + "https://open.spotify.com/album/${album.id}", + isLiked: isSavedAlbum.asData?.value ?? false, + onHeart: isSavedAlbum.asData?.value == null + ? null + : () async { + if (isSavedAlbum.asData!.value) { + await favoriteAlbumsNotifier.removeFavorites([album.id!]); + } else { + await favoriteAlbumsNotifier.addFavorites([album.id!]); + } return null; - } - : null, + }, child: const TrackView(), ); } diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index d511cb97..70ad72de 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -4,27 +4,28 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.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'; 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(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, @@ -35,7 +36,7 @@ class ArtistPage extends HookConsumerWidget { ), extendBodyBehindAppBar: true, body: Builder(builder: (context) { - if (artistQuery.hasError && artistQuery.data == null) { + if (artistQuery.hasError && artistQuery.asData?.value == null) { return Center(child: Text(artistQuery.error.toString())); } return Skeletonizer( @@ -66,11 +67,12 @@ class ArtistPage extends HookConsumerWidget { SliverSafeArea( sliver: ArtistPageRelatedArtists(artistId: artistId), ), - if (artistQuery.data != null) + if (artistQuery.asData?.value != null) SliverSafeArea( top: false, sliver: SliverToBoxAdapter( - child: ArtistPageFooter(artist: artistQuery.data!), + child: + ArtistPageFooter(artist: artistQuery.asData!.value), ), ), ], diff --git a/lib/pages/artist/section/footer.dart b/lib/pages/artist/section/footer.dart index b01ef705..abe86410 100644 --- a/lib/pages/artist/section/footer.dart +++ b/lib/pages/artist/section/footer.dart @@ -3,27 +3,28 @@ import 'package:flutter/material.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 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), padding: mediaQuery.smAndDown @@ -38,9 +39,9 @@ class ArtistPageFooter extends HookConsumerWidget { 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, @@ -69,7 +70,7 @@ class ArtistPageFooter extends HookConsumerWidget { ), const TextSpan(text: '\n\n'), TextSpan( - text: summary.data!.extract, + text: summary.asData?.value!.extract, ), TextSpan( text: '\n...read more at wikipedia', @@ -81,7 +82,7 @@ class ArtistPageFooter extends HookConsumerWidget { 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..713e0d26 100644 --- a/lib/pages/artist/section/header.dart +++ b/lib/pages/artist/section/header.dart @@ -1,33 +1,29 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.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: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/extensions/image.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; -import 'package:spotube/provider/authentication_provider.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); @@ -43,15 +39,12 @@ class ArtistPageHeader extends HookConsumerWidget { xxl: textTheme.titleMedium, ); - 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 auth = ref.watch(authenticationProvider); + ref.watch(blacklistProvider); + final blacklistNotifier = ref.watch(blacklistProvider.notifier); + final isBlackListed = blacklistNotifier.containsArtist(artist); - final image = TypeConversionUtils.image_X_UrlString( - artist.images, + final image = artist.images.asUrlString( placeholder: ImagePlaceholder.artist, ); @@ -142,54 +135,42 @@ class ArtistPageHeader extends HookConsumerWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - if (auth != null) - HookBuilder( - builder: (context) { - final isFollowingQuery = - useQueries.artist.doIFollow(ref, artistId); + if (auth.asData?.value != null) + Consumer( + builder: (context, ref, _) { + final isFollowingQuery = ref + .watch(artistIsFollowingProvider(artist.id!)); + final followingArtistNotifier = + ref.watch(followedArtistsProvider.notifier); - final followUnfollow = useCallback(() async { - try { - isFollowingQuery.data! - ? await spotify.me.unfollow( - FollowingType.artist, - [artistId], - ) - : await spotify.me.follow( - FollowingType.artist, - [artistId], + return switch (isFollowingQuery) { + AsyncData(value: final following) => Builder( + builder: (context) { + if (following) { + return OutlinedButton( + onPressed: () async { + await followingArtistNotifier + .removeArtists([artist.id!]); + }, + child: Text(context.l10n.following), ); - 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), - ); + return FilledButton( + 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), @@ -206,16 +187,16 @@ class ArtistPageHeader extends HookConsumerWidget { ), onPressed: () async { if (isBlackListed) { - ref - .read(BlackListNotifier.provider.notifier) - .remove( - BlacklistedElement.artist( - artist.id!, artist.name!), - ); + await ref + .read(blacklistProvider.notifier) + .remove(artist.id!); } else { - ref.read(BlackListNotifier.provider.notifier).add( - BlacklistedElement.artist( - artist.id!, artist.name!), + await ref.read(blacklistProvider.notifier).add( + BlacklistTableCompanion.insert( + name: artist.name!, + elementId: artist.id!, + elementType: BlacklistedType.artist, + ), ); } }, diff --git a/lib/pages/artist/section/related_artists.dart b/lib/pages/artist/section/related_artists.dart index 2938c084..066f73fd 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: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..d52ed470 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -4,30 +4,29 @@ 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 +37,47 @@ 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) { + 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); + } } } @@ -111,6 +136,7 @@ class ArtistPageTopTracks extends HookConsumerWidget { 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..d3b0d0cb --- /dev/null +++ b/lib/pages/connect/connect.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.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/pages/connect/control/control.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class ConnectPage extends HookConsumerWidget { + static const name = "connect"; + + const ConnectPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:colorScheme, :textTheme) = Theme.of(context); + + final connectClients = ref.watch(connectClientsProvider); + final connectClientsNotifier = ref.read(connectClientsProvider.notifier); + final discoveredDevices = connectClients.asData?.value.services; + + return Scaffold( + appBar: PageWindowTitleBar( + automaticallyImplyLeading: true, + title: Text(context.l10n.devices), + titleSpacing: 0, + ), + body: ListTileTheme( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + selectedTileColor: colorScheme.secondary.withOpacity(0.1), + 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: textTheme.titleMedium, + ), + ), + ), + 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 Card( + child: ListTile( + leading: const Icon(SpotubeIcons.monitor), + title: Text(device.name), + subtitle: selected + ? Text( + "${connectClients.asData?.value.resolvedService?.host}" + ":${connectClients.asData?.value.resolvedService?.port}", + ) + : null, + selected: selected, + onTap: () { + if (selected) { + ServiceUtils.pushNamed( + context, + ConnectControlPage.name, + ); + } else { + connectClientsNotifier.resolveService(device); + } + }, + trailing: selected + ? IconButton( + icon: const Icon(SpotubeIcons.power), + onPressed: () => + connectClientsNotifier.clearResolvedService(), + ) + : null, + ), + ); + }, + ), + const ConnectPageLocalDevices(), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart new file mode 100644 index 00000000..cae0bd1b --- /dev/null +++ b/lib/pages/connect/control/control.dart @@ -0,0 +1,337 @@ +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:spotube/collections/spotube_icons.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/pages/track/track.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/provider/connect/connect.dart'; +import 'package:media_kit/media_kit.dart' hide Track; +import 'package:spotube/utils/service_utils.dart'; + +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), + ); + }, + ); + } +} + +class ConnectControlPage extends HookConsumerWidget { + static const name = "connect_control"; + + const ConnectControlPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:textTheme, :colorScheme) = Theme.of(context); + + final resolvedService = + ref.watch(connectClientsProvider).asData?.value.resolvedService; + 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); + + final resumePauseStyle = IconButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + padding: const EdgeInsets.all(12), + iconSize: 24, + ); + final buttonStyle = IconButton.styleFrom( + backgroundColor: colorScheme.surface.withOpacity(0.4), + minimumSize: const Size(28, 28), + ); + + final activeButtonStyle = IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer, + foregroundColor: colorScheme.onPrimaryContainer, + minimumSize: const Size(28, 28), + ); + + ref.listen(connectClientsProvider, (prev, next) { + if (next.asData?.value.resolvedService == null) { + context.pop(); + } + }); + + return SafeArea( + child: Scaffold( + appBar: PageWindowTitleBar( + title: Text(resolvedService!.name), + automaticallyImplyLeading: true, + ), + body: 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: 400, maxWidth: 400), + 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: textTheme.titleLarge!, + onTap: () { + if (playlist.activeTrack == null) return; + ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": playlist.activeTrack!.id!, + }, + ); + }, + ), + ), + SliverToBoxAdapter( + child: ArtistLink( + artists: playlist.activeTrack?.artists ?? [], + textStyle: textTheme.bodyMedium!, + mainAxisAlignment: WrapAlignment.start, + onOverflowArtistClick: () => + ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": playlist.activeTrack!.id!, + }, + ), + ), + ), + ], + ), + ), + const SliverGap(30), + SliverToBoxAdapter( + child: Consumer( + builder: (context, ref, _) { + final position = ref.watch(positionProvider); + final duration = ref.watch(durationProvider); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + children: [ + Slider( + value: position > duration + ? 0 + : position.inSeconds.toDouble(), + min: 0, + max: duration.inSeconds.toDouble(), + onChanged: (value) { + connectNotifier + .seek(Duration(seconds: value.toInt())); + }, + ), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text(position.toHumanReadableString()), + Text(duration.toHumanReadableString()), + ], + ), + ], + ), + ); + }, + ), + ), + SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + tooltip: shuffled + ? context.l10n.unshuffle_playlist + : context.l10n.shuffle_playlist, + icon: const Icon(SpotubeIcons.shuffle), + style: shuffled ? activeButtonStyle : buttonStyle, + onPressed: playlist.activeTrack == null + ? null + : () { + connectNotifier.setShuffle(!shuffled); + }, + ), + IconButton( + tooltip: context.l10n.previous_track, + icon: const Icon(SpotubeIcons.skipBack), + onPressed: playlist.activeTrack == null + ? null + : connectNotifier.previous, + ), + IconButton( + tooltip: playing + ? context.l10n.pause_playback + : context.l10n.resume_playback, + icon: playlist.activeTrack == null + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: colorScheme.onPrimary, + ), + ) + : Icon( + playing + ? SpotubeIcons.pause + : SpotubeIcons.play, + ), + style: resumePauseStyle, + onPressed: playlist.activeTrack == null + ? null + : () { + if (playing) { + connectNotifier.pause(); + } else { + connectNotifier.resume(); + } + }, + ), + IconButton( + tooltip: context.l10n.next_track, + icon: const Icon(SpotubeIcons.skipForward), + onPressed: playlist.activeTrack == null + ? null + : connectNotifier.next, + ), + IconButton( + tooltip: loopMode == PlaylistMode.single + ? context.l10n.loop_track + : loopMode == PlaylistMode.loop + ? context.l10n.repeat_playlist + : null, + icon: Icon( + loopMode == PlaylistMode.single + ? SpotubeIcons.repeatOne + : SpotubeIcons.repeat, + ), + style: loopMode == PlaylistMode.single || + loopMode == PlaylistMode.loop + ? activeButtonStyle + : buttonStyle, + 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), + 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 SliverGap(30), + if (constrains.mdAndDown) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20), + sliver: SliverToBoxAdapter( + child: OutlinedButton.icon( + icon: const Icon(SpotubeIcons.queue), + label: Text(context.l10n.queue), + onPressed: () { + showModalBottomSheet( + context: context, + builder: (context) { + return const RemotePlayerQueue(); + }, + ); + }, + ), + ), + ) + ], + ), + ), + 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 724fb346..0159a77f 100644 --- a/lib/pages/getting_started/getting_started.dart +++ b/lib/pages/getting_started/getting_started.dart @@ -2,19 +2,28 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.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'; class GettingStarting extends HookConsumerWidget { + static const name = "getting_started"; + const GettingStarting({super.key}); @override Widget build(BuildContext context, ref) { - final ThemeData(:colorScheme) = Theme.of(context); + final preferences = ref.watch(userPreferencesProvider); + final themeData = theme( + preferences.accentColorScheme, + Brightness.dark, + preferences.amoledDarkTheme, + ); final pageController = usePageController(); final onNext = useCallback(() { @@ -31,63 +40,66 @@ class GettingStarting extends HookConsumerWidget { ); }, [pageController]); - return 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: colorScheme.primary, + 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, + ), ), ), - ), - ); - }, - ), - ], - ), - extendBodyBehindAppBar: true, - body: DecoratedBox( - decoration: BoxDecoration( - image: DecorationImage( - image: Assets.bengaliPatternsBg.provider(), - fit: BoxFit.cover, - colorFilter: ColorFilter.mode( - colorScheme.background.withOpacity(0.2), - BlendMode.srcOver, + ); + }, ), - ), - ), - child: PageView( - controller: pageController, - children: [ - GettingStartedPageGreetingSection(onNext: onNext), - GettingStartedPageLanguageRegionSection(onNext: onNext), - GettingStartedPagePlaybackSection( - onNext: onNext, - onPrevious: onPrevious, - ), - const GettingStartedScreenSupportSection(), ], ), + 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(), + ], + ), + ), ), ); } diff --git a/lib/pages/getting_started/sections/greeting.dart b/lib/pages/getting_started/sections/greeting.dart index 563e43de..6d649351 100644 --- a/lib/pages/getting_started/sections/greeting.dart +++ b/lib/pages/getting_started/sections/greeting.dart @@ -3,7 +3,7 @@ import 'package:gap/gap.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/getting_started/blur_card.dart'; +import 'package:spotube/modules/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/utils/platform.dart'; diff --git a/lib/pages/getting_started/sections/playback.dart b/lib/pages/getting_started/sections/playback.dart index e94a06cc..e7087afd 100644 --- a/lib/pages/getting_started/sections/playback.dart +++ b/lib/pages/getting_started/sections/playback.dart @@ -4,10 +4,11 @@ import 'package:gap/gap.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/getting_started/blur_card.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( @@ -87,7 +88,7 @@ class GettingStartedPagePlaybackSection extends HookConsumerWidget { audioSourceToIconMap[source]!, const Gap(8), Text( - source.name, + source.name.capitalize(), style: textTheme.bodySmall!.copyWith( color: preferences.audioSource == source ? colorScheme.primary diff --git a/lib/pages/getting_started/sections/region.dart b/lib/pages/getting_started/sections/region.dart index 9303392c..9e31a273 100644 --- a/lib/pages/getting_started/sections/region.dart +++ b/lib/pages/getting_started/sections/region.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.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'; @@ -55,14 +55,14 @@ class GettingStartedPageLanguageRegionSection extends HookConsumerWidget { ), const Gap(16), DropdownMenu( - initialSelection: preferences.recommendationMarket, + initialSelection: preferences.market, onSelected: (value) { if (value == null) return; ref .read(userPreferencesProvider.notifier) .setRecommendationMarket(value); }, - hintText: preferences.recommendationMarket.name, + hintText: preferences.market.name, label: Text(context.l10n.market_place_region), inputDecorationTheme: const InputDecorationTheme(isDense: true), diff --git a/lib/pages/getting_started/sections/support.dart b/lib/pages/getting_started/sections/support.dart index 1be7ca34..f09a585d 100644 --- a/lib/pages/getting_started/sections/support.dart +++ b/lib/pages/getting_started/sections/support.dart @@ -2,9 +2,12 @@ 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:spotube/collections/env.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/home/home.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'; @@ -14,6 +17,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( @@ -59,21 +63,23 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { ); }, ), - 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, + if (!Env.hideDonations) ...[ + 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, + ), + onPressed: () async { + await launchUrlString( + "https://opencollective.com/spotube", + mode: LaunchMode.externalApplication, + ); + }, ), - onPressed: () async { - await launchUrlString( - "https://opencollective.com/spotube", - mode: LaunchMode.externalApplication, - ); - }, - ), + ] ], ), ], @@ -101,9 +107,11 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { style: TextButton.styleFrom( foregroundColor: Colors.white, ), - onPressed: () { - KVStoreService.doneGettingStarted = true; - context.go("/"); + onPressed: () async { + await KVStoreService.setDoneGettingStarted(true); + if (context.mounted) { + context.goNamed(HomePage.name); + } }, ), ), @@ -115,9 +123,9 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { backgroundColor: const Color(0xff1db954), foregroundColor: Colors.white, ), - onPressed: () { - KVStoreService.doneGettingStarted = true; - context.push("/login"); + onPressed: () async { + await KVStoreService.setDoneGettingStarted(true); + await onLogin(); }, ), ], diff --git a/lib/pages/home/feed/feed_section.dart b/lib/pages/home/feed/feed_section.dart new file mode 100644 index 00000000..bcfc0b81 --- /dev/null +++ b/lib/pages/home/feed/feed_section.dart @@ -0,0 +1,69 @@ +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/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/extensions/constrains.dart'; +import 'package:spotube/provider/spotify/views/home_section.dart'; + +class HomeFeedSectionPage extends HookConsumerWidget { + static const name = "home_feed_section"; + + final String sectionUri; + const HomeFeedSectionPage({super.key, required this.sectionUri}); + + @override + Widget build(BuildContext context, ref) { + final homeFeedSection = ref.watch(homeSectionViewProvider(sectionUri)); + final section = homeFeedSection.asData?.value ?? FakeData.feedSection; + + return Skeletonizer( + enabled: homeFeedSection.isLoading, + child: Scaffold( + appBar: PageWindowTitleBar( + title: Text(section.title ?? ""), + centerTitle: false, + automaticallyImplyLeading: true, + titleSpacing: 0, + ), + body: CustomScrollView( + slivers: [ + SliverLayoutBuilder( + builder: (context, constrains) { + return SliverGrid.builder( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: constrains.smAndDown ? 225 : 250, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: section.items.length, + itemBuilder: (context, index) { + final item = section.items[index]; + + if (item.album != null) { + return AlbumCard(item.album!.asAlbum); + } else if (item.artist != null) { + return ArtistCard(item.artist!.asArtist); + } else if (item.playlist != null) { + return PlaylistCard(item.playlist!.asPlaylist); + } + return const SizedBox(); + }, + ); + }, + ), + 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..58436bcf 100644 --- a/lib/pages/home/genres/genre_playlists.dart +++ b/lib/pages/home/genres/genre_playlists.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; @@ -7,42 +5,31 @@ 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/modules/playlist/playlist_card.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; +import 'package:spotube/components/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:collection/collection.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:spotube/utils/platform.dart'; class GenrePlaylistsPage extends HookConsumerWidget { + static const name = "genre_playlists"; + final Category category; const GenrePlaylistsPage({super.key, 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 + appBar: kIsDesktop ? const PageWindowTitleBar( leading: BackButton(color: Colors.white), backgroundColor: Colors.transparent, @@ -68,12 +55,12 @@ class GenrePlaylistsPage extends HookConsumerWidget { controller: scrollController, slivers: [ SliverAppBar( - automaticallyImplyLeading: DesktopTools.platform.isMobile, + automaticallyImplyLeading: kIsMobile, expandedHeight: mediaQuery.mdAndDown ? 200 : 150, title: const Text(""), backgroundColor: Colors.transparent, flexibleSpace: FlexibleSpaceBar( - centerTitle: DesktopTools.platform.isDesktop, + centerTitle: kIsDesktop, title: Text( category.name!, style: Theme.of(context).textTheme.headlineMedium?.copyWith( @@ -109,7 +96,7 @@ class GenrePlaylistsPage extends HookConsumerWidget { padding: EdgeInsets.symmetric( horizontal: mediaQuery.mdAndDown ? 12 : 24, ), - sliver: playlists.isEmpty + sliver: playlists.asData?.value.items.isNotEmpty != true ? Skeletonizer.sliver( child: SliverToBoxAdapter( child: Wrap( @@ -129,12 +116,14 @@ class GenrePlaylistsPage extends HookConsumerWidget { crossAxisSpacing: 12, mainAxisSpacing: 12, ), - itemCount: playlists.length + 1, + itemCount: + (playlists.asData?.value.items.length ?? 0) + 1, itemBuilder: (context, index) { - final playlist = playlists.elementAtOrNull(index); + final playlist = playlists.asData?.value.items + .elementAtOrNull(index); if (playlist == null) { - if (!playlistsQuery.hasNextPage) { + if (playlists.asData?.value.hasMore == false) { return const SizedBox.shrink(); } return Skeletonizer( @@ -142,11 +131,7 @@ class GenrePlaylistsPage extends HookConsumerWidget { child: Waypoint( controller: scrollController, isGrid: true, - onTouchEdge: () async { - if (playlistsQuery.hasNextPage) { - await playlistsQuery.fetchNext(); - } - }, + onTouchEdge: playlistsNotifier.fetchMore, child: PlaylistCard(FakeData.playlist), ), ); diff --git a/lib/pages/home/genres/genres.dart b/lib/pages/home/genres/genres.dart index 17a67beb..4846d633 100644 --- a/lib/pages/home/genres/genres.dart +++ b/lib/pages/home/genres/genres.dart @@ -5,29 +5,22 @@ 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/gradients.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.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'; +import 'package:spotube/pages/home/genres/genre_playlists.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; 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); @@ -35,6 +28,7 @@ class GenrePage extends HookConsumerWidget { appBar: PageWindowTitleBar( title: Text(context.l10n.explore_genres), automaticallyImplyLeading: true, + titleSpacing: 0, ), body: SafeArea( top: false, @@ -48,14 +42,20 @@ 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); + context.pushNamed( + GenrePlaylistsPage.name, + pathParameters: { + "categoryId": category.id!, + }, + extra: category, + ); }, child: Ink( padding: const EdgeInsets.all(8), diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 312ca7f9..efdca4f7 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -1,38 +1,68 @@ 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 'package:spotube/collections/assets.gen.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.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/pages/settings/settings.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:spotube/utils/service_utils.dart'; 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 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, + appBar: kIsMobile || kIsMacOS ? null : const PageWindowTitleBar(), body: CustomScrollView( controller: controller, slivers: [ - if (DesktopTools.platform.isMacOS || DesktopTools.platform.isWeb) - const SliverGap(20), + if (mediaQuery.smAndDown || layoutMode == LayoutMode.compact) + SliverAppBar( + floating: true, + title: Assets.spotubeLogoPng.image(height: 45), + actions: [ + const ConnectDeviceButton(), + const Gap(10), + IconButton( + icon: const Icon(SpotubeIcons.settings, size: 20), + onPressed: () { + ServiceUtils.pushNamed(context, SettingsPage.name); + }, + ), + const Gap(10), + ], + ) + else if (kIsMacOS) + const SliverGap(10), const HomeGenresSection(), + const SliverGap(10), + const SliverToBoxAdapter(child: HomeRecentlyPlayedSection()), const SliverToBoxAdapter(child: HomeFeaturedSection()), const HomePageFriendsSection(), const SliverToBoxAdapter(child: 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..8107e627 100644 --- a/lib/pages/lastfm_login/lastfm_login.dart +++ b/lib/pages/lastfm_login/lastfm_login.dart @@ -4,13 +4,14 @@ 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/components/dialogs/prompt_dialog.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/scrobbler_provider.dart'; +import 'package:spotube/provider/scrobbler/scrobbler.dart'; 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) { diff --git a/lib/pages/library/library.dart b/lib/pages/library/library.dart index b6b88656..a0bc1bb7 100644 --- a/lib/pages/library/library.dart +++ b/lib/pages/library/library.dart @@ -1,18 +1,20 @@ import 'package:flutter/material.dart' hide Image; 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:spotube/modules/library/user_local_tracks.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; +import 'package:spotube/modules/library/user_albums.dart'; +import 'package:spotube/modules/library/user_artists.dart'; +import 'package:spotube/modules/library/user_downloads.dart'; +import 'package:spotube/modules/library/user_playlists.dart'; +import 'package:spotube/components/themed_button_tab_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; class LibraryPage extends HookConsumerWidget { - const LibraryPage({Key? key}) : super(key: key); + static const name = "library"; + + const LibraryPage({super.key}); @override Widget build(BuildContext context, ref) { final downloadingCount = ref.watch(downloadManagerProvider).$downloadCount; @@ -27,7 +29,7 @@ class LibraryPage extends HookConsumerWidget { leading: ThemedButtonsTabBar( tabs: [ Tab(text: " ${context.l10n.playlists} "), - Tab(text: " ${context.l10n.local_tracks} "), + Tab(text: " ${context.l10n.local_tab} "), Tab( child: Badge( isLabelVisible: downloadingCount > 0, diff --git a/lib/pages/library/local_folder.dart b/lib/pages/library/local_folder.dart new file mode 100644 index 00000000..ad1d5d82 --- /dev/null +++ b/lib/pages/library/local_folder.dart @@ -0,0 +1,241 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.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/modules/library/user_local_tracks.dart'; +import 'package:spotube/components/expandable_search/expandable_search.dart'; +import 'package:spotube/components/fallbacks/not_found.dart'; +import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; +import 'package:spotube/components/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/utils/service_utils.dart'; + +class LocalLibraryPage extends HookConsumerWidget { + static const name = "local_library_page"; + + final String location; + final bool isDownloads; + const LocalLibraryPage(this.location, {super.key, this.isDownloads = 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 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 = useTextEditingController(); + useValueListenable(searchController); + final searchFocus = useFocusNode(); + final isFiltering = useState(false); + + final controller = useScrollController(); + + return SafeArea( + bottom: false, + child: Scaffold( + appBar: PageWindowTitleBar( + leading: const BackButton(), + centerTitle: true, + title: Text(isDownloads ? context.l10n.downloads : location), + backgroundColor: Colors.transparent, + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const SizedBox(width: 5), + FilledButton( + onPressed: trackSnapshot.asData?.value != null + ? () async { + if (trackSnapshot.asData?.value.isNotEmpty == + true) { + if (!isPlaylistPlaying) { + await playLocalTracks( + ref, + trackSnapshot.asData!.value[location] ?? [], + ); + } + } + } + : 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: 5), + FilledButton( + child: const Icon(SpotubeIcons.refresh), + onPressed: () { + ref.invalidate(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[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 const Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [NotFound()], + ), + ); + } + + return Expanded( + child: RefreshIndicator( + 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/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 802b28d3..b62013c5 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -6,25 +6,27 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.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/modules/library/playlist_generate/multi_select_field.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/extensions/image.dart'; +import 'package:spotube/models/spotify/recommendation_seeds.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.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'; const RecommendationAttribute zeroValues = (min: 0, target: 0, max: 0); 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) { @@ -34,10 +36,10 @@ class PlaylistGeneratorPage extends HookConsumerWidget { final textTheme = theme.textTheme; 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,22 +52,9 @@ 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, @@ -97,8 +86,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { autocompleteOptionBuilder: (option, onSelected) => ListTile( leading: CircleAvatar( backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - option.images, + option.images.asUrlString( placeholder: ImagePlaceholder.artist, ), ), @@ -130,8 +118,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { selectedSeedBuilder: (artist) => Chip( avatar: CircleAvatar( backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - artist.images, + artist.images.asUrlString( placeholder: ImagePlaceholder.artist, ), ), @@ -176,8 +163,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { autocompleteOptionBuilder: (option, onSelected) => ListTile( leading: CircleAvatar( backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - option.album?.images, + (option.album?.images).asUrlString( placeholder: ImagePlaceholder.artist, ), ), @@ -203,7 +189,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { ); final genreSelector = MultiSelectField( - options: genresCollection.data ?? [], + options: genresCollection.asData?.value ?? [], selectedOptions: genres.value, getValueForOption: (option) => option, onSelected: (value) { @@ -355,88 +341,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,23 +562,59 @@ 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), @@ -479,35 +626,18 @@ class PlaylistGeneratorPage extends HookConsumerWidget { genres.value.isEmpty ? null : () { - final PlaylistGenerateResultRouteState - routeState = ( - seeds: ( - artists: artists.value - .map((a) => a.id!) - .toList(), - tracks: tracks.value - .map((t) => t.id!) - .toList(), - genres: genres.value - ), - market: market.value, + final routeState = + GeneratePlaylistProviderInput( + seedArtists: artists.value + .map((a) => a.id!) + .toList(), + seedTracks: + tracks.value.map((t) => t.id!).toList(), + seedGenres: genres.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, - ) + max: max.value, + min: min.value, + target: target.value, ); GoRouter.of(context).push( "/library/generate/result", diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index f751b65b..3bdc3b52 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -1,258 +1,243 @@ -import 'package:fl_query_hooks/fl_query_hooks.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'; 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/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/pages/playlist/playlist.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; 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; - }, - child: Scaffold( - appBar: const PageWindowTitleBar(leading: BackButton()), - body: generatedPlaylist.isLoading - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - Text(context.l10n.generating_playlist), - ], - ), - ) - : Padding( - padding: const EdgeInsets.all(8.0), - child: ListView( - children: [ - GridView( - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - mainAxisExtent: 32, + return Scaffold( + appBar: const PageWindowTitleBar(leading: BackButton()), + body: generatedPlaylist.isLoading + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + Text(context.l10n.generating_playlist), + ], + ), + ) + : Padding( + padding: const EdgeInsets.all(8.0), + child: ListView( + children: [ + GridView( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + mainAxisExtent: 32, + ), + shrinkWrap: true, + children: [ + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.play), + label: Text(context.l10n.play), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + await playlistNotifier.load( + generatedPlaylist.asData!.value + .where( + (e) => selectedTracks.value + .contains(e.id!), + ) + .toList(), + autoPlay: true, + ); + }, ), - shrinkWrap: true, + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.queueAdd), + label: Text(context.l10n.add_to_queue), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + await playlistNotifier.addTracks( + 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, + ), + ), + ), + ); + } + }, + ), + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.addFilled), + label: Text(context.l10n.create_a_playlist), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + final playlist = await showDialog( + context: context, + builder: (context) => PlaylistCreateDialog( + trackIds: selectedTracks.value, + ), + ); + + if (playlist != null) { + router.goNamed( + PlaylistPage.name, + pathParameters: { + "id": playlist.id!, + }, + extra: playlist, + ); + } + }, + ), + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.playlistAdd), + label: Text(context.l10n.add_to_playlist), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + final hasAdded = await showDialog( + context: context, + builder: (context) => PlaylistAddTrackDialog( + openFromPlaylist: null, + tracks: selectedTracks.value + .map( + (e) => generatedPlaylist.asData!.value + .firstWhere( + (element) => element.id == e, + ), + ) + .toList(), + ), + ); + + if (context.mounted && hasAdded == true) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.l10n.add_count_to_playlist( + selectedTracks.value.length, + ), + ), + ), + ); + } + }, + ) + ], + ), + const SizedBox(height: 16), + if (generatedPlaylist.asData?.value != null) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.play), - label: Text(context.l10n.play), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - await playlistNotifier.load( - generatedPlaylist.data!.where( - (e) => - selectedTracks.value.contains(e.id!), - ), - autoPlay: true, - ); - }, + Text( + context.l10n.selected_count_tracks( + selectedTracks.value.length, + ), ), - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.queueAdd), - label: Text(context.l10n.add_to_queue), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - await playlistNotifier.addTracks( - generatedPlaylist.data!.where( - (e) => - selectedTracks.value.contains(e.id!), - ), - ); - if (context.mounted) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.add_count_to_queue( - selectedTracks.value.length, - ), - ), - ), - ); - } - }, + ElevatedButton.icon( + onPressed: () { + if (isAllTrackSelected) { + selectedTracks.value = []; + } else { + selectedTracks.value = generatedPlaylist + .asData?.value + .map((e) => e.id!) + .toList() ?? + []; + } + }, + icon: const Icon(SpotubeIcons.selectionCheck), + label: Text( + isAllTrackSelected + ? context.l10n.deselect_all + : context.l10n.select_all, + ), ), - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.addFilled), - label: Text(context.l10n.create_a_playlist), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - final playlist = await showDialog( - context: context, - builder: (context) => PlaylistCreateDialog( - trackIds: selectedTracks.value, - ), - ); - - if (playlist != null) { - router.go( - '/playlist/${playlist.id}', - extra: playlist, - ); - } - }, - ), - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.playlistAdd), - label: Text(context.l10n.add_to_playlist), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - final hasAdded = await showDialog( - context: context, - builder: (context) => - PlaylistAddTrackDialog( - openFromPlaylist: null, - tracks: selectedTracks.value - .map( - (e) => generatedPlaylist.data! - .firstWhere( - (element) => element.id == e, - ), - ) - .toList(), - ), - ); - - if (context.mounted && hasAdded == true) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.add_count_to_playlist( - selectedTracks.value.length, - ), - ), - ), - ); - } - }, - ) ], ), - const SizedBox(height: 16), - if (generatedPlaylist.data != null) - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + const SizedBox(height: 8), + Card( + margin: const EdgeInsets.all(0), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Text( - context.l10n.selected_count_tracks( - selectedTracks.value.length, - ), - ), - ElevatedButton.icon( - onPressed: () { - if (isAllTrackSelected) { - selectedTracks.value = []; - } else { - selectedTracks.value = generatedPlaylist.data - ?.map((e) => e.id!) - .toList() ?? - []; - } - }, - icon: const Icon(SpotubeIcons.selectionCheck), - label: Text( - isAllTrackSelected - ? context.l10n.deselect_all - : context.l10n.select_all, - ), - ), + for (final track + in generatedPlaylist.asData?.value ?? []) + 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), + ) ], ), - 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), - ) - ], - ), - ), ), - ], - ), + ), + ], ), - ), + ), ); } } diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index ac4b61e7..423212f3 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -2,34 +2,35 @@ import 'dart:ui'; 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: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/components/titlebar/titlebar.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/themed_button_tab_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_custom_status_bar_color.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'; class LyricsPage extends HookConsumerWidget { + static const name = "lyrics"; + final bool isModal; - const LyricsPage({Key? key, this.isModal = false}) : super(key: key); + const LyricsPage({super.key, this.isModal = false}); @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, ), @@ -44,21 +45,40 @@ class LyricsPage extends HookConsumerWidget { noSetBGColor: true, ); - final tabbar = ThemedButtonsTabBar( + PreferredSizeWidget tabbar = ThemedButtonsTabBar( tabs: [ Tab(text: " ${context.l10n.synced} "), Tab(text: " ${context.l10n.plain} "), ], ); - final auth = ref.watch(AuthenticationNotifier.provider); + tabbar = PreferredSize( + preferredSize: tabbar.preferredSize, + child: 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 (auth == null) { - return Scaffold( - appBar: !kIsMacOS && !isModal ? const PageWindowTitleBar() : null, - body: const AnonymousFallback(), - ); - } + if (providerName == null) { + return const SizedBox.shrink(); + } + + return Align( + alignment: Alignment.bottomRight, + child: Text(context.l10n.powered_by_provider(providerName)), + ); + }, + ), + const Gap(5), + ], + ), + ); if (isModal) { return DefaultTabController( @@ -69,7 +89,7 @@ class LyricsPage extends HookConsumerWidget { child: Container( clipBehavior: Clip.hardEdge, decoration: BoxDecoration( - color: Theme.of(context).colorScheme.background.withOpacity(.4), + color: Theme.of(context).colorScheme.surface.withOpacity(.4), borderRadius: const BorderRadius.only( topLeft: Radius.circular(10), topRight: Radius.circular(10), diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index 2cf73728..8f6ec1fc 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -1,27 +1,26 @@ 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: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/modules/root/sidebar.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'; 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,27 +28,22 @@ class MiniLyricsPage extends HookConsumerWidget { final update = useForceUpdate(); final wasMaximized = useRef(false); - final playlistQueue = ref.watch(ProxyPlaylistNotifier.provider); + final playlistQueue = ref.watch(audioPlayerProvider); 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 @@ -77,12 +71,13 @@ class MiniLyricsPage extends HookConsumerWidget { firstChild: DragToMoveArea( child: Row( children: [ - const SizedBox(width: 10), - SizedBox( - height: 30, - width: 30, - child: Sidebar.brandLogo(), - ), + const Gap(10), + if (!kIsMacOS) + SizedBox( + height: 30, + width: 30, + child: Sidebar.brandLogo(), + ), const Spacer(), if (showLyrics.value) SizedBox( @@ -103,8 +98,7 @@ class MiniLyricsPage extends HookConsumerWidget { : const Icon(SpotubeIcons.lyricsOff), style: ButtonStyle( foregroundColor: showLyrics.value - ? MaterialStateProperty.all( - theme.colorScheme.primary) + ? WidgetStateProperty.all(theme.colorScheme.primary) : null, ), onPressed: () async { @@ -112,11 +106,13 @@ class MiniLyricsPage extends HookConsumerWidget { areaActive.value = true; hoverMode.value = false; - await DesktopTools.window.setSize( - showLyrics.value - ? const Size(400, 500) - : const Size(400, 150), - ); + if (kIsDesktop) { + await windowManager.setSize( + showLyrics.value + ? const Size(400, 500) + : const Size(400, 150), + ); + } }, ), IconButton( @@ -126,8 +122,7 @@ class MiniLyricsPage extends HookConsumerWidget { : const Icon(SpotubeIcons.hoverOff), style: ButtonStyle( foregroundColor: hoverMode.value - ? MaterialStateProperty.all( - theme.colorScheme.primary) + ? WidgetStateProperty.all(theme.colorScheme.primary) : null, ), onPressed: () async { @@ -135,33 +130,34 @@ class MiniLyricsPage extends HookConsumerWidget { 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(); - }, - ); - }, - ), + if (kIsDesktop) + FutureBuilder( + future: windowManager.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 + ? WidgetStateProperty.all( + theme.colorScheme.primary) + : null, + ), + onPressed: snapshot.data == null + ? null + : () async { + await windowManager.setAlwaysOnTop( + snapshot.data == true ? false : true, + ); + update(); + }, + ); + }, + ), ], ), ), @@ -179,12 +175,12 @@ class MiniLyricsPage extends HookConsumerWidget { child: TabBarView( children: [ SyncedLyrics( - palette: PaletteColor(theme.colorScheme.background, 0), + palette: PaletteColor(theme.colorScheme.surface, 0), isModal: true, defaultTextZoom: 65, ), PlainLyrics( - palette: PaletteColor(theme.colorScheme.background, 0), + palette: PaletteColor(theme.colorScheme.surface, 0), isModal: true, defaultTextZoom: 65, ), @@ -221,30 +217,41 @@ class MiniLyricsPage extends HookConsumerWidget { MediaQuery.of(context).size.height * .7, ), builder: (context) { - return const PlayerQueue(floating: true); + return Consumer(builder: (context, ref, _) { + final playlist = + ref.watch(audioPlayerProvider); + + return PlayerQueue.fromAudioPlayerNotifier( + floating: true, + playlist: playlist, + notifier: ref + .read(audioPlayerProvider.notifier), + ); + }); }, ); } : null, ), - Flexible(child: PlayerControls(compact: true)), + const Flexible(child: PlayerControls(compact: true)), IconButton( tooltip: context.l10n.exit_mini_player, 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)); diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index bee5114d..7c571d5f 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -4,17 +4,15 @@ 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: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,14 +22,13 @@ 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; @@ -56,8 +53,7 @@ class PlainLyrics extends HookConsumerWidget { ), Center( child: Text( - TypeConversionUtils.artists_X_String( - playlist.activeTrack?.artists ?? []), + playlist.activeTrack?.artists?.asString() ?? "", style: (mediaQuery.mdAndUp ? textTheme.headlineSmall : textTheme.titleLarge) @@ -96,9 +92,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)) { @@ -123,6 +119,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..59bd863a 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -1,26 +1,26 @@ +import 'dart:async'; + 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' hide Offset; 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,47 +30,41 @@ 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 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; 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; }, ); @@ -82,128 +76,149 @@ class SyncedLyrics extends HookConsumerWidget { final bodyTextTheme = textTheme.bodyLarge?.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 + ? textTheme.headlineSmall + : textTheme.titleLarge, + ), ), ), 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.size.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( - onTap: () async { - final duration = - await audioPlayer.duration ?? - Duration.zero; - final time = Duration( - seconds: - lyricSlice.time.inSeconds - delay, - ); - if (time > duration || time.isNegative) { - return; - } - audioPlayer.seek(time); - }, - child: Builder(builder: (context) { - return StrokeText( - text: lyricSlice.text, - textStyle: - DefaultTextStyle.of(context).style, - textColor: isActive - ? Colors.white - : palette.bodyTextColor, - strokeColor: isActive - ? Colors.black - : Colors.transparent, - ); - }), - ), + : 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( + onTap: () async { + final time = Duration( + seconds: + lyricSlice.time.inSeconds - delay, + ); + if (time > audioPlayer.duration || + time.isNegative) { + return; + } + audioPlayer.seek(time); + }, + child: Builder(builder: (context) { + return StrokeText( + text: lyricSlice.text, + textStyle: + DefaultTextStyle.of(context).style, + textColor: isActive + ? Colors.white + : palette.bodyTextColor, + strokeColor: isActive + ? Colors.black + : Colors.transparent, + ); + }), ), ), ), - ); - }, - ), + ), + ); + }, ), 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, @@ -235,7 +250,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..07c0210a --- /dev/null +++ b/lib/pages/mobile_login/hooks/login_callback.dart @@ -0,0 +1,79 @@ +import 'dart:io'; + +import 'package:desktop_webview_window/desktop_webview_window.dart'; +import 'package:flutter/material.dart'; +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:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotube/pages/mobile_login/mobile_login.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.pushNamed(WebViewLogin.name); + 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.go("/"); + } + }); + } + + return true; + }); + } on PlatformException catch (_) { + if (!await WebviewWindow.isWebviewAvailable()) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + showDialog( + context: context, + builder: (context) { + return const NoWebviewRuntimeDialog(); + }, + ); + }); + } + } + }, [authNotifier, theme, context.go, context.pushNamed]); +} diff --git a/lib/pages/mobile_login/mobile_login.dart b/lib/pages/mobile_login/mobile_login.dart index 8b9bce4c..c45c2184 100644 --- a/lib/pages/mobile_login/mobile_login.dart +++ b/lib/pages/mobile_login/mobile_login.dart @@ -1,20 +1,19 @@ import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.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/components/titlebar/titlebar.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/utils/platform.dart'; class WebViewLogin extends HookConsumerWidget { - const WebViewLogin({Key? key}) : super(key: key); + static const name = "login"; + const WebViewLogin({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( @@ -25,48 +24,47 @@ class WebViewLogin extends HookConsumerWidget { } return Scaffold( - body: SafeArea( - 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", - ), - ), - initialUrlRequest: URLRequest( - url: Uri.parse("https://accounts.spotify.com/"), - ), - androidOnPermissionRequest: (controller, origin, resources) async { - return PermissionRequestResponse( - resources: resources, - action: PermissionRequestResponseAction.GRANT, - ); - }, - onLoadStop: (controller, action) async { - if (action == null) return; - String url = action.toString(); - if (url.endsWith("/")) { - url = url.substring(0, url.length - 1); - } - - final exp = RegExp(r"https:\/\/accounts.spotify.com\/.+\/status"); - - if (exp.hasMatch(url)) { - final cookies = - await CookieManager.instance().getCookies(url: action); - final cookieHeader = - "sp_dc=${cookies.firstWhere((element) => element.name == "sp_dc").value}"; - - authenticationNotifier.setCredentials( - await AuthenticationCredentials.fromCookie(cookieHeader), - ); - if (mounted()) { - // ignore: use_build_context_synchronously - GoRouter.of(context).go("/"); - } - } - }, + appBar: const PageWindowTitleBar( + leading: BackButton(color: Colors.white), + backgroundColor: Colors.transparent, + ), + extendBodyBehindAppBar: true, + body: InAppWebView( + 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: WebUri("https://accounts.spotify.com/"), + ), + onPermissionRequest: (controller, permissionRequest) async { + return PermissionResponse( + resources: permissionRequest.resources, + action: PermissionResponseAction.GRANT, + ); + }, + onLoadStop: (controller, action) async { + if (action == null) return; + String url = action.toString(); + if (url.endsWith("/")) { + url = url.substring(0, url.length - 1); + } + + final exp = RegExp(r"https:\/\/accounts.spotify.com\/.+\/status"); + + if (exp.hasMatch(url)) { + final cookies = + await CookieManager.instance().getCookies(url: action); + final cookieHeader = + "sp_dc=${cookies.firstWhere((element) => element.name == "sp_dc").value}"; + + await authenticationNotifier.login(cookieHeader); + if (context.mounted) { + // ignore: use_build_context_synchronously + GoRouter.of(context).go("/"); + } + } + }, ), ); } 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..a6cc5ffb --- /dev/null +++ b/lib/pages/mobile_login/no_webview_runtime_dialog.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.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), + ), + FilledButton( + 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) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: 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/playlist/liked_playlist.dart b/lib/pages/playlist/liked_playlist.dart index 1fb2e1dc..942f46d5 100644 --- a/lib/pages/playlist/liked_playlist.dart +++ b/lib/pages/playlist/liked_playlist.dart @@ -1,24 +1,27 @@ 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/tracks_view/track_view.dart'; +import 'package:spotube/components/tracks_view/track_view_props.dart'; +import 'package:spotube/pages/playlist/playlist.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; 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!, + collection: playlist, image: "assets/liked-tracks.jpg", pagination: PaginationProps( hasNextPage: false, @@ -28,7 +31,7 @@ class LikedPlaylistPage extends HookConsumerWidget { return tracks.toList(); }, onRefresh: () async { - await likedTracks.refresh(); + ref.invalidate(likedTracksProvider); }, ), title: playlist.name!, diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index 89a279ab..e1b33e98 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -1,97 +1,92 @@ +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/tracks_view/sections/body/use_is_user_playlist.dart'; +import 'package:spotube/components/tracks_view/track_view.dart'; +import 'package:spotube/components/tracks_view/track_view_props.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'; class PlaylistPage extends HookConsumerWidget { - final PlaylistSimple playlist; + static const name = "playlist"; + + final PlaylistSimple _playlist; const PlaylistPage({ - Key? key, - required this.playlist, - }) : super(key: key); + super.key, + 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, + collection: playlist, + image: playlist.images.asUrlString( 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(); - }, - ); + pagination: PaginationProps( + hasNextPage: tracks.asData?.value.hasMore ?? false, + 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, - tracks: tracks, + tracks: tracks.asData?.value.items ?? [], 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; - }, + 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; + }, child: const TrackView(), ); } diff --git a/lib/pages/profile/profile.dart b/lib/pages/profile/profile.dart new file mode 100644 index 00000000..9e51793d --- /dev/null +++ b/lib/pages/profile/profile.dart @@ -0,0 +1,148 @@ +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: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'; + +class ProfilePage extends HookConsumerWidget { + static const name = "profile"; + + const ProfilePage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:textTheme) = Theme.of(context); + + 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( + appBar: PageWindowTitleBar( + title: Text(context.l10n.profile), + titleSpacing: 0, + automaticallyImplyLeading: true, + centerTitle: false, + ), + body: 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, + style: textTheme.titleLarge, + textAlign: TextAlign.center, + ), + ), + const SliverGap(20), + SliverCrossAxisConstrained( + maxCrossAxisExtent: 500, + child: SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + label: Text(context.l10n.edit), + icon: const Icon(SpotubeIcons.edit), + onPressed: () { + launchUrlString( + "https://www.spotify.com/account/profile/", + mode: LaunchMode.externalApplication, + ); + }, + ), + ], + ), + ), + ), + SliverCrossAxisConstrained( + maxCrossAxisExtent: 500, + child: SliverToBoxAdapter( + child: Card( + margin: const EdgeInsets.all(10), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Table( + columnWidths: const { + 0: FixedColumnWidth(110), + }, + children: [ + for (final MapEntry(:key, :value) + in userProperties.entries) + TableRow( + children: [ + TableCell( + child: Padding( + padding: const EdgeInsets.all(6), + child: Text( + key, + style: textTheme.titleSmall, + ), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(6), + child: Text(value), + ), + ), + ], + ) + ], + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index aaf3e30a..0274de00 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -1,107 +1,124 @@ import 'dart:async'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.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/side_bar_tiles.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/components/framework/app_pop_scope.dart'; +import 'package:spotube/modules/player/player_queue.dart'; +import 'package:spotube/components/dialogs/replace_downloaded_dialog.dart'; +import 'package:spotube/modules/root/bottom_player.dart'; +import 'package:spotube/modules/root/sidebar.dart'; +import 'package:spotube/modules/root/spotube_navigation_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart'; -import 'package:spotube/hooks/configurators/use_update_checker.dart'; +import 'package:spotube/pages/home/home.dart'; import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/utils/persisted_state_notifier.dart'; - -const rootPaths = { - "/": 0, - "/search": 1, - "/library": 2, - "/lyrics": 3, -}; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/server/routes/connect.dart'; +import 'package:spotube/services/connectivity_adapter.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:spotube/utils/service_utils.dart'; class RootApp extends HookConsumerWidget { final Widget child; const RootApp({ required this.child, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { - final isMounted = useIsMounted(); + final theme = Theme.of(context); + 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 connectRoutes = ref.watch(serverConnectRoutesProvider); useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) async { - final sharedPreferences = await SharedPreferences.getInstance(); - - if (sharedPreferences.getBool(kIsUsingEncryption) == false && - context.mounted) { - await PersistedStateNotifier.showNoEncryptionDialog(context); - } + ServiceUtils.checkForUpdates(context, ref); }); - final subscription = - QueryClient.connectivity.onConnectivityChanged.listen((status) { - if (status) { + final subscriptions = [ + ConnectionCheckerService.instance.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, + ), + ); + } + }), + connectRoutes.connectClientStream.listen((clientOrigin) { scaffoldMessenger.showSnackBar( SnackBar( + backgroundColor: Colors.yellow[600], + behavior: SnackBarBehavior.floating, content: Row( + mainAxisSize: MainAxisSize.min, children: [ - Icon( - SpotubeIcons.wifi, - color: theme.colorScheme.onPrimary, + const Icon( + SpotubeIcons.error, + color: Colors.black, ), 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, + Text( + context.l10n.connect_client_alert(clientOrigin), + style: const TextStyle(color: Colors.black), ), - const SizedBox(width: 10), - Text(context.l10n.you_are_offline), ], ), - backgroundColor: theme.colorScheme.error, - showCloseIcon: true, - width: 300, ), ); - } - }); + }) + ]; return () { - subscription.cancel(); + for (final subscription in subscriptions) { + subscription.cancel(); + } }; }, []); useEffect(() { downloader.onFileExists = (track) async { - if (!isMounted()) return false; + if (!context.mounted) return false; if (!showingDialogCompleter.value.isCompleted) { await showingDialogCompleter.value.future; @@ -133,7 +150,6 @@ class RootApp extends HookConsumerWidget { }, [downloader]); // checks for latest version of the application - useUpdateChecker(ref); useEndlessPlayback(ref); @@ -151,59 +167,69 @@ class RootApp extends HookConsumerWidget { return null; }, [backgroundColor]); - void onSelectIndexChanged(int d) { - final invertedRouteMap = - rootPaths.map((key, value) => MapEntry(value, key)); + final navTileNames = useMemoized(() { + return getSidebarTileList(context.l10n).map((s) => s.name).toList(); + }, []); - if (context.mounted) { - WidgetsBinding.instance.addPostFrameCallback((_) { - GoRouter.of(context).go(invertedRouteMap[d]!); - }); - } + final scaffold = Scaffold( + body: Sidebar(child: child), + extendBody: true, + drawerScrimColor: Colors.transparent, + endDrawer: kIsDesktop + ? 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: Consumer( + builder: (context, ref, _) { + final playlist = ref.watch(audioPlayerProvider); + final playlistNotifier = + ref.read(audioPlayerProvider.notifier); + + return PlayerQueue.fromAudioPlayerNotifier( + floating: true, + playlist: playlist, + notifier: playlistNotifier, + ); + }, + ), + ) + : null, + bottomNavigationBar: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + BottomPlayer(), + SpotubeNavigationBar(), + ], + ), + ); + + if (!kIsAndroid) { + return scaffold; } - return WillPopScope( - onWillPop: () async { - if (rootPaths[location] != 0) { - onSelectIndexChanged(0); - return false; + final topRoute = GoRouterState.of(context).topRoute; + final canPop = topRoute != null && !navTileNames.contains(topRoute.name); + + return AppPopScope( + canPop: canPop, + onPopInvoked: (didPop) { + if (didPop) return; + + if (topRoute?.name == HomePage.name) { + SystemNavigator.pop(); + } else { + context.goNamed(HomePage.name); } - 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: [ - BottomPlayer(), - SpotubeNavigationBar( - selectedIndex: rootPaths[location], - onSelectedIndexChanged: onSelectIndexChanged, - ), - ], - ), - ), + child: scaffold, ); } } diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index f4a78d4f..d5de12f0 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -1,74 +1,59 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.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: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/utils/use_force_update.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/provider/authentication/authentication.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:collection/collection.dart'; - -final searchTermStateProvider = StateProvider((ref) => ""); 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 searchTerm = ref.watch(searchTermStateProvider); + final controller = useSearchController(); + + final auth = ref.watch(authenticationProvider); final mediaQuery = MediaQuery.of(context); - 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 isFetching = queries.every((s) => s.isLoading); + + useEffect(() { + controller.text = searchTerm; + + return null; + }, []); final resultWidget = HookBuilder( builder: (context) { @@ -78,18 +63,18 @@ class SearchPage extends HookConsumerWidget { controller: controller, child: SingleChildScrollView( controller: controller, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), + child: const Padding( + padding: 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), + SearchTracksSection(), + SearchPlaylistsSection(), + Gap(20), + SearchArtistsSection(), + Gap(20), + SearchAlbumsSection(), ], ), ), @@ -102,35 +87,117 @@ class SearchPage extends HookConsumerWidget { return SafeArea( bottom: false, child: Scaffold( - appBar: kIsDesktop && !kIsMacOS ? const PageWindowTitleBar() : null, - body: !authenticationNotifier.isLoggedIn + appBar: kIsDesktop && !kIsMacOS + ? const PageWindowTitleBar(automaticallyImplyLeading: true) + : null, + body: auth.asData?.value == null ? const AnonymousFallback() : Column( children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 10, - ), - 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}...", + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if ((kIsMobile || kIsMacOS) && context.canPop()) + const BackButton() + else + const Gap(20), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + right: 20, + top: 20, + bottom: 20, + ), + child: SearchAnchor( + searchController: controller, + viewBuilder: (_) => HookBuilder(builder: (context) { + final searchController = + useListenable(controller); + final update = useForceUpdate(); + final suggestions = searchController.text.isEmpty + ? KVStoreService.recentSearches + : KVStoreService.recentSearches + .where( + (s) => + weightedRatio( + s.toLowerCase(), + searchController.text + .toLowerCase(), + ) > + 50, + ) + .toList(); + + return ListView.builder( + itemCount: suggestions.length, + itemBuilder: (context, index) { + final suggestion = suggestions[index]; + + return ListTile( + leading: const Icon(SpotubeIcons.history), + title: Text(suggestion), + trailing: IconButton( + icon: const Icon(SpotubeIcons.trash), + onPressed: () { + KVStoreService.setRecentSearches( + KVStoreService.recentSearches + .where((s) => s != suggestion) + .toList(), + ); + update(); + }, + ), + onTap: () { + controller.closeView(suggestion); + ref + .read( + searchTermStateProvider.notifier) + .state = suggestion; + }, + ); + }, + ); + }), + suggestionsBuilder: (context, controller) { + return []; + }, + viewOnSubmitted: (value) async { + controller.closeView(value); + Timer( + const Duration(milliseconds: 50), + () { + ref + .read(searchTermStateProvider.notifier) + .state = value; + if (value.trim().isEmpty) { + return; + } + KVStoreService.setRecentSearches( + { + value, + ...KVStoreService.recentSearches, + }.toList(), + ); + }, + ); + }, + builder: (context, controller) { + return SearchBar( + autoFocus: queries.none((s) => + s.asData?.value != null && + !s.hasError) && + !kIsMobile, + controller: controller, + leading: const Icon(SpotubeIcons.search), + hintText: "${context.l10n.search}...", + onTap: controller.openView, + onChanged: (_) => controller.openView(), + ); + }, + ), + ), ), - 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( @@ -144,7 +211,7 @@ class SearchPage extends HookConsumerWidget { Icon( SpotubeIcons.web, size: 120, - color: theme.colorScheme.onBackground + color: theme.colorScheme.onSurface .withOpacity(0.7), ), const SizedBox(height: 20), @@ -152,7 +219,7 @@ class SearchPage extends HookConsumerWidget { context.l10n.search_to_get_results, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w900, - color: theme.colorScheme.onBackground + color: theme.colorScheme.onSurface .withOpacity(0.5), ), ), @@ -178,7 +245,7 @@ class SearchPage extends HookConsumerWidget { style: TextStyle( fontSize: 20, fontWeight: FontWeight.w900, - color: theme.colorScheme.onBackground + color: theme.colorScheme.onSurface .withOpacity(0.7), ), ), diff --git a/lib/pages/search/sections/albums.dart b/lib/pages/search/sections/albums.dart index 8aa33feb..857eb59c 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: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..16295580 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: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..3799f9fa 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: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..6ec8f685 100644 --- a/lib/pages/search/sections/tracks.dart +++ b/lib/pages/search/sections/tracks.dart @@ -1,34 +1,31 @@ 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: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( @@ -43,50 +40,80 @@ class SearchTracksSection extends HookConsumerWidget { style: theme.textTheme.titleLarge!, ), ), - 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) { + 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..1357c52f 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.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/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'; @@ -16,7 +16,9 @@ final _licenseProvider = FutureProvider((ref) async { }); class AboutSpotube extends HookConsumerWidget { - const AboutSpotube({Key? key}) : super(key: key); + static const name = "about"; + + const AboutSpotube({super.key}); @override Widget build(BuildContext context, ref) { @@ -72,6 +74,13 @@ class AboutSpotube extends HookConsumerWidget { Text("v${packageInfo.version}") ], ), + TableRow( + children: [ + Text(context.l10n.channel), + colon, + Text(Env.releaseChannel.name) + ], + ), TableRow( children: [ Text(context.l10n.build_number), @@ -129,63 +138,6 @@ class AboutSpotube extends HookConsumerWidget { ), ), 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", - ), - mode: LaunchMode.externalApplication, - ); - }, - child: Image.network( - "https://opencollective.com/spotube/donate/button.png?color=blue", - height: 45, - ), - ), - ), - 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, diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index b4ce5044..1f018dab 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -5,36 +5,40 @@ import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/blacklist_provider.dart'; 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], ); @@ -68,14 +72,14 @@ class BlackListPage extends HookConsumerWidget { final item = filteredBlacklist.elementAt(index); return ListTile( leading: Text("${index + 1}."), - title: Text("${item.name} (${item.type.name})"), - subtitle: Text(item.id), + title: Text("${item.name} (${item.elementType.name})"), + subtitle: Text(item.elementId), trailing: IconButton( icon: Icon(SpotubeIcons.trash, color: Colors.red[400]), onPressed: () { ref - .read(BlackListNotifier.provider.notifier) - .remove(filteredBlacklist.elementAt(index)); + .read(blacklistProvider.notifier) + .remove(filteredBlacklist.elementAt(index).elementId); }, ), ); diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart index cfb28d18..6ccbe32f 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -1,75 +1,24 @@ -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:hooks_riverpod/hooks_riverpod.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/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'; -class LogsPage extends HookWidget { - const LogsPage({Key? key}) : super(key: key); +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( @@ -80,7 +29,9 @@ class LogsPage extends HookWidget { icon: const Icon(SpotubeIcons.clipboard), iconSize: 16, onPressed: () async { - await Clipboard.setData(ClipboardData(text: rawLogs.value)); + final logsSnapshot = await ref.read(logsProvider.future); + + await Clipboard.setData(ClipboardData(text: logsSnapshot)); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -90,55 +41,36 @@ class LogsPage extends HookWidget { } }, ), + IconButton( + icon: const Icon(SpotubeIcons.trash), + iconSize: 16, + onPressed: () async { + ref.invalidate(logsProvider); + + final logsFile = await AppLogger.getLogsPath(); + + await logsFile.writeAsString(""); + }, + ) ], ), 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), - ), - ], + child: switch (logsQuery) { + AsyncData(:final value) => Card( + child: InterScrollbar( + controller: controller, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SingleChildScrollView( + controller: controller, + child: Text(value), ), - Positioned( - right: 10, - top: 0, - child: IconButton( - icon: const Icon(SpotubeIcons.clipboard), - onPressed: () async { - await Clipboard.setData( - ClipboardData(text: log.body), - ); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.copied_to_clipboard( - log.date.toString(), - ), - ), - ), - ); - } - }, - ), - ), - ], - ); - }, - ), - ), + ), + ), + ), + AsyncError(:final error) => 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..a0a5bf30 100644 --- a/lib/pages/settings/sections/about.dart +++ b/lib/pages/settings/sections/about.dart @@ -4,14 +4,14 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/env.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,49 +21,50 @@ 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) => FilledButton( + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll(Colors.red[100]), + foregroundColor: + const WidgetStatePropertyAll(Colors.pinkAccent), + padding: const WidgetStatePropertyAll(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), + ], + ), ), ), - ), if (Env.enableUpdateChecker) SwitchListTile( secondary: const Icon(SpotubeIcons.update), diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index 83740866..b9a26147 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -3,31 +3,60 @@ import 'package:flutter/material.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/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/profile/profile.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'; +import 'package:spotube/utils/service_utils.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 auth = ref.watch(authenticationProvider); + final scrobbler = ref.watch(scrobblerProvider); + final me = ref.watch(meProvider); + final meData = me.asData?.value; + final logoutBtnStyle = FilledButton.styleFrom( backgroundColor: Colors.red, foregroundColor: Colors.white, ); + 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: CircleAvatar( + backgroundImage: UniversalImage.imageProvider( + (meData?.images).asUrlString( + placeholder: ImagePlaceholder.artist, + ), + ), + ), + ), + onTap: () { + ServiceUtils.pushNamed(context, ProfilePage.name); + }, + ), + if (auth.asData?.value == null) LayoutBuilder(builder: (context, constrains) { return ListTile( leading: Icon( @@ -44,19 +73,13 @@ 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"); - }, + onPressed: onLogin, style: ButtonStyle( - shape: MaterialStateProperty.all( + shape: WidgetStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(25.0), ), @@ -86,14 +109,14 @@ class SettingsAccountSection extends HookConsumerWidget { trailing: FilledButton( style: logoutBtnStyle, onPressed: () async { - ref.read(AuthenticationNotifier.provider.notifier).logout(); + ref.read(authenticationProvider.notifier).logout(); GoRouter.of(context).pop(); }, 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), diff --git a/lib/pages/settings/sections/appearance.dart b/lib/pages/settings/sections/appearance.dart index 3d941212..f97add42 100644 --- a/lib/pages/settings/sections/appearance.dart +++ b/lib/pages/settings/sections/appearance.dart @@ -3,19 +3,19 @@ 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) { diff --git a/lib/pages/settings/sections/desktop.dart b/lib/pages/settings/sections/desktop.dart index ae721fc4..c61f0150 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:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.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,6 +19,7 @@ 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), @@ -51,13 +52,12 @@ class SettingsDesktopSection extends HookConsumerWidget { value: preferences.systemTitleBar, onChanged: preferencesNotifier.setSystemTitleBar, ), - if (!DesktopTools.platform.isMacOS) - SwitchListTile( - secondary: const Icon(SpotubeIcons.discord), - title: Text(context.l10n.discord_rich_presence), - value: preferences.discordPresence, - onChanged: preferencesNotifier.setDiscordPresence, - ), + SwitchListTile( + secondary: const Icon(SpotubeIcons.discord), + title: Text(context.l10n.discord_rich_presence), + value: preferences.discordPresence, + onChanged: preferencesNotifier.setDiscordPresence, + ), ], ); } diff --git a/lib/pages/settings/sections/developers.dart b/lib/pages/settings/sections/developers.dart index 4b5f58a6..f33fe843 100644 --- a/lib/pages/settings/sections/developers.dart +++ b/lib/pages/settings/sections/developers.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.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) { diff --git a/lib/pages/settings/sections/downloads.dart b/lib/pages/settings/sections/downloads.dart index b1e360d0..8e679a7d 100644 --- a/lib/pages/settings/sections/downloads.dart +++ b/lib/pages/settings/sections/downloads.dart @@ -1,16 +1,16 @@ 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_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.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 +18,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, ); diff --git a/lib/pages/settings/sections/language_region.dart b/lib/pages/settings/sections/language_region.dart index fbfe1030..18c2d088 100644 --- a/lib/pages/settings/sections/language_region.dart +++ b/lib/pages/settings/sections/language_region.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.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'; @@ -23,6 +24,7 @@ class SettingsLanguageRegionSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.language_region, children: [ + const Gap(10), AdaptiveSelectTile( value: preferences.locale, onChanged: (locale) { @@ -55,7 +57,7 @@ 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); diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart index bd2e33b9..6273c557 100644 --- a/lib/pages/settings/sections/playback.dart +++ b/lib/pages/settings/sections/playback.dart @@ -1,20 +1,22 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:piped_client/piped_client.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/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/sourced_track/enums.dart'; class SettingsPlaybackSection extends HookConsumerWidget { - const SettingsPlaybackSection({Key? key}) : super(key: key); + const SettingsPlaybackSection({super.key}); @override Widget build(BuildContext context, ref) { @@ -25,6 +27,7 @@ class SettingsPlaybackSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.playback, children: [ + const Gap(10), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.audioQuality), title: Text(context.l10n.audio_quality), @@ -49,6 +52,7 @@ class SettingsPlaybackSection extends HookConsumerWidget { } }, ), + const Gap(5), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.api), title: Text(context.l10n.audio_source), @@ -181,7 +185,8 @@ class SettingsPlaybackSection extends HookConsumerWidget { value: preferences.normalizeAudio, onChanged: preferencesNotifier.setNormalizeAudio, ), - if (preferences.audioSource != AudioSource.jiosaavn) + if (preferences.audioSource != AudioSource.jiosaavn) ...[ + const Gap(5), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.stream), title: Text(context.l10n.streaming_music_codec), @@ -201,7 +206,7 @@ class SettingsPlaybackSection extends HookConsumerWidget { preferencesNotifier.setStreamMusicCodec(value); }, ), - if (preferences.audioSource != AudioSource.jiosaavn) + const Gap(5), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.file), title: Text(context.l10n.download_music_codec), @@ -220,13 +225,21 @@ class SettingsPlaybackSection extends HookConsumerWidget { if (value == null) return; preferencesNotifier.setDownloadMusicCodec(value); }, - ), + ) + ], SwitchListTile( secondary: const Icon(SpotubeIcons.repeat), title: Text(context.l10n.endless_playback), value: preferences.endlessPlayback, onChanged: preferencesNotifier.setEndlessPlayback, ), + SwitchListTile( + title: Text(context.l10n.enable_connect), + subtitle: Text(context.l10n.enable_connect_description), + secondary: const Icon(SpotubeIcons.connect), + value: preferences.enableConnect, + onChanged: preferencesNotifier.setEnableConnect, + ), ], ); } diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index f773b809..8bce4bcf 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -1,9 +1,8 @@ import 'package:flutter/foundation.dart'; 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/components/shared/page_window_title_bar.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 +13,12 @@ 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'; 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) { @@ -29,6 +31,7 @@ class SettingsPage extends HookConsumerWidget { appBar: PageWindowTitleBar( title: Text(context.l10n.settings), centerTitle: true, + automaticallyImplyLeading: true, ), body: Scrollbar( controller: controller, @@ -45,8 +48,7 @@ class SettingsPage extends HookConsumerWidget { const SettingsAppearanceSection(), const SettingsPlaybackSection(), const SettingsDownloadsSection(), - if (DesktopTools.platform.isDesktop) - const SettingsDesktopSection(), + if (kIsDesktop) const SettingsDesktopSection(), if (!kIsWeb) const SettingsDevelopersSection(), const SettingsAboutSection(), Center( diff --git a/lib/pages/stats/albums/albums.dart b/lib/pages/stats/albums/albums.dart new file mode 100644 index 00000000..e14a2f32 --- /dev/null +++ b/lib/pages/stats/albums/albums.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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'; + +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 Scaffold( + appBar: PageWindowTitleBar( + automaticallyImplyLeading: true, + centerTitle: false, + title: Text(context.l10n.albums), + ), + body: 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..436bbb57 --- /dev/null +++ b/lib/pages/stats/artists/artists.dart @@ -0,0 +1,58 @@ +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: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'; + +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 Scaffold( + appBar: PageWindowTitleBar( + automaticallyImplyLeading: true, + centerTitle: false, + title: Text(context.l10n.artists), + ), + body: 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..da62fb30 --- /dev/null +++ b/lib/pages/stats/fees/fees.dart @@ -0,0 +1,138 @@ +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: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'; + +class StatsStreamFeesPage extends HookConsumerWidget { + static const name = "stats_stream_fees"; + + const StatsStreamFeesPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:textTheme, :hintColor) = Theme.of(context); + 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], + ); + + return Scaffold( + appBar: PageWindowTitleBar( + automaticallyImplyLeading: true, + centerTitle: false, + title: Text(context.l10n.streaming_fees_hypothetical), + ), + body: CustomScrollView( + slivers: [ + SliverCrossAxisConstrained( + maxCrossAxisExtent: 600, + alignment: -1, + child: SliverPadding( + padding: const EdgeInsets.all(16.0), + sliver: SliverToBoxAdapter( + child: Text( + context.l10n.spotify_hipotetical_calculation, + style: textTheme.bodySmall?.copyWith( + color: hintColor, + ), + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.l10n.total_money(usdFormatter.format(total)), + style: textTheme.titleLarge, + ), + DropdownButton( + value: duration.value, + onChanged: (value) { + if (value == null) return; + duration.value = value; + }, + items: [ + DropdownMenuItem( + value: HistoryDuration.days7, + child: Text(context.l10n.this_week), + ), + DropdownMenuItem( + value: HistoryDuration.days30, + child: Text(context.l10n.this_month), + ), + DropdownMenuItem( + value: HistoryDuration.months6, + child: Text(context.l10n.last_6_months), + ), + DropdownMenuItem( + value: HistoryDuration.year, + child: Text(context.l10n.this_year), + ), + DropdownMenuItem( + value: HistoryDuration.years2, + child: Text(context.l10n.last_2_years), + ), + DropdownMenuItem( + value: HistoryDuration.allTime, + child: Text(context.l10n.all_time), + ), + ], + ), + ], + ), + ), + ), + 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..3ad0984b --- /dev/null +++ b/lib/pages/stats/minutes/minutes.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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'; + +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 Scaffold( + appBar: PageWindowTitleBar( + title: Text(context.l10n.minutes_listened), + centerTitle: false, + automaticallyImplyLeading: true, + ), + body: 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..4e83b0a2 --- /dev/null +++ b/lib/pages/stats/playlists/playlists.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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'; + +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 Scaffold( + appBar: PageWindowTitleBar( + automaticallyImplyLeading: true, + centerTitle: false, + title: Text(context.l10n.playlists), + ), + body: 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..b2dc03c2 --- /dev/null +++ b/lib/pages/stats/stats.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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'; + +class StatsPage extends HookConsumerWidget { + static const name = "stats"; + + const StatsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + return SafeArea( + bottom: false, + child: Scaffold( + appBar: kIsMacOS || kIsMobile ? null : const PageWindowTitleBar(), + body: 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..059366e0 --- /dev/null +++ b/lib/pages/stats/streams/streams.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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'; + +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 Scaffold( + appBar: PageWindowTitleBar( + title: Text(context.l10n.streamed_songs), + centerTitle: false, + automaticallyImplyLeading: true, + ), + body: 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..84c53b74 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -6,38 +6,43 @@ 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/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'; class TrackPage extends HookConsumerWidget { + static const name = "track"; + final String trackId; const TrackPage({ - Key? key, + super.key, required this.trackId, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { final ThemeData(:textTheme, :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) { @@ -60,8 +65,7 @@ class TrackPage extends HookConsumerWidget { decoration: BoxDecoration( image: DecorationImage( image: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - track.album!.images, + track.album!.images.asUrlString( placeholder: ImagePlaceholder.albumArt, ), ), @@ -104,8 +108,7 @@ class TrackPage extends HookConsumerWidget { ClipRRect( borderRadius: BorderRadius.circular(10), child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString( - track.album!.images, + path: track.album!.images.asUrlString( placeholder: ImagePlaceholder.albumArt, ), height: 200, @@ -146,9 +149,11 @@ class TrackPage extends HookConsumerWidget { children: [ const Icon(SpotubeIcons.artist), const Gap(5), - TypeConversionUtils - .artists_X_ClickableArtists( - track.artists!, + Flexible( + child: ArtistLink( + artists: track.artists!, + hideOverflowArtist: false, + ), ), ], ), @@ -163,7 +168,8 @@ class TrackPage extends HookConsumerWidget { children: [ const Gap(5), if (!isActive && - !playlist.tracks.contains(track)) + !playlist.tracks + .containsBy(track, (t) => t.id)) OutlinedButton.icon( icon: const Icon(SpotubeIcons.queueAdd), label: Text(context.l10n.queue), @@ -173,7 +179,8 @@ class TrackPage extends HookConsumerWidget { ), const Gap(5), if (!isActive && - !playlist.tracks.contains(track)) + !playlist.tracks + .containsBy(track, (t) => t.id)) IconButton.outlined( icon: const Icon(SpotubeIcons.lightning), diff --git a/lib/provider/audio_player/audio_player.dart b/lib/provider/audio_player/audio_player.dart new file mode 100644 index 00000000..7c1b6897 --- /dev/null +++ b/lib/provider/audio_player/audio_player.dart @@ -0,0 +1,361 @@ +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(); + var 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) 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 (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..08550844 --- /dev/null +++ b/lib/provider/audio_player/audio_player_streams.dart @@ -0,0 +1,165 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:palette_generator/palette_generator.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/extensions/image.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/palette_provider.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/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); + + 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 { + final activeTrack = ref.read(audioPlayerProvider).activeTrack; + if (activeTrack == null) return; + + final palette = await PaletteGenerator.fromImageProvider( + UniversalImage.imageProvider( + (activeTrack.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + height: 50, + width: 50, + ), + ); + ref.read(paletteProvider.notifier).state = palette; + }); + } + + StreamSubscription subscribeToPlaylist() { + return audioPlayer.playlistStream.listen((mpvPlaylist) { + try { + notificationService.addTrack(audioPlayerState.activeTrack!); + discord.updatePresence(audioPlayerState.activeTrack!); + updatePalette(); + } 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) { + 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!); + history.addTrack(audioPlayerState.activeTrack!); + lastScrobbled = uid; + } 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 { + try { + if (event < const Duration(seconds: 3) || + 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/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..05a05972 --- /dev/null +++ b/lib/provider/authentication/authentication.dart @@ -0,0 +1,173 @@ +import 'dart:async'; +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/io.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/utils/platform.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(); + + (dio.httpClientAdapter as IOHttpClientAdapter) + .createHttpClient = () => HttpClient() + ..badCertificateCallback = (X509Certificate cert, String host, int port) { + return host.endsWith("spotify.com") && port == 443; + }; + + 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; + + ref.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); + } + + Future credentialsFromCookie( + String cookie, + ) async { + try { + final spDc = cookie + .split("; ") + .firstWhereOrNull((c) => c.trim().startsWith("sp_dc=")) + ?.trim(); + final res = await dio.getUri( + Uri.parse( + "https://open.spotify.com/get_access_token?reason=transport&productType=web_player", + ), + options: Options( + headers: { + "Cookie": spDc ?? "", + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" + }, + validateStatus: (status) => true, + ), + ); + final body = res.data; + + if ((res.statusCode ?? 500) >= 400) { + throw Exception( + "Failed to get access token: ${body['error'] ?? res.statusMessage}", + ); + } + + 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..000a28af --- /dev/null +++ b/lib/provider/connect/connect.dart @@ -0,0 +1,190 @@ +import 'dart:convert'; + +import 'package:media_kit/media_kit.dart' hide Track; +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, +); + +class ConnectNotifier extends AsyncNotifier { + @override + build() async { + try { + final connectClients = ref.watch(connectClientsProvider); + + if (connectClients.asData?.value.resolvedService == null) return null; + + final service = connectClients.asData!.value.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 subscription = channel.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; + }); + }, + onError: (error) { + AppLogger.reportError(error, StackTrace.current); + }, + ); + + ref.onDispose(() { + subscription.cancel(); + channel.sink.close(status.goingAway); + }); + + return channel; + } catch (e, stack) { + AppLogger.reportError(e, stack); + rethrow; + } + } + + Future emit(Object message) async { + if (state.value == null) return; + state.value?.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..ad0c389a 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_provider.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..8c9ffadf 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:io'; -import 'package:catcher_2/catcher_2.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'; @@ -9,12 +9,13 @@ 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'; class DownloadManagerProvider extends ChangeNotifier { DownloadManagerProvider({required this.ref}) @@ -22,66 +23,72 @@ 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 downloadImage( + (track.album?.images).asUrlString( + 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: BigInt.from(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, + ); + } 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, - ); }); } @@ -127,14 +134,14 @@ class DownloadManagerProvider extends ChangeNotifier { return Uint8List.fromList(bytes); } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); + AppLogger.reportError(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 +220,7 @@ class DownloadManagerProvider extends ChangeNotifier { ); } } catch (e) { - Catcher2.reportCheckedError(e, StackTrace.current); + AppLogger.reportError(e, StackTrace.current); continue; } } diff --git a/lib/provider/history/history.dart b/lib/provider/history/history.dart new file mode 100644 index 00000000..0c20a9e5 --- /dev/null +++ b/lib/provider/history/history.dart @@ -0,0 +1,68 @@ +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 { + await _batchInsertHistoryEntries([ + for (final track in tracks) + HistoryTableCompanion.insert( + type: HistoryEntryType.track, + itemId: track.id!, + data: track.toJson(), + ), + ]); + } + + Future addTrack(Track track) async { + 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..ef393a17 --- /dev/null +++ b/lib/provider/history/recent.dart @@ -0,0 +1,62 @@ +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; + +class RecentlyPlayedItemNotifier extends AsyncNotifier> { + @override + build() async { + final database = ref.watch(databaseProvider); + + final uniqueItemIds = await (database.selectOnly( + database.historyTable, + distinct: true, + ) + ..addColumns([database.historyTable.itemId, database.historyTable.id]) + ..where( + database.historyTable.type.isInValues([ + HistoryEntryType.playlist, + HistoryEntryType.album, + ]), + ) + ..limit(10) + ..orderBy([ + OrderingTerm( + expression: database.historyTable.createdAt, + mode: OrderingMode.desc, + ), + ])) + .map( + (row) => row.read(database.historyTable.id), + ) + .get() + .then((value) => value.whereNotNull().toList()); + + final query = database.select(database.historyTable) + ..where( + (tbl) => tbl.id.isIn(uniqueItemIds), + ) + ..orderBy([ + (tbl) => OrderingTerm( + expression: tbl.createdAt, + mode: OrderingMode.desc, + ), + ]); + + final subscription = query.watch().listen((event) { + state = AsyncData(event); + }); + + ref.onDispose(() => subscription.cancel()); + + final items = 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..b737d148 --- /dev/null +++ b/lib/provider/history/top/tracks.dart @@ -0,0 +1,142 @@ +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, artist: 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)), + }), + ); + } + + @override + fetch(arg, offset, limit) async { + final tracksQuery = createTracksQuery()..limit(limit, offset: offset); + + final items = getTracksWithCount(await tracksQuery.get()); + + 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) { + return groupBy( + tracks, + (track) => track.track!.id!, + ) + .entries + .map((entry) { + return (count: entry.value.length, 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..513fd9b9 --- /dev/null +++ b/lib/provider/local_tracks/local_tracks_provider.dart @@ -0,0 +1,126 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:spotube/services/logger/logger.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:metadata_god/metadata_god.dart'; +import 'package:mime/mime.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'package: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; + +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", +}; + +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); + if (!await downloadDir.exists()) { + await downloadDir.create(recursive: true); + } + final localLibraryLocations = ref.watch( + userPreferencesProvider.select((s) => s.localLibraryLocation), + ); + + for (final location in [downloadLocation, ...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) => + e is File && + supportedAudioTypes.contains(lookupMimeType(e.path)), + ) + .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", + 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.whereNotNull().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..b0e95cae --- /dev/null +++ b/lib/provider/logs/logs_provider.dart @@ -0,0 +1,12 @@ +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(); + final stream = file.openRead().transform(utf8.decoder); + await for (final line in stream) { + yield line; + } +}); diff --git a/lib/provider/piped_instances_provider.dart b/lib/provider/piped_instances_provider.dart index d571f730..3c5d5f04 100644 --- a/lib/provider/piped_instances_provider.dart +++ b/lib/provider/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/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..685896ec --- /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]); + 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..e2a579cc --- /dev/null +++ b/lib/provider/server/router.dart @@ -0,0 +1,20 @@ +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.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..0d35b473 --- /dev/null +++ b/lib/provider/server/routes/connect.dart @@ -0,0 +1,203 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf_web_socket/shelf_web_socket.dart'; +import 'package:spotify/spotify.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; + + 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); + + 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) { + 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..30322a6f --- /dev/null +++ b/lib/provider/server/routes/playback.dart @@ -0,0 +1,74 @@ +import 'package:dio/dio.dart' hide Response; +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shelf/shelf.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'; + +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(); + + /// @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); + + ref.read(activeSourcedTrackProvider.notifier).update(sourcedTrack); + + final res = await dio.get( + sourcedTrack!.url, + options: Options( + headers: { + ...request.headers, + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "host": Uri.parse(sourcedTrack.url).host, + "Cache-Control": "max-age=0", + "Connection": "keep-alive", + }, + responseType: ResponseType.stream, + validateStatus: (status) => status! < 500, + ), + ); + + final audioStream = + (res.data?.stream as Stream?)?.asBroadcastStream(); + + audioStream!.listen( + (event) {}, + cancelOnError: true, + ); + + return Response( + res.statusCode!, + body: audioStream, + context: { + "shelf.io.buffer_output": false, + }, + headers: res.headers.map, + ); + } catch (e, stack) { + AppLogger.reportError(e, stack); + return Response.internalServerError(); + } + } +} + +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..131f1ea4 --- /dev/null +++ b/lib/provider/server/server.dart @@ -0,0 +1,34 @@ +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/services/audio_player/audio_player.dart'; +import 'package:spotube/services/logger/logger.dart'; + +final serverProvider = FutureProvider( + (ref) async { + final pipeline = ref.watch(pipelineProvider); + final router = ref.watch(serverRouterProvider); + final port = Random().nextInt(17500) + 5000; + + SpotubeMedia.serverPort = port; + + final server = await serve( + pipeline.addHandler(router.call), + InternetAddress.anyIPv4, + port, + ); + + AppLogger.log + .t('Playback server at http://${server.address.host}:${server.port}'); + + ref.onDispose(() { + server.close(); + }); + + return (server: server, port: port); + }, +); diff --git a/lib/provider/server/sourced_track.dart b/lib/provider/server/sourced_track.dart new file mode 100644 index 00000000..53a04023 --- /dev/null +++ b/lib/provider/server/sourced_track.dart @@ -0,0 +1,28 @@ +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'; + +final sourcedTrackProvider = + FutureProvider.family((ref, 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; +}); 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..cf444d49 --- /dev/null +++ b/lib/provider/spotify/album/favorite.dart @@ -0,0 +1,86 @@ +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) { + return spotify.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.me.saveAlbums(ids); + final albums = await spotify.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.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..987ccdf2 --- /dev/null +++ b/lib/provider/spotify/album/is_saved.dart @@ -0,0 +1,10 @@ +part of '../spotify.dart'; + +final albumsIsSavedProvider = FutureProvider.autoDispose.family( + (ref, albumId) async { + final spotify = ref.watch(spotifyProvider); + return spotify.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..43d2e474 --- /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.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..e39abad5 --- /dev/null +++ b/lib/provider/spotify/album/tracks.dart @@ -0,0 +1,61 @@ +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.albums.tracks(arg.id!).getPage(limit, offset); + final items = tracks.items?.map((e) => e.asTrack(arg)).toList() ?? []; + + 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..f3fb682f --- /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.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..c69badd2 --- /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.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..4e6bcfe8 --- /dev/null +++ b/lib/provider/spotify/artist/following.dart @@ -0,0 +1,104 @@ +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 { + FollowedArtistsNotifier() : super(); + + @override + fetch(offset, limit) async { + final artists = await spotify.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 saveArtists(List artistIds) async { + if (state.value == null) return; + await spotify.me.follow(FollowingType.artist, artistIds); + + state = await AsyncValue.guard(() async { + final artists = await spotify.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.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.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..db1be184 --- /dev/null +++ b/lib/provider/spotify/artist/is_following.dart @@ -0,0 +1,10 @@ +part of '../spotify.dart'; + +final artistIsFollowingProvider = FutureProvider.family( + (ref, String artistId) async { + final spotify = ref.watch(spotifyProvider); + return spotify.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..317feba3 --- /dev/null +++ b/lib/provider/spotify/artist/related.dart @@ -0,0 +1,11 @@ +part of '../spotify.dart'; + +final relatedArtistsProvider = FutureProvider.autoDispose + .family, String>((ref, artistId) async { + ref.cacheFor(); + + final spotify = ref.watch(spotifyProvider); + final artists = await spotify.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..a2862c3d --- /dev/null +++ b/lib/provider/spotify/artist/top_tracks.dart @@ -0,0 +1,14 @@ +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.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..6237b64c --- /dev/null +++ b/lib/provider/spotify/category/categories.dart @@ -0,0 +1,19 @@ +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.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..9f1034be --- /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, + "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?.whereNotNull().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..c6c0d6e3 --- /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.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..000001ad --- /dev/null +++ b/lib/provider/spotify/playlist/favorite.dart @@ -0,0 +1,136 @@ +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.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.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.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.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.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.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..69057e5d --- /dev/null +++ b/lib/provider/spotify/playlist/featured.dart @@ -0,0 +1,58 @@ +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.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..0832003e --- /dev/null +++ b/lib/provider/spotify/playlist/generate.dart @@ -0,0 +1,40 @@ +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.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.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..27c3e2b6 --- /dev/null +++ b/lib/provider/spotify/playlist/liked.dart @@ -0,0 +1,33 @@ +part of '../spotify.dart'; + +class LikedTracksNotifier extends AsyncNotifier> { + @override + FutureOr> build() async { + final spotify = ref.watch(spotifyProvider); + final savedTracked = await spotify.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.tracks.me.removeOne(track.id!); + return tracks.where((e) => e.id != track.id).toList(); + } else { + await spotify.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..0eec3a87 --- /dev/null +++ b/lib/provider/spotify/playlist/playlist.dart @@ -0,0 +1,106 @@ +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.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.playlists.createPlaylist( + me.value!.id!, + input.playlistName, + collaborative: input.collaborative, + description: input.description, + public: input.public, + ); + + if (input.base64Image != null) { + await spotify.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.playlists.updatePlaylist( + state.id!, + input.playlistName, + collaborative: input.collaborative, + description: input.description, + public: input.public, + ); + + if (input.base64Image != null) { + await spotify.playlists.updatePlaylistImage( + state.id!, + input.base64Image!, + ); + + final playlist = await spotify.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; + } + }); + } +} + +final playlistProvider = + AsyncNotifierProvider.family( + () => PlaylistNotifier(), +); diff --git a/lib/provider/spotify/playlist/tracks.dart b/lib/provider/spotify/playlist/tracks.dart new file mode 100644 index 00000000..379ad110 --- /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.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..5bbc02e4 --- /dev/null +++ b/lib/provider/spotify/search/search.dart @@ -0,0 +1,88 @@ +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.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..8cf60120 --- /dev/null +++ b/lib/provider/spotify/spotify.dart @@ -0,0 +1,79 @@ +library spotify; + +import 'dart:async'; + +import 'package:drift/drift.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/provider/spotify/utils/json_cast.dart'; +import 'package:spotube/services/logger/logger.dart'; +import 'package:collection/collection.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_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/spotify_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: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/persistence.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'; diff --git a/lib/provider/spotify/tracks/track.dart b/lib/provider/spotify/tracks/track.dart new file mode 100644 index 00000000..e3913b1f --- /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.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..c5949e1f --- /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.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..0da14c6f --- /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 { + SpotifyApi 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/persistence.dart b/lib/provider/spotify/utils/persistence.dart new file mode 100644 index 00000000..57f41dec --- /dev/null +++ b/lib/provider/spotify/utils/persistence.dart @@ -0,0 +1,40 @@ +part of '../spotify.dart'; + +// ignore: invalid_use_of_internal_member +mixin Persistence on BuildlessAsyncNotifier { + LazyBox get store => Hive.lazyBox("spotube_cache"); + + FutureOr fromJson(Map json); + Map toJson(T data); + + FutureOr onInit() {} + + Future load() async { + final json = await store.get(runtimeType.toString()); + if (json != null || + (json is Map && json.entries.isNotEmpty) || + (json is List && json.isNotEmpty)) { + state = AsyncData( + await fromJson( + castNestedJson(json), + ), + ); + } + + await onInit(); + } + + Future save() async { + await store.put( + runtimeType.toString(), + state.value == null ? null : toJson(state.value as T), + ); + } + + @override + set state(AsyncValue value) { + if (state == value) return; + super.state = value; + save(); + } +} 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..ad6a076a --- /dev/null +++ b/lib/provider/spotify/views/home.dart @@ -0,0 +1,22 @@ +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")), + ); + + if (spTCookie == null) return null; + + 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..5eb9183d --- /dev/null +++ b/lib/provider/spotify/views/home_section.dart @@ -0,0 +1,26 @@ +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")), + ); + + if (spTCookie == null) return null; + + 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 index 2675a9f7..5824cce0 100644 --- a/lib/provider/spotify_provider.dart +++ b/lib/provider/spotify_provider.dart @@ -2,14 +2,14 @@ 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/provider/authentication/authentication.dart'; import 'package:spotube/utils/primitive_utils.dart'; final spotifyProvider = Provider((ref) { - final authState = ref.watch(AuthenticationNotifier.provider); + final authState = ref.watch(authenticationProvider); final anonCred = PrimitiveUtils.getRandomElement(Env.spotifySecrets); - if (authState == null) { + if (authState.asData?.value == null) { return SpotifyApi( SpotifyApiCredentials( anonCred["clientId"], @@ -18,5 +18,5 @@ final spotifyProvider = Provider((ref) { ); } - return SpotifyApi.withAccessToken(authState.accessToken); + return SpotifyApi.withAccessToken(authState.asData!.value!.accessToken.value); }); 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..23479b71 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -1,173 +1,215 @@ -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:hooks_riverpod/hooks_riverpod.dart'; +import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/settings/color_scheme_picker_dialog.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/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/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'; -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 getLibraryDirectory()).path, "Caches"); } return getDownloadsDirectory().then((dir) { - return path.join(dir!.path, "Spotube"); + 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)..where((t) => t.id.equals(0)); + + await query.replace(PreferencesTableCompanion.insert()); } - @override - Map toJson() { - return state.toJson(); + 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 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 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))); } } 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/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/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 04df7111..00000000 --- a/lib/services/audio_player/mk_state_player.dart +++ /dev/null @@ -1,384 +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; - - static const String EXTRA_PACKAGE_NAME = "android.media.extra.PACKAGE_NAME"; - static const String EXTRA_AUDIO_SESSION = "android.media.extra.AUDIO_SESSION"; - static const String ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION = - "android.media.action.OPEN_AUDIO_EFFECT_CONTROL_SESSION"; - static const String ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION = - "android.media.action.CLOSE_AUDIO_EFFECT_CONTROL_SESSION"; - - 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); - - nativePlayer.setProperty( - "audiotrack-session-id", _androidAudioSessionId.toString()); - nativePlayer.setProperty("ao", "audiotrack,opensles,"); - }); - } - } - - Future notifyAudioSessionUpdate(bool active) async { - if (DesktopTools.platform.isAndroid) { - sendBroadcast(BroadcastMessage( - name: active - ? ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION - : ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION, - data: { - EXTRA_AUDIO_SESSION: _androidAudioSessionId, - 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..0b1843c4 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -1,43 +1,45 @@ import 'package:audio_service/audio_service.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:flutter/material.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/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: + kIsLinux ? 'spotube' : 'com.krtirtho.Spotube', 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 +48,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 +69,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..56fe0fc4 100644 --- a/lib/services/audio_services/mobile_audio_service.dart +++ b/lib/services/audio_services/mobile_audio_service.dart @@ -1,19 +1,23 @@ 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/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()); @@ -90,57 +94,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..86765671 100644 --- a/lib/services/connectivity_adapter.dart +++ b/lib/services/connectivity_adapter.dart @@ -2,31 +2,36 @@ import 'dart:async'; import 'dart:io'; 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) { + 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); } }); } @@ -100,15 +105,16 @@ class FlQueryInternetConnectionCheckerAdapter extends ConnectivityAdapter await isVpnActive(); // when VPN is active that means we are connected } - @override + bool isConnectedSync = false; + Future get isConnected async { final connected = await _isConnected(); + isConnectedSync = connected; if (connected != isConnectedSync /*previous value*/) { _connectionStreamController.add(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..3b358366 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 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": "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, { + required 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 6f6807e0..efe83abf 100644 --- a/lib/services/kv_store/kv_store.dart +++ b/lib/services/kv_store/kv_store.dart @@ -1,4 +1,9 @@ +import 'dart:convert'; + +import 'package:encrypt/encrypt.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotube/services/wm_tools/wm_tools.dart'; +import 'package:uuid/uuid.dart'; abstract class KVStoreService { static SharedPreferences? _sharedPreferences; @@ -10,6 +15,76 @@ abstract class KVStoreService { static bool get doneGettingStarted => sharedPreferences.getBool('doneGettingStarted') ?? false; - static set doneGettingStarted(bool value) => - sharedPreferences.setBool('doneGettingStarted', value); + static Future setDoneGettingStarted(bool value) async => + await sharedPreferences.setBool('doneGettingStarted', value); + + static bool get askedForBatteryOptimization => + sharedPreferences.getBool('askedForBatteryOptimization') ?? false; + static Future setAskedForBatteryOptimization(bool value) async => + await sharedPreferences.setBool('askedForBatteryOptimization', value); + + static List get recentSearches => + sharedPreferences.getStringList('recentSearches') ?? []; + + static Future setRecentSearches(List value) async => + await sharedPreferences.setStringList('recentSearches', value); + + static WindowSize? get windowSize { + final raw = sharedPreferences.getString('windowSize'); + + if (raw == null) { + return null; + } + return WindowSize.fromJson(jsonDecode(raw)); + } + + static Future setWindowSize(WindowSize value) async => + await sharedPreferences.setString( + 'windowSize', + jsonEncode( + value.toJson(), + ), + ); + + static String get encryptionKey { + final value = sharedPreferences.getString('encryption'); + + final key = const Uuid().v4(); + if (value == null) { + setEncryptionKey(key); + return key; + } + + return value; + } + + static Future setEncryptionKey(String key) async { + await sharedPreferences.setString('encryption', key); + } + + static IV get ivKey { + final iv = sharedPreferences.getString('iv'); + final value = IV.fromSecureRandom(8); + + if (iv == null) { + setIVKey(value); + + return value; + } + + return IV.fromBase64(iv); + } + + static Future setIVKey(IV iv) async { + await sharedPreferences.setString('iv', iv.base64); + } + + static double get volume => sharedPreferences.getDouble('volume') ?? 1.0; + static Future setVolume(double value) async => + await sharedPreferences.setDouble('volume', value); + + static bool get hasMigratedToDrift => + sharedPreferences.getBool('hasMigratedToDrift') ?? false; + static Future setHasMigratedToDrift(bool value) async => + await sharedPreferences.setBool('hasMigratedToDrift', value); } diff --git a/lib/services/logger/logger.dart b/lib/services/logger/logger.dart new file mode 100644 index 00000000..1df7b5aa --- /dev/null +++ b/lib/services/logger/logger.dart @@ -0,0 +1,124 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +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'; + +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 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, + ); + } + + 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..0a1af8a9 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); 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..5fe136ce 100644 --- a/lib/services/sourced_track/models/source_info.g.dart +++ b/lib/services/sourced_track/models/source_info.g.dart @@ -6,7 +6,7 @@ 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, 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..58dd0280 100644 --- a/lib/services/sourced_track/models/video_info.dart +++ b/lib/services/sourced_track/models/video_info.dart @@ -1,5 +1,6 @@ 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 { diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index c73f3078..977b980b 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -5,8 +5,9 @@ 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'; @@ -127,22 +128,18 @@ abstract class SourcedTrack extends Track { weakMatch: true, ), AudioSource.jiosaavn => - await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref), + await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref), }; } on HttpClientClosedException catch (_) { return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); + } on VideoUnplayableException 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, + weakMatch: preferences.audioSource == AudioSource.jiosaavn, ); } rethrow; 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..d24f110f 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,15 @@ class PipedSourcedTrack extends SourcedTrack { required Track track, required Ref ref, }) 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(); final preferences = ref.read(userPreferencesProvider); final pipedClient = ref.read(pipedProvider); @@ -58,17 +68,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, @@ -163,7 +173,7 @@ class PipedSourcedTrack extends SourcedTrack { final PipedSearchResult(items: searchResults) = await pipedClient.search( query, preference.searchMode == SearchMode.youtube - ? PipedFilter.videos + ? PipedFilter.video : PipedFilter.musicSongs, ); @@ -267,15 +277,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..0b5ee71b 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/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'; @@ -45,7 +48,16 @@ class YoutubeSourcedTrack extends SourcedTrack { required Track track, required Ref ref, }) 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), + ])) + .get() + .then((s) => s.firstOrNull); if (cachedSource == null || cachedSource.sourceType != SourceType.youtube) { final siblings = await fetchSiblings(ref: ref, track: track); @@ -53,15 +65,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, @@ -220,15 +230,23 @@ class YoutubeSourcedTrack extends SourcedTrack { final links = await SongLinkService.links(track.id!); final ytLink = links.firstWhereOrNull((link) => link.platform == "youtube"); - if (ytLink?.url != null) { - return [ - await toSiblingType( - 0, - YoutubeVideoInfo.fromVideo( - await youtubeClient.videos.get(ytLink!.url!), - ), - ) - ]; + if (ytLink?.url != null + // allows to fetch siblings more results for already sourced track + && + track is! SourcedTrack) { + try { + return [ + await toSiblingType( + 0, + YoutubeVideoInfo.fromVideo( + await youtubeClient.videos.get(ytLink!.url!), + ), + ) + ]; + } on VideoUnplayableException catch (e, stack) { + // Ignore this error and continue with the search + AppLogger.reportError(e, stack); + } } final query = SourcedTrack.getSearchTerm(track); @@ -274,15 +292,19 @@ class YoutubeSourcedTrack extends SourcedTrack { onTimeout: () => throw ClientException("Timeout"), ); - 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..920e09b5 --- /dev/null +++ b/lib/services/wm_tools/wm_tools.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.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/themes/theme.dart b/lib/themes/theme.dart index 51e98269..485e5af7 100644 --- a/lib/themes/theme.dart +++ b/lib/themes/theme.dart @@ -4,7 +4,6 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { final scheme = ColorScheme.fromSeed( seedColor: seed, shadow: Colors.black12, - background: isAmoled ? Colors.black : null, surface: isAmoled ? Colors.black : null, brightness: brightness, ); @@ -15,7 +14,12 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { horizontalTitleGap: 5, iconColor: scheme.onSurface, ), - appBarTheme: const AppBarTheme(surfaceTintColor: Colors.transparent), + appBarTheme: const AppBarTheme( + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: 0, + shadowColor: Colors.transparent, + elevation: 0, + ), inputDecorationTheme: InputDecorationTheme( border: OutlineInputBorder( borderRadius: BorderRadius.circular(15), @@ -25,7 +29,7 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { navigationBarTheme: const NavigationBarThemeData( labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, height: 50, - iconTheme: MaterialStatePropertyAll( + iconTheme: WidgetStatePropertyAll( IconThemeData(size: 18), ), ), @@ -43,6 +47,9 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), color: scheme.surface, elevation: 4, + labelTextStyle: WidgetStatePropertyAll( + TextStyle(color: scheme.onSurface), + ), ), snackBarTheme: SnackBarThemeData( behavior: SnackBarBehavior.floating, @@ -52,25 +59,25 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { ), sliderTheme: SliderThemeData(overlayShape: SliderComponentShape.noOverlay), searchBarTheme: SearchBarThemeData( - textStyle: const MaterialStatePropertyAll(TextStyle(fontSize: 15)), + textStyle: const WidgetStatePropertyAll(TextStyle(fontSize: 15)), constraints: const BoxConstraints(maxWidth: double.infinity), - padding: const MaterialStatePropertyAll(EdgeInsets.all(8)), - backgroundColor: MaterialStatePropertyAll( + padding: const WidgetStatePropertyAll(EdgeInsets.all(8)), + backgroundColor: WidgetStatePropertyAll( Color.lerp( - scheme.surfaceVariant, + scheme.surfaceContainerHighest, scheme.surface, brightness == Brightness.light ? .9 : .7, ), ), - elevation: const MaterialStatePropertyAll(0), - shape: MaterialStatePropertyAll( + elevation: const WidgetStatePropertyAll(0), + shape: WidgetStatePropertyAll( RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), ), scrollbarTheme: const ScrollbarThemeData( - thickness: MaterialStatePropertyAll(14), + thickness: WidgetStatePropertyAll(14), ), checkboxTheme: CheckboxThemeData( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), diff --git a/lib/utils/duration.dart b/lib/utils/duration.dart index 35678a96..1869cea1 100644 --- a/lib/utils/duration.dart +++ b/lib/utils/duration.dart @@ -1,4 +1,4 @@ -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/services/logger/logger.dart'; /// Parses duration string formatted by Duration.toString() to [Duration]. /// The string should be of form hours:minutes:seconds.microseconds @@ -37,8 +37,6 @@ Duration parseDuration(String input) { days = p ~/ 24; } - // TODO verify that there are no negative parts - return Duration( days: days, hours: hours, @@ -53,7 +51,7 @@ Duration? tryParseDuration(String input) { try { return parseDuration(input); } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); + AppLogger.reportError(e, stack); return null; } } diff --git a/lib/utils/migrations/adapters.dart b/lib/utils/migrations/adapters.dart new file mode 100644 index 00000000..f7f6350b --- /dev/null +++ b/lib/utils/migrations/adapters.dart @@ -0,0 +1,320 @@ +import 'package:hive/hive.dart'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; + +part 'adapters.g.dart'; +part 'adapters.freezed.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, + }; +} + +@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); +} + +@JsonSerializable() +class AuthenticationCredentials { + String cookie; + String accessToken; + DateTime expiration; + + AuthenticationCredentials({ + required this.cookie, + required this.accessToken, + required this.expiration, + }); + + 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(), + }; + } +} + +@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(false) bool showSystemTrayIcon, + @Default(false) bool skipNonMusic, + @Default(false) bool systemTitleBar, + @Default(CloseBehavior.close) 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([]) List localLibraryLocation, + @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, + @Default(false) bool enableConnect, + }) = _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?; + } +} + +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.fromJson(Map json) + : id = json['id'], + name = json['name'], + type = BlacklistedType.fromName(json['type']); + + Map toJson() => {'id': id, 'type': type.name, 'name': name}; +} + +@freezed +class PlaybackHistoryItem with _$PlaybackHistoryItem { + factory PlaybackHistoryItem.playlist({ + required DateTime date, + required PlaylistSimple playlist, + }) = PlaybackHistoryPlaylist; + + factory PlaybackHistoryItem.album({ + required DateTime date, + required AlbumSimple album, + }) = PlaybackHistoryAlbum; + + factory PlaybackHistoryItem.track({ + required DateTime date, + required Track track, + }) = PlaybackHistoryTrack; + + factory PlaybackHistoryItem.fromJson(Map json) => + _$PlaybackHistoryItemFromJson(json); +} + +class PlaybackHistoryState { + final List items; + const PlaybackHistoryState({this.items = const []}); + + factory PlaybackHistoryState.fromJson(Map json) { + return PlaybackHistoryState( + items: json["items"] + ?.map( + (json) => PlaybackHistoryItem.fromJson(json), + ) + .toList() + .cast() ?? + [], + ); + } +} + +class ScrobblerState { + final String username; + final String passwordHash; + + ScrobblerState({ + required this.username, + required this.passwordHash, + }); + + factory ScrobblerState.fromJson(Map json) { + return ScrobblerState( + username: json["username"], + passwordHash: json["passwordHash"], + ); + } +} diff --git a/lib/provider/user_preferences/user_preferences_state.freezed.dart b/lib/utils/migrations/adapters.freezed.dart similarity index 53% rename from lib/provider/user_preferences/user_preferences_state.freezed.dart rename to lib/utils/migrations/adapters.freezed.dart index 4d08d1a9..339ec0e5 100644 --- a/lib/provider/user_preferences/user_preferences_state.freezed.dart +++ b/lib/utils/migrations/adapters.freezed.dart @@ -3,7 +3,7 @@ // 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'; +part of 'adapters.dart'; // ************************************************************************** // FreezedGenerator @@ -12,7 +12,7 @@ part of 'user_preferences_state.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'); UserPreferences _$UserPreferencesFromJson(Map json) { return _UserPreferences.fromJson(json); @@ -43,6 +43,7 @@ mixin _$UserPreferences { Market get recommendationMarket => throw _privateConstructorUsedError; SearchMode get searchMode => throw _privateConstructorUsedError; String get downloadLocation => throw _privateConstructorUsedError; + List get localLibraryLocation => throw _privateConstructorUsedError; String get pipedInstance => throw _privateConstructorUsedError; ThemeMode get themeMode => throw _privateConstructorUsedError; AudioSource get audioSource => throw _privateConstructorUsedError; @@ -50,6 +51,7 @@ mixin _$UserPreferences { SourceCodecs get downloadMusicCodec => throw _privateConstructorUsedError; bool get discordPresence => throw _privateConstructorUsedError; bool get endlessPlayback => throw _privateConstructorUsedError; + bool get enableConnect => throw _privateConstructorUsedError; Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -87,13 +89,15 @@ abstract class $UserPreferencesCopyWith<$Res> { Market recommendationMarket, SearchMode searchMode, String downloadLocation, + List localLibraryLocation, String pipedInstance, ThemeMode themeMode, AudioSource audioSource, SourceCodecs streamMusicCodec, SourceCodecs downloadMusicCodec, bool discordPresence, - bool endlessPlayback}); + bool endlessPlayback, + bool enableConnect}); } /// @nodoc @@ -124,6 +128,7 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> Object? recommendationMarket = null, Object? searchMode = null, Object? downloadLocation = null, + Object? localLibraryLocation = null, Object? pipedInstance = null, Object? themeMode = null, Object? audioSource = null, @@ -131,6 +136,7 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> Object? downloadMusicCodec = null, Object? discordPresence = null, Object? endlessPlayback = null, + Object? enableConnect = null, }) { return _then(_value.copyWith( audioQuality: null == audioQuality @@ -193,6 +199,10 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> ? _value.downloadLocation : downloadLocation // ignore: cast_nullable_to_non_nullable as String, + localLibraryLocation: null == localLibraryLocation + ? _value.localLibraryLocation + : localLibraryLocation // ignore: cast_nullable_to_non_nullable + as List, pipedInstance: null == pipedInstance ? _value.pipedInstance : pipedInstance // ignore: cast_nullable_to_non_nullable @@ -221,6 +231,10 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> ? _value.endlessPlayback : endlessPlayback // ignore: cast_nullable_to_non_nullable as bool, + enableConnect: null == enableConnect + ? _value.enableConnect + : enableConnect // ignore: cast_nullable_to_non_nullable + as bool, ) as $Val); } } @@ -257,13 +271,15 @@ abstract class _$$UserPreferencesImplCopyWith<$Res> Market recommendationMarket, SearchMode searchMode, String downloadLocation, + List localLibraryLocation, String pipedInstance, ThemeMode themeMode, AudioSource audioSource, SourceCodecs streamMusicCodec, SourceCodecs downloadMusicCodec, bool discordPresence, - bool endlessPlayback}); + bool endlessPlayback, + bool enableConnect}); } /// @nodoc @@ -292,6 +308,7 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> Object? recommendationMarket = null, Object? searchMode = null, Object? downloadLocation = null, + Object? localLibraryLocation = null, Object? pipedInstance = null, Object? themeMode = null, Object? audioSource = null, @@ -299,6 +316,7 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> Object? downloadMusicCodec = null, Object? discordPresence = null, Object? endlessPlayback = null, + Object? enableConnect = null, }) { return _then(_$UserPreferencesImpl( audioQuality: null == audioQuality @@ -361,6 +379,10 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> ? _value.downloadLocation : downloadLocation // ignore: cast_nullable_to_non_nullable as String, + localLibraryLocation: null == localLibraryLocation + ? _value._localLibraryLocation + : localLibraryLocation // ignore: cast_nullable_to_non_nullable + as List, pipedInstance: null == pipedInstance ? _value.pipedInstance : pipedInstance // ignore: cast_nullable_to_non_nullable @@ -389,6 +411,10 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> ? _value.endlessPlayback : endlessPlayback // ignore: cast_nullable_to_non_nullable as bool, + enableConnect: null == enableConnect + ? _value.enableConnect + : enableConnect // ignore: cast_nullable_to_non_nullable + as bool, )); } } @@ -402,10 +428,10 @@ class _$UserPreferencesImpl implements _UserPreferences { this.amoledDarkTheme = false, this.checkUpdate = true, this.normalizeAudio = false, - this.showSystemTrayIcon = true, + this.showSystemTrayIcon = false, this.skipNonMusic = false, this.systemTitleBar = false, - this.closeBehavior = CloseBehavior.minimizeToTray, + this.closeBehavior = CloseBehavior.close, @JsonKey( fromJson: UserPreferences._accentColorSchemeFromJson, toJson: UserPreferences._accentColorSchemeToJson, @@ -420,13 +446,16 @@ class _$UserPreferencesImpl implements _UserPreferences { this.recommendationMarket = Market.US, this.searchMode = SearchMode.youtube, this.downloadLocation = "", + final List localLibraryLocation = const [], 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}); + this.endlessPlayback = true, + this.enableConnect = false}) + : _localLibraryLocation = localLibraryLocation; factory _$UserPreferencesImpl.fromJson(Map json) => _$$UserPreferencesImplFromJson(json); @@ -482,6 +511,16 @@ class _$UserPreferencesImpl implements _UserPreferences { @override @JsonKey() final String downloadLocation; + final List _localLibraryLocation; + @override + @JsonKey() + List get localLibraryLocation { + if (_localLibraryLocation is EqualUnmodifiableListView) + return _localLibraryLocation; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_localLibraryLocation); + } + @override @JsonKey() final String pipedInstance; @@ -503,10 +542,13 @@ class _$UserPreferencesImpl implements _UserPreferences { @override @JsonKey() final bool endlessPlayback; + @override + @JsonKey() + final bool enableConnect; @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)'; + 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, localLibraryLocation: $localLibraryLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)'; } @override @@ -543,6 +585,8 @@ class _$UserPreferencesImpl implements _UserPreferences { other.searchMode == searchMode) && (identical(other.downloadLocation, downloadLocation) || other.downloadLocation == downloadLocation) && + const DeepCollectionEquality() + .equals(other._localLibraryLocation, _localLibraryLocation) && (identical(other.pipedInstance, pipedInstance) || other.pipedInstance == pipedInstance) && (identical(other.themeMode, themeMode) || @@ -556,7 +600,9 @@ class _$UserPreferencesImpl implements _UserPreferences { (identical(other.discordPresence, discordPresence) || other.discordPresence == discordPresence) && (identical(other.endlessPlayback, endlessPlayback) || - other.endlessPlayback == endlessPlayback)); + other.endlessPlayback == endlessPlayback) && + (identical(other.enableConnect, enableConnect) || + other.enableConnect == enableConnect)); } @JsonKey(ignore: true) @@ -578,13 +624,15 @@ class _$UserPreferencesImpl implements _UserPreferences { recommendationMarket, searchMode, downloadLocation, + const DeepCollectionEquality().hash(_localLibraryLocation), pipedInstance, themeMode, audioSource, streamMusicCodec, downloadMusicCodec, discordPresence, - endlessPlayback + endlessPlayback, + enableConnect ]); @JsonKey(ignore: true) @@ -627,13 +675,15 @@ abstract class _UserPreferences implements UserPreferences { final Market recommendationMarket, final SearchMode searchMode, final String downloadLocation, + final List localLibraryLocation, final String pipedInstance, final ThemeMode themeMode, final AudioSource audioSource, final SourceCodecs streamMusicCodec, final SourceCodecs downloadMusicCodec, final bool discordPresence, - final bool endlessPlayback}) = _$UserPreferencesImpl; + final bool endlessPlayback, + final bool enableConnect}) = _$UserPreferencesImpl; factory _UserPreferences.fromJson(Map json) = _$UserPreferencesImpl.fromJson; @@ -677,6 +727,8 @@ abstract class _UserPreferences implements UserPreferences { @override String get downloadLocation; @override + List get localLibraryLocation; + @override String get pipedInstance; @override ThemeMode get themeMode; @@ -691,7 +743,638 @@ abstract class _UserPreferences implements UserPreferences { @override bool get endlessPlayback; @override + bool get enableConnect; + @override @JsonKey(ignore: true) _$$UserPreferencesImplCopyWith<_$UserPreferencesImpl> get copyWith => throw _privateConstructorUsedError; } + +PlaybackHistoryItem _$PlaybackHistoryItemFromJson(Map json) { + switch (json['runtimeType']) { + case 'playlist': + return PlaybackHistoryPlaylist.fromJson(json); + case 'album': + return PlaybackHistoryAlbum.fromJson(json); + case 'track': + return PlaybackHistoryTrack.fromJson(json); + + default: + throw CheckedFromJsonException(json, 'runtimeType', 'PlaybackHistoryItem', + 'Invalid union type "${json['runtimeType']}"!'); + } +} + +/// @nodoc +mixin _$PlaybackHistoryItem { + DateTime get date => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, Track track) track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, Track track)? track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, Track track)? track, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $PlaybackHistoryItemCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $PlaybackHistoryItemCopyWith<$Res> { + factory $PlaybackHistoryItemCopyWith( + PlaybackHistoryItem value, $Res Function(PlaybackHistoryItem) then) = + _$PlaybackHistoryItemCopyWithImpl<$Res, PlaybackHistoryItem>; + @useResult + $Res call({DateTime date}); +} + +/// @nodoc +class _$PlaybackHistoryItemCopyWithImpl<$Res, $Val extends PlaybackHistoryItem> + implements $PlaybackHistoryItemCopyWith<$Res> { + _$PlaybackHistoryItemCopyWithImpl(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? date = null, + }) { + return _then(_value.copyWith( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$PlaybackHistoryPlaylistImplCopyWith<$Res> + implements $PlaybackHistoryItemCopyWith<$Res> { + factory _$$PlaybackHistoryPlaylistImplCopyWith( + _$PlaybackHistoryPlaylistImpl value, + $Res Function(_$PlaybackHistoryPlaylistImpl) then) = + __$$PlaybackHistoryPlaylistImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({DateTime date, PlaylistSimple playlist}); +} + +/// @nodoc +class __$$PlaybackHistoryPlaylistImplCopyWithImpl<$Res> + extends _$PlaybackHistoryItemCopyWithImpl<$Res, + _$PlaybackHistoryPlaylistImpl> + implements _$$PlaybackHistoryPlaylistImplCopyWith<$Res> { + __$$PlaybackHistoryPlaylistImplCopyWithImpl( + _$PlaybackHistoryPlaylistImpl _value, + $Res Function(_$PlaybackHistoryPlaylistImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? date = null, + Object? playlist = null, + }) { + return _then(_$PlaybackHistoryPlaylistImpl( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + playlist: null == playlist + ? _value.playlist + : playlist // ignore: cast_nullable_to_non_nullable + as PlaylistSimple, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$PlaybackHistoryPlaylistImpl implements PlaybackHistoryPlaylist { + _$PlaybackHistoryPlaylistImpl( + {required this.date, required this.playlist, final String? $type}) + : $type = $type ?? 'playlist'; + + factory _$PlaybackHistoryPlaylistImpl.fromJson(Map json) => + _$$PlaybackHistoryPlaylistImplFromJson(json); + + @override + final DateTime date; + @override + final PlaylistSimple playlist; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'PlaybackHistoryItem.playlist(date: $date, playlist: $playlist)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PlaybackHistoryPlaylistImpl && + (identical(other.date, date) || other.date == date) && + (identical(other.playlist, playlist) || + other.playlist == playlist)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, date, playlist); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$PlaybackHistoryPlaylistImplCopyWith<_$PlaybackHistoryPlaylistImpl> + get copyWith => __$$PlaybackHistoryPlaylistImplCopyWithImpl< + _$PlaybackHistoryPlaylistImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, Track track) track, + }) { + return playlist(date, this.playlist); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, Track track)? track, + }) { + return playlist?.call(date, this.playlist); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, Track track)? track, + required TResult orElse(), + }) { + if (playlist != null) { + return playlist(date, this.playlist); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) { + return playlist(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) { + return playlist?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) { + if (playlist != null) { + return playlist(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$PlaybackHistoryPlaylistImplToJson( + this, + ); + } +} + +abstract class PlaybackHistoryPlaylist implements PlaybackHistoryItem { + factory PlaybackHistoryPlaylist( + {required final DateTime date, + required final PlaylistSimple playlist}) = _$PlaybackHistoryPlaylistImpl; + + factory PlaybackHistoryPlaylist.fromJson(Map json) = + _$PlaybackHistoryPlaylistImpl.fromJson; + + @override + DateTime get date; + PlaylistSimple get playlist; + @override + @JsonKey(ignore: true) + _$$PlaybackHistoryPlaylistImplCopyWith<_$PlaybackHistoryPlaylistImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$PlaybackHistoryAlbumImplCopyWith<$Res> + implements $PlaybackHistoryItemCopyWith<$Res> { + factory _$$PlaybackHistoryAlbumImplCopyWith(_$PlaybackHistoryAlbumImpl value, + $Res Function(_$PlaybackHistoryAlbumImpl) then) = + __$$PlaybackHistoryAlbumImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({DateTime date, AlbumSimple album}); +} + +/// @nodoc +class __$$PlaybackHistoryAlbumImplCopyWithImpl<$Res> + extends _$PlaybackHistoryItemCopyWithImpl<$Res, _$PlaybackHistoryAlbumImpl> + implements _$$PlaybackHistoryAlbumImplCopyWith<$Res> { + __$$PlaybackHistoryAlbumImplCopyWithImpl(_$PlaybackHistoryAlbumImpl _value, + $Res Function(_$PlaybackHistoryAlbumImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? date = null, + Object? album = null, + }) { + return _then(_$PlaybackHistoryAlbumImpl( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + album: null == album + ? _value.album + : album // ignore: cast_nullable_to_non_nullable + as AlbumSimple, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$PlaybackHistoryAlbumImpl implements PlaybackHistoryAlbum { + _$PlaybackHistoryAlbumImpl( + {required this.date, required this.album, final String? $type}) + : $type = $type ?? 'album'; + + factory _$PlaybackHistoryAlbumImpl.fromJson(Map json) => + _$$PlaybackHistoryAlbumImplFromJson(json); + + @override + final DateTime date; + @override + final AlbumSimple album; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'PlaybackHistoryItem.album(date: $date, album: $album)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PlaybackHistoryAlbumImpl && + (identical(other.date, date) || other.date == date) && + (identical(other.album, album) || other.album == album)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, date, album); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$PlaybackHistoryAlbumImplCopyWith<_$PlaybackHistoryAlbumImpl> + get copyWith => + __$$PlaybackHistoryAlbumImplCopyWithImpl<_$PlaybackHistoryAlbumImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, Track track) track, + }) { + return album(date, this.album); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, Track track)? track, + }) { + return album?.call(date, this.album); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, Track track)? track, + required TResult orElse(), + }) { + if (album != null) { + return album(date, this.album); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) { + return album(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) { + return album?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) { + if (album != null) { + return album(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$PlaybackHistoryAlbumImplToJson( + this, + ); + } +} + +abstract class PlaybackHistoryAlbum implements PlaybackHistoryItem { + factory PlaybackHistoryAlbum( + {required final DateTime date, + required final AlbumSimple album}) = _$PlaybackHistoryAlbumImpl; + + factory PlaybackHistoryAlbum.fromJson(Map json) = + _$PlaybackHistoryAlbumImpl.fromJson; + + @override + DateTime get date; + AlbumSimple get album; + @override + @JsonKey(ignore: true) + _$$PlaybackHistoryAlbumImplCopyWith<_$PlaybackHistoryAlbumImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$PlaybackHistoryTrackImplCopyWith<$Res> + implements $PlaybackHistoryItemCopyWith<$Res> { + factory _$$PlaybackHistoryTrackImplCopyWith(_$PlaybackHistoryTrackImpl value, + $Res Function(_$PlaybackHistoryTrackImpl) then) = + __$$PlaybackHistoryTrackImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({DateTime date, Track track}); +} + +/// @nodoc +class __$$PlaybackHistoryTrackImplCopyWithImpl<$Res> + extends _$PlaybackHistoryItemCopyWithImpl<$Res, _$PlaybackHistoryTrackImpl> + implements _$$PlaybackHistoryTrackImplCopyWith<$Res> { + __$$PlaybackHistoryTrackImplCopyWithImpl(_$PlaybackHistoryTrackImpl _value, + $Res Function(_$PlaybackHistoryTrackImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? date = null, + Object? track = null, + }) { + return _then(_$PlaybackHistoryTrackImpl( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + track: null == track + ? _value.track + : track // ignore: cast_nullable_to_non_nullable + as Track, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$PlaybackHistoryTrackImpl implements PlaybackHistoryTrack { + _$PlaybackHistoryTrackImpl( + {required this.date, required this.track, final String? $type}) + : $type = $type ?? 'track'; + + factory _$PlaybackHistoryTrackImpl.fromJson(Map json) => + _$$PlaybackHistoryTrackImplFromJson(json); + + @override + final DateTime date; + @override + final Track track; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'PlaybackHistoryItem.track(date: $date, track: $track)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PlaybackHistoryTrackImpl && + (identical(other.date, date) || other.date == date) && + (identical(other.track, track) || other.track == track)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, date, track); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$PlaybackHistoryTrackImplCopyWith<_$PlaybackHistoryTrackImpl> + get copyWith => + __$$PlaybackHistoryTrackImplCopyWithImpl<_$PlaybackHistoryTrackImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, Track track) track, + }) { + return track(date, this.track); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, Track track)? track, + }) { + return track?.call(date, this.track); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, Track track)? track, + required TResult orElse(), + }) { + if (track != null) { + return track(date, this.track); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) { + return track(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) { + return track?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) { + if (track != null) { + return track(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$PlaybackHistoryTrackImplToJson( + this, + ); + } +} + +abstract class PlaybackHistoryTrack implements PlaybackHistoryItem { + factory PlaybackHistoryTrack( + {required final DateTime date, + required final Track track}) = _$PlaybackHistoryTrackImpl; + + factory PlaybackHistoryTrack.fromJson(Map json) = + _$PlaybackHistoryTrackImpl.fromJson; + + @override + DateTime get date; + Track get track; + @override + @JsonKey(ignore: true) + _$$PlaybackHistoryTrackImplCopyWith<_$PlaybackHistoryTrackImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/provider/user_preferences/user_preferences_state.g.dart b/lib/utils/migrations/adapters.g.dart similarity index 60% rename from lib/provider/user_preferences/user_preferences_state.g.dart rename to lib/utils/migrations/adapters.g.dart index ce488247..ca95a840 100644 --- a/lib/provider/user_preferences/user_preferences_state.g.dart +++ b/lib/utils/migrations/adapters.g.dart @@ -1,13 +1,176 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'user_preferences_state.dart'; +part of 'adapters.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; +} + +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 // ************************************************************************** -_$UserPreferencesImpl _$$UserPreferencesImplFromJson( - Map json) => +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', +}; + +AuthenticationCredentials _$AuthenticationCredentialsFromJson(Map json) => + AuthenticationCredentials( + cookie: json['cookie'] as String, + accessToken: json['accessToken'] as String, + expiration: DateTime.parse(json['expiration'] as String), + ); + +Map _$AuthenticationCredentialsToJson( + AuthenticationCredentials instance) => + { + 'cookie': instance.cookie, + 'accessToken': instance.accessToken, + 'expiration': instance.expiration.toIso8601String(), + }; + +_$UserPreferencesImpl _$$UserPreferencesImplFromJson(Map json) => _$UserPreferencesImpl( audioQuality: $enumDecodeNullable(_$SourceQualitiesEnumMap, json['audioQuality']) ?? @@ -16,12 +179,12 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson( amoledDarkTheme: json['amoledDarkTheme'] as bool? ?? false, checkUpdate: json['checkUpdate'] as bool? ?? true, normalizeAudio: json['normalizeAudio'] as bool? ?? false, - showSystemTrayIcon: json['showSystemTrayIcon'] as bool? ?? true, + showSystemTrayIcon: json['showSystemTrayIcon'] as bool? ?? false, skipNonMusic: json['skipNonMusic'] as bool? ?? false, systemTitleBar: json['systemTitleBar'] as bool? ?? false, closeBehavior: $enumDecodeNullable(_$CloseBehaviorEnumMap, json['closeBehavior']) ?? - CloseBehavior.minimizeToTray, + CloseBehavior.close, accentColorScheme: UserPreferences._accentColorSchemeReadValue( json, 'accentColorScheme') == null @@ -44,6 +207,10 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson( $enumDecodeNullable(_$SearchModeEnumMap, json['searchMode']) ?? SearchMode.youtube, downloadLocation: json['downloadLocation'] as String? ?? "", + localLibraryLocation: (json['localLibraryLocation'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], pipedInstance: json['pipedInstance'] as String? ?? "https://pipedapi.kavin.rocks", themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']) ?? @@ -59,6 +226,7 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson( SourceCodecs.m4a, discordPresence: json['discordPresence'] as bool? ?? true, endlessPlayback: json['endlessPlayback'] as bool? ?? true, + enableConnect: json['enableConnect'] as bool? ?? false, ); Map _$$UserPreferencesImplToJson( @@ -80,6 +248,7 @@ Map _$$UserPreferencesImplToJson( 'recommendationMarket': _$MarketEnumMap[instance.recommendationMarket]!, 'searchMode': _$SearchModeEnumMap[instance.searchMode]!, 'downloadLocation': instance.downloadLocation, + 'localLibraryLocation': instance.localLibraryLocation, 'pipedInstance': instance.pipedInstance, 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, 'audioSource': _$AudioSourceEnumMap[instance.audioSource]!, @@ -87,6 +256,7 @@ Map _$$UserPreferencesImplToJson( 'downloadMusicCodec': _$SourceCodecsEnumMap[instance.downloadMusicCodec]!, 'discordPresence': instance.discordPresence, 'endlessPlayback': instance.endlessPlayback, + 'enableConnect': instance.enableConnect, }; const _$SourceQualitiesEnumMap = { @@ -380,3 +550,51 @@ const _$SourceCodecsEnumMap = { SourceCodecs.m4a: 'm4a', SourceCodecs.weba: 'weba', }; + +_$PlaybackHistoryPlaylistImpl _$$PlaybackHistoryPlaylistImplFromJson( + Map json) => + _$PlaybackHistoryPlaylistImpl( + date: DateTime.parse(json['date'] as String), + playlist: PlaylistSimple.fromJson( + Map.from(json['playlist'] as Map)), + $type: json['runtimeType'] as String?, + ); + +Map _$$PlaybackHistoryPlaylistImplToJson( + _$PlaybackHistoryPlaylistImpl instance) => + { + 'date': instance.date.toIso8601String(), + 'playlist': instance.playlist.toJson(), + 'runtimeType': instance.$type, + }; + +_$PlaybackHistoryAlbumImpl _$$PlaybackHistoryAlbumImplFromJson(Map json) => + _$PlaybackHistoryAlbumImpl( + date: DateTime.parse(json['date'] as String), + album: + AlbumSimple.fromJson(Map.from(json['album'] as Map)), + $type: json['runtimeType'] as String?, + ); + +Map _$$PlaybackHistoryAlbumImplToJson( + _$PlaybackHistoryAlbumImpl instance) => + { + 'date': instance.date.toIso8601String(), + 'album': instance.album.toJson(), + 'runtimeType': instance.$type, + }; + +_$PlaybackHistoryTrackImpl _$$PlaybackHistoryTrackImplFromJson(Map json) => + _$PlaybackHistoryTrackImpl( + date: DateTime.parse(json['date'] as String), + track: Track.fromJson(Map.from(json['track'] as Map)), + $type: json['runtimeType'] as String?, + ); + +Map _$$PlaybackHistoryTrackImplToJson( + _$PlaybackHistoryTrackImpl instance) => + { + 'date': instance.date.toIso8601String(), + 'track': instance.track.toJson(), + 'runtimeType': instance.$type, + }; diff --git a/lib/utils/persisted_state_notifier.dart b/lib/utils/migrations/cache_box.dart similarity index 52% rename from lib/utils/persisted_state_notifier.dart rename to lib/utils/migrations/cache_box.dart index 60f7b96e..dfe1947b 100644 --- a/lib/utils/persisted_state_notifier.dart +++ b/lib/utils/migrations/cache_box.dart @@ -1,61 +1,31 @@ -import 'dart:async'; import 'dart:convert'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:hive/hive.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; -import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/spotify/utils/json_cast.dart'; +import 'package:spotube/services/kv_store/encrypted_kv_store.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/primitive_utils.dart'; -const secureStorage = FlutterSecureStorage( - aOptions: AndroidOptions( - encryptedSharedPreferences: true, - ), -); - const kKeyBoxName = "spotube_box_name"; const kNoEncryptionWarningShownKey = "showedNoEncryptionWarning"; const kIsUsingEncryption = "isUsingEncryption"; String getBoxKey(String boxName) => "spotube_box_$boxName"; -abstract class PersistedStateNotifier extends StateNotifier { - final String cacheKey; - final bool encrypted; - - FutureOr onInit() {} - - PersistedStateNotifier( - super.state, - this.cacheKey, { - this.encrypted = false, - }) { - _load().then((_) => onInit()); - } - +class PersistenceCacheBox { static late LazyBox _box; static late LazyBox _encryptedBox; - static Future showNoEncryptionDialog(BuildContext context) async { - final localStorage = await SharedPreferences.getInstance(); - final wasShownAlready = - localStorage.getBool(kNoEncryptionWarningShownKey) == true; + final String cacheKey; + final bool encrypted; - if (wasShownAlready || !context.mounted) { - return; - } + final T Function(Map) fromJson; - await showPromptDialog( - context: context, - title: context.l10n.failed_to_encrypt, - message: context.l10n.encryption_failed_warning, - cancelText: null, - ); - await localStorage.setBool(kNoEncryptionWarningShownKey, true); - } + PersistenceCacheBox( + this.cacheKey, { + required this.fromJson, + this.encrypted = false, + }); static Future read(String key) async { final localStorage = await SharedPreferences.getInstance(); @@ -65,7 +35,7 @@ abstract class PersistedStateNotifier extends StateNotifier { try { await localStorage.setBool(kIsUsingEncryption, true); - return await secureStorage.read(key: key); + return await EncryptedKvStoreService.storage.read(key: key); } catch (e) { await localStorage.setBool(kIsUsingEncryption, false); return localStorage.getString(key); @@ -81,7 +51,7 @@ abstract class PersistedStateNotifier extends StateNotifier { try { await localStorage.setBool(kIsUsingEncryption, true); - await secureStorage.write(key: key, value: value); + await EncryptedKvStoreService.storage.write(key: key, value: value); } catch (e) { await localStorage.setBool(kIsUsingEncryption, false); await localStorage.setString(key, value); @@ -116,49 +86,15 @@ abstract class PersistedStateNotifier extends StateNotifier { LazyBox get box => encrypted ? _encryptedBox : _box; - Future _load() async { + Future getData() async { final json = await box.get(cacheKey); if (json != null || (json is Map && json.entries.isNotEmpty) || (json is List && json.isNotEmpty)) { - state = await fromJson(castNestedJson(json)); + return fromJson(castNestedJson(json)); } - } - 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); - }), - ); - } - - void save() async { - await box.put(cacheKey, toJson()); - } - - FutureOr fromJson(Map json); - Map toJson(); - - @override - set state(T value) { - if (state == value) return; - super.state = value; - save(); + return null; } } diff --git a/lib/utils/migrations/hive.dart b/lib/utils/migrations/hive.dart new file mode 100644 index 00000000..e5781931 --- /dev/null +++ b/lib/utils/migrations/hive.dart @@ -0,0 +1,319 @@ +import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hive/hive.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotube/models/database/database.dart' + hide + SourceType, + AudioSource, + CloseBehavior, + MusicCodec, + LayoutMode, + SearchMode, + BlacklistedType; +import 'package:spotube/models/database/database.dart' as db; +import 'package:spotube/services/kv_store/kv_store.dart'; +import 'package:spotube/services/logger/logger.dart'; +import 'package:spotube/utils/migrations/adapters.dart'; +import 'package:spotube/utils/migrations/cache_box.dart'; + +late AppDatabase _database; + +Future getHiveCacheDir() async => + kIsWeb ? null : (await getApplicationSupportDirectory()).path; + +Future migrateAuthenticationInfo() async { + AppLogger.log.i("🔵 Migrating authentication info.."); + + final box = PersistenceCacheBox( + "authentication", + encrypted: true, + fromJson: (json) => AuthenticationCredentials.fromJson(json), + ); + + final credentials = await box.getData(); + + if (credentials == null) return; + + await _database.into(_database.authenticationTable).insert( + AuthenticationTableCompanion.insert( + accessToken: DecryptedText(credentials.accessToken), + cookie: DecryptedText(credentials.cookie), + expiration: credentials.expiration, + id: const Value(0), + ), + mode: InsertMode.insertOrReplace, + ); + + AppLogger.log.i("✅ Migrated authentication info"); +} + +Future migratePreferences() async { + AppLogger.log.i("🔵 Migrating preferences.."); + final box = PersistenceCacheBox( + "preferences", + fromJson: (json) => UserPreferences.fromJson(json), + ); + + final preferences = await box.getData(); + + if (preferences == null) return; + + await _database.into(_database.preferencesTable).insert( + PreferencesTableCompanion.insert( + id: const Value(0), + accentColorScheme: Value(preferences.accentColorScheme), + albumColorSync: Value(preferences.albumColorSync), + amoledDarkTheme: Value(preferences.amoledDarkTheme), + audioQuality: Value(preferences.audioQuality), + audioSource: Value( + switch (preferences.audioSource) { + AudioSource.youtube => db.AudioSource.youtube, + AudioSource.piped => db.AudioSource.piped, + AudioSource.jiosaavn => db.AudioSource.jiosaavn, + }, + ), + checkUpdate: Value(preferences.checkUpdate), + closeBehavior: Value( + switch (preferences.closeBehavior) { + CloseBehavior.minimizeToTray => db.CloseBehavior.minimizeToTray, + CloseBehavior.close => db.CloseBehavior.close, + }, + ), + discordPresence: Value(preferences.discordPresence), + downloadLocation: Value(preferences.downloadLocation), + downloadMusicCodec: Value(preferences.downloadMusicCodec), + enableConnect: Value(preferences.enableConnect), + endlessPlayback: Value(preferences.endlessPlayback), + layoutMode: Value( + switch (preferences.layoutMode) { + LayoutMode.adaptive => db.LayoutMode.adaptive, + LayoutMode.compact => db.LayoutMode.compact, + LayoutMode.extended => db.LayoutMode.extended, + }, + ), + localLibraryLocation: Value(preferences.localLibraryLocation), + locale: Value(preferences.locale), + market: Value(preferences.recommendationMarket), + normalizeAudio: Value(preferences.normalizeAudio), + pipedInstance: Value(preferences.pipedInstance), + searchMode: Value( + switch (preferences.searchMode) { + SearchMode.youtube => db.SearchMode.youtube, + SearchMode.youtubeMusic => db.SearchMode.youtubeMusic, + }, + ), + showSystemTrayIcon: Value(preferences.showSystemTrayIcon), + skipNonMusic: Value(preferences.skipNonMusic), + streamMusicCodec: Value(preferences.streamMusicCodec), + systemTitleBar: Value(preferences.systemTitleBar), + themeMode: Value(preferences.themeMode), + ), + mode: InsertMode.replace, + ); + + AppLogger.log.i("✅ Migrated preferences"); +} + +Future migrateSkipSegment() async { + AppLogger.log.i("🔵 Migrating skip segments.."); + Hive.registerAdapter(SkipSegmentAdapter()); + + final box = await Hive.openLazyBox( + SkipSegment.boxName, + path: await getHiveCacheDir(), + ); + + final skipSegments = await Future.wait( + box.keys.map( + (key) async => ( + id: key as String, + data: await box.get(key), + ), + ), + ); + + await _database.batch((batch) { + batch.insertAll( + _database.skipSegmentTable, + skipSegments + .where((element) => element.data != null) + .expand((element) => (element.data as List).map( + (segment) => SkipSegmentTableCompanion.insert( + trackId: element.id, + start: segment["start"], + end: segment["end"], + ), + )) + .toList(), + ); + }); + + AppLogger.log.i("✅ Migrated skip segments"); +} + +Future migrateSourceMatches() async { + AppLogger.log.i("🔵 Migrating source matches.."); + + Hive.registerAdapter(SourceMatchAdapter()); + Hive.registerAdapter(SourceTypeAdapter()); + + final box = await Hive.openBox( + SourceMatch.boxName, + path: await getHiveCacheDir(), + ); + + final sourceMatches = + box.keys.map((key) => (data: box.get(key), trackId: key)); + + await _database.batch((batch) { + batch.insertAll( + _database.sourceMatchTable, + sourceMatches + .where((element) => element.data != null) + .map( + (sourceMatch) => SourceMatchTableCompanion.insert( + sourceId: sourceMatch.data!.sourceId, + trackId: sourceMatch.trackId, + sourceType: Value( + switch (sourceMatch.data!.sourceType) { + SourceType.jiosaavn => db.SourceType.jiosaavn, + SourceType.youtube => db.SourceType.youtube, + SourceType.youtubeMusic => db.SourceType.youtubeMusic, + }, + ), + ), + ) + .toList(), + ); + }); + + AppLogger.log.i("✅ Migrated source matches"); +} + +Future migrateBlacklist() async { + AppLogger.log.i("🔵 Migrating blacklist.."); + + final box = PersistenceCacheBox>( + "blacklist", + fromJson: (json) => (json["blacklist"] as List) + .map((e) => BlacklistedElement.fromJson(e)) + .toSet(), + ); + + final data = await box.getData(); + + if (data == null) return; + + await _database.batch((batch) { + batch.insertAll( + _database.blacklistTable, + data.map( + (element) => BlacklistTableCompanion.insert( + name: element.name, + elementId: element.id, + elementType: switch (element.type) { + BlacklistedType.artist => db.BlacklistedType.artist, + BlacklistedType.track => db.BlacklistedType.track, + }, + ), + ), + ); + }); + + AppLogger.log.i("✅ Migrated blacklist"); +} + +Future migrateLastFmCredentials() async { + AppLogger.log.i("🔵 Migrating Last.fm credentials.."); + + final box = PersistenceCacheBox( + "scrobbler", + fromJson: (json) => ScrobblerState.fromJson(json), + encrypted: true, + ); + + final data = await box.getData(); + + if (data == null) return; + + await _database.into(_database.scrobblerTable).insert( + ScrobblerTableCompanion.insert( + id: const Value(0), + passwordHash: DecryptedText(data.passwordHash), + username: data.username, + ), + mode: InsertMode.replace, + ); + + AppLogger.log.i("✅ Migrated Last.fm credentials"); +} + +Future migratePlaybackHistory() async { + AppLogger.log.i("🔵 Migrating playback history.."); + + final box = PersistenceCacheBox( + "playback_history", + fromJson: (json) => PlaybackHistoryState.fromJson(json), + ); + + final data = await box.getData(); + + if (data == null) return; + + await _database.batch((batch) { + batch.insertAll( + _database.historyTable, + data.items.map( + (item) => switch (item) { + PlaybackHistoryAlbum() => HistoryTableCompanion.insert( + createdAt: Value(item.date), + itemId: item.album.id!, + data: item.album.toJson(), + type: db.HistoryEntryType.album, + ), + PlaybackHistoryPlaylist() => HistoryTableCompanion.insert( + createdAt: Value(item.date), + itemId: item.playlist.id!, + data: item.playlist.toJson(), + type: db.HistoryEntryType.playlist, + ), + PlaybackHistoryTrack() => HistoryTableCompanion.insert( + createdAt: Value(item.date), + itemId: item.track.id!, + data: item.track.toJson(), + type: db.HistoryEntryType.track, + ), + _ => throw Exception("Unknown history item type"), + }, + ), + ); + }); + + AppLogger.log.i("✅ Migrated playback history"); +} + +Future migrateFromHiveToDrift(AppDatabase database) async { + if (KVStoreService.hasMigratedToDrift) return; + + await PersistenceCacheBox.initializeBoxes( + path: await getHiveCacheDir(), + ); + + _database = database; + + await migrateAuthenticationInfo(); + await migratePreferences(); + + await migrateSkipSegment(); + await migrateSourceMatches(); + + await migrateBlacklist(); + await migratePlaybackHistory(); + + await migrateLastFmCredentials(); + + await KVStoreService.setHasMigratedToDrift(true); + + AppLogger.log.i("🚀 Migrated all data to Drift"); +} diff --git a/lib/utils/migrations/sandbox.dart b/lib/utils/migrations/sandbox.dart new file mode 100644 index 00000000..1ed5090a --- /dev/null +++ b/lib/utils/migrations/sandbox.dart @@ -0,0 +1,58 @@ +import 'dart:io'; + +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotube/services/logger/logger.dart'; +import 'package:spotube/utils/platform.dart'; + +/// Migrates sandbox files on macOS to non-sandbox directories +Future migrateMacOsFromSandboxToNoSandbox() async { + if (!kIsMacOS) return; + + try { + final sandboxApplicationSupportDir = Directory( + "/Users/${Platform.environment["USER"]}/Library/Containers/oss.krtirtho.spotube/Data/Library/Application Support/oss.krtirtho.spotube", + ); + + if (!await sandboxApplicationSupportDir.exists()) { + stdout.writeln("🔵 Sandbox directory not found, skipping migration"); + return; + } + + const fileExts = [".db", ".lock", ".hive"]; + + final supportDir = await getApplicationSupportDirectory() + ..create(recursive: true); + + final supportFiles = await supportDir.list().toList(); + final oldSupportFiles = await sandboxApplicationSupportDir.list().toList(); + + if (oldSupportFiles.isEmpty) { + stdout.writeln( + "🔵 No files found in sandboxed directory, skipping migration", + ); + return; + } else if (supportFiles.any( + (file) => file is File && fileExts.contains(extension(file.path)))) { + stdout.writeln( + "🔵 Non-sandbox directory is not empty, skipping migration", + ); + return; + } + + for (final oldSupportFile in oldSupportFiles) { + if (oldSupportFile is File && + fileExts.contains(extension(oldSupportFile.path))) { + final newPath = join(supportDir.path, basename(oldSupportFile.path)); + await oldSupportFile.copy(newPath); + } + } + + stdout.writeln("✅ Migrated sandboxed files to non-sandboxed directory"); + } catch (e, stack) { + stdout.writeln( + "❌ Error migrating sandboxed files to non-sandboxed directory", + ); + AppLogger.reportError(e, stack); + } +} diff --git a/lib/utils/persisted_change_notifier.dart b/lib/utils/persisted_change_notifier.dart deleted file mode 100644 index d48cb67a..00000000 --- a/lib/utils/persisted_change_notifier.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/widgets.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -abstract class PersistedChangeNotifier extends ChangeNotifier { - late SharedPreferences _localStorage; - PersistedChangeNotifier() { - SharedPreferences.getInstance().then((value) => _localStorage = value).then( - (_) async { - final persistedMap = (await toMap()) - .entries - .toList() - .fold>({}, (acc, entry) { - if (entry.value != null) { - if (entry.value is bool) { - acc[entry.key] = _localStorage.getBool(entry.key); - } else if (entry.value is int) { - acc[entry.key] = _localStorage.getInt(entry.key); - } else if (entry.value is double) { - acc[entry.key] = _localStorage.getDouble(entry.key); - } else if (entry.value is String) { - acc[entry.key] = _localStorage.getString(entry.key); - } - } else { - acc[entry.key] = _localStorage.get(entry.key); - } - return acc; - }); - await loadFromLocal(persistedMap); - notifyListeners(); - }, - ); - } - - FutureOr loadFromLocal(Map map); - - FutureOr> toMap(); - - Future updatePersistence({bool clearNullEntries = false}) async { - for (final entry in (await toMap()).entries) { - if (entry.value is bool) { - await _localStorage.setBool(entry.key, entry.value); - } else if (entry.value is int) { - await _localStorage.setInt(entry.key, entry.value); - } else if (entry.value is double) { - await _localStorage.setDouble(entry.key, entry.value); - } else if (entry.value is String) { - await _localStorage.setString(entry.key, entry.value); - } else if (entry.value == null && clearNullEntries) { - _localStorage.remove(entry.key); - } - } - } -} diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart index 60c77e59..c00f07ab 100644 --- a/lib/utils/service_utils.dart +++ b/lib/utils/service_utils.dart @@ -1,22 +1,29 @@ -import 'dart:convert'; - -import 'package:flutter/widgets.dart' hide Element; +import 'package:dio/dio.dart'; import 'package:go_router/go_router.dart'; -import 'package:html/dom.dart'; +import 'package:html/dom.dart' hide Text; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/library/user_local_tracks.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:http/http.dart' as http; +import 'package:spotube/modules/library/user_local_tracks.dart'; +import 'package:spotube/modules/root/update_dialog.dart'; + import 'package:spotube/models/lyrics.dart'; +import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/services/dio/dio.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/primitive_utils.dart'; import 'package:collection/collection.dart'; import 'package:html/parser.dart' as parser; -abstract class ServiceUtils { - static final logger = getLogger("ServiceUtils"); +import 'dart:async'; +import 'package:flutter/material.dart' hide Element; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:spotube/collections/env.dart'; + +import 'package:version/version.dart'; + +abstract class ServiceUtils { static final _englishMatcherRegex = RegExp( "^[a-zA-Z0-9\\s!\"#\$%&\\'()*+,-.\\/:;<=>?@\\[\\]^_`{|}~]*\$", ); @@ -60,9 +67,12 @@ abstract class ServiceUtils { } static Future extractLyrics(Uri url) async { - final response = await http.get(url); + final response = await globalDio.getUri( + url, + options: Options(responseType: ResponseType.plain), + ); - Document document = parser.parse(response.body); + Document document = parser.parse(response.data); String? lyrics = document.querySelector('div.lyrics')?.text.trim(); if (lyrics == null) { lyrics = ""; @@ -101,11 +111,14 @@ abstract class ServiceUtils { String reqUrl = "$searchUrl${Uri.encodeComponent(song)}"; Map headers = {"Authorization": 'Bearer $apiKey'}; - final response = await http.get( + final response = await globalDio.getUri( Uri.parse(authHeader ? reqUrl : "$reqUrl&access_token=$apiKey"), - headers: authHeader ? headers : null, + options: Options( + headers: authHeader ? headers : null, + responseType: ResponseType.json, + ), ); - Map data = jsonDecode(response.body)["response"]; + Map data = response.data["response"]; if (data["hits"]?.length == 0) return null; List results = data["hits"]?.map((val) { return { @@ -179,14 +192,15 @@ abstract class ServiceUtils { artists: artistNames, ); - logger.v("[Searching Subtitle] $query"); - final searchUri = Uri.parse("$baseUri/subtitles4songs.aspx").replace( queryParameters: {"q": query}, ); - final res = await http.get(searchUri); - final document = parser.parse(res.body); + final res = await globalDio.getUri( + searchUri, + options: Options(responseType: ResponseType.plain), + ); + final document = parser.parse(res.data); final results = document.querySelectorAll("#tablecontainer table tbody tr td a"); @@ -209,7 +223,6 @@ abstract class ServiceUtils { // not result was found at all if (rateSortedResults.first["points"] == 0) { - logger.e("[Subtitle not found] ${track.name}"); return Future.error("Subtitle lookup failed", StackTrace.current); } @@ -217,9 +230,11 @@ abstract class ServiceUtils { final subtitleUri = Uri.parse("$baseUri/${topResult.attributes["href"]}&type=lrc"); - logger.v("[Selected subtitle] ${topResult.text} | $subtitleUri"); - - final lrcDocument = parser.parse((await http.get(subtitleUri)).body); + final lrcDocument = parser.parse((await globalDio.getUri( + subtitleUri, + options: Options(responseType: ResponseType.plain), + )) + .data); final lrcList = lrcDocument .querySelector("#ctl00_ContentPlaceHolder1_lbllyrics") ?.innerHtml @@ -251,6 +266,7 @@ abstract class ServiceUtils { uri: subtitleUri, lyrics: lrcList, rating: rateSortedResults.first["points"] as int, + provider: "Rent An Adviser", ); return subtitle; @@ -261,6 +277,22 @@ abstract class ServiceUtils { GoRouter.of(context).go(location, extra: extra); } + static void navigateNamed( + BuildContext context, + String name, { + Object? extra, + Map? pathParameters, + Map? queryParameters, + }) { + if (GoRouterState.of(context).matchedLocation == name) return; + GoRouter.of(context).goNamed( + name, + pathParameters: pathParameters ?? const {}, + queryParameters: queryParameters ?? const {}, + extra: extra, + ); + } + static void push(BuildContext context, String location, {Object? extra}) { final router = GoRouter.of(context); final routerState = GoRouterState.of(context); @@ -272,6 +304,36 @@ abstract class ServiceUtils { router.push(location, extra: extra); } + static void pushNamed( + BuildContext context, + String name, { + Object? extra, + Map pathParameters = const {}, + Map queryParameters = const {}, + }) { + final router = GoRouter.of(context); + final routerState = GoRouterState.of(context); + final routerStack = router.routerDelegate.currentConfiguration.matches + .map((e) => e.matchedLocation); + + final nameLocation = routerState.namedLocation( + name, + pathParameters: pathParameters, + queryParameters: queryParameters, + ); + + if (routerState.matchedLocation == nameLocation || + routerStack.contains(nameLocation)) { + return; + } + router.pushNamed( + name, + pathParameters: pathParameters, + queryParameters: queryParameters, + extra: extra, + ); + } + static DateTime parseSpotifyAlbumDate(AlbumSimple? album) { if (album == null || album.releaseDate == null) { return DateTime.parse("1975-01-01"); @@ -307,7 +369,9 @@ abstract class ServiceUtils { case SortBy.duration: return a.durationMs?.compareTo(b.durationMs ?? 0) ?? 0; case SortBy.artist: - return a.artists?.first.name?.compareTo(b.artists?.first.name ?? "") ?? 0; + return a.artists?.first.name + ?.compareTo(b.artists?.first.name ?? "") ?? + 0; case SortBy.album: return a.album?.name?.compareTo(b.album?.name ?? "") ?? 0; default: @@ -315,4 +379,74 @@ abstract class ServiceUtils { } }); } + + static Future checkForUpdates( + BuildContext context, + WidgetRef ref, + ) async { + if (!Env.enableUpdateChecker) return; + final database = ref.read(databaseProvider); + final checkUpdate = await (database.selectOnly(database.preferencesTable) + ..addColumns([database.preferencesTable.checkUpdate]) + ..where(database.preferencesTable.id.equals(0))) + .map((row) => row.read(database.preferencesTable.checkUpdate)) + .getSingleOrNull(); + + if (checkUpdate == false) return; + final packageInfo = await PackageInfo.fromPlatform(); + + if (Env.releaseChannel == ReleaseChannel.nightly) { + final value = await globalDio.getUri( + Uri.parse( + "https://api.github.com/repos/KRTirtho/spotube/actions/workflows/spotube-release-binary.yml/runs?status=success&per_page=1", + ), + options: Options( + responseType: ResponseType.json, + ), + ); + + final buildNum = value.data["workflow_runs"][0]["run_number"] as int; + + if (buildNum <= int.parse(packageInfo.buildNumber) || !context.mounted) { + return; + } + + await showDialog( + context: context, + barrierDismissible: true, + barrierColor: Colors.black26, + builder: (context) { + return RootAppUpdateDialog.nightly(nightlyBuildNum: buildNum); + }, + ); + } else { + final value = await globalDio.getUri( + Uri.parse( + "https://api.github.com/repos/KRTirtho/spotube/releases/latest", + ), + ); + final tagName = (value.data["tag_name"] as String).replaceAll("v", ""); + final currentVersion = packageInfo.version == "Unknown" + ? null + : Version.parse(packageInfo.version); + final latestVersion = + tagName == "nightly" ? null : Version.parse(tagName); + + if (currentVersion == null || + latestVersion == null || + (latestVersion.isPreRelease && !currentVersion.isPreRelease) || + (!latestVersion.isPreRelease && currentVersion.isPreRelease)) return; + + if (latestVersion <= currentVersion || !context.mounted) return; + + showDialog( + context: context, + barrierDismissible: true, + barrierColor: Colors.black26, + builder: (context) { + return RootAppUpdateDialog(version: latestVersion); + }, + ); + } + } } diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart deleted file mode 100644 index 662b611c..00000000 --- a/lib/utils/type_conversion_utils.dart +++ /dev/null @@ -1,154 +0,0 @@ -// ignore_for_file: non_constant_identifier_names - -import 'dart:io'; - -import 'package:flutter/widgets.dart' hide Image; -import 'package:metadata_god/metadata_god.dart'; -import 'package:path/path.dart'; -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/components/shared/links/anchor_button.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/utils/primitive_utils.dart'; -import 'package:spotube/utils/service_utils.dart'; - -enum ImagePlaceholder { - albumArt, - artist, - collection, - online, -} - -abstract class TypeConversionUtils { - static String image_X_UrlString( - List? images, { - 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]!; - - return images != null && images.isNotEmpty - ? images[index > images.length - 1 ? images.length - 1 : index].url! - : placeholderUrl; - } - - static String artists_X_String(List artists) { - return artists.map((e) => e.name?.replaceAll(",", " ")).join(", "); - } - - static Widget artists_X_ClickableArtists( - List artists, { - WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.center, - WrapAlignment mainAxisAlignment = WrapAlignment.center, - TextStyle textStyle = const TextStyle(), - void Function(String route)? onRouteChange, - }) { - return Wrap( - crossAxisAlignment: crossAxisAlignment, - alignment: mainAxisAlignment, - children: artists - .asMap() - .entries - .map( - (artist) => Builder(builder: (context) { - return AnchorButton( - (artist.key != artists.length - 1) - ? "${artist.value.name}, " - : artist.value.name!, - onTap: () { - if (onRouteChange != null) { - onRouteChange("/artist/${artist.value.id}"); - } else { - ServiceUtils.push( - context, - "/artist/${artist.value.id}", - ); - } - }, - overflow: TextOverflow.ellipsis, - style: textStyle, - ); - }), - ) - .toList(), - ); - } - - static Album simpleAlbum_X_Album(AlbumSimple albumSimple) { - Album album = Album(); - album.albumType = albumSimple.albumType; - album.artists = albumSimple.artists; - album.availableMarkets = albumSimple.availableMarkets; - album.externalUrls = albumSimple.externalUrls; - album.href = albumSimple.href; - album.id = albumSimple.id; - album.images = albumSimple.images; - album.name = albumSimple.name; - album.releaseDate = albumSimple.releaseDate; - album.releaseDatePrecision = albumSimple.releaseDatePrecision; - album.tracks = albumSimple.tracks; - album.type = albumSimple.type; - album.uri = albumSimple.uri; - return album; - } - - static Track simpleTrack_X_Track(TrackSimple trackSmp, AlbumSimple album) { - Track track = Track(); - track.name = trackSmp.name; - track.album = album; - track.artists = trackSmp.artists; - track.availableMarkets = trackSmp.availableMarkets; - track.discNumber = trackSmp.discNumber; - track.durationMs = trackSmp.durationMs; - track.explicit = trackSmp.explicit; - track.externalUrls = trackSmp.externalUrls; - track.href = trackSmp.href; - track.id = trackSmp.id; - track.isPlayable = trackSmp.isPlayable; - track.linkedFrom = trackSmp.linkedFrom; - track.name = trackSmp.name; - track.previewUrl = trackSmp.previewUrl; - track.trackNumber = trackSmp.trackNumber; - track.type = trackSmp.type; - track.uri = trackSmp.uri; - return track; - } - - static Track localTrack_X_Track( - File file, { - Metadata? metadata, - String? art, - }) { - final track = Track(); - track.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(); - track.artists = [ - Artist() - ..name = metadata?.artist ?? "Unknown" - ..id = metadata?.artist ?? "Unknown" - ]; - - track.id = metadata?.title ?? basenameWithoutExtension(file.path); - track.name = metadata?.title ?? basenameWithoutExtension(file.path); - track.type = "track"; - track.uri = file.path; - track.durationMs = (metadata?.durationMs?.toInt() ?? 0); - - return track; - } -} diff --git a/linux/com.github.KRTirtho.Spotube.appdata.xml b/linux/com.github.KRTirtho.Spotube.appdata.xml index 7b6c92c2..ebe2fb7d 100644 --- a/linux/com.github.KRTirtho.Spotube.appdata.xml +++ b/linux/com.github.KRTirtho.Spotube.appdata.xml @@ -3,7 +3,7 @@ com.github.KRTirtho.Spotube Spotube - 🎧 Open source Spotify client that doesn't require Premium nor uses Electron! Available for both desktop & mobile! + Freedom of music CC0-1.0 BSD-4-Clause @@ -13,9 +13,11 @@ touch Kingkor Roy Tirtho - https://github.com/krtirtho/spotube + https://github.com/krtirtho/spotube/issues + https://spotube.krtirtho.dev + https://opencollective.com/spotube -

🎧 Open source Spotify client that doesn't require Premium nor uses Electron! Available for +

Open source Spotify client that doesn't require Premium nor uses Electron! Available for both desktop & mobile!

Following are the features that currently spotube offers:

    @@ -30,12 +32,13 @@
  • 📖 Open source/libre software
  • 🔉 Playback control is done locally, not on the server
-
- + - https://rawcdn.githack.com/KRTirtho/spotube/62055018feade0b895663a0bfc5f85f265ae2154/assets/spotube-screenshot.png + https://rawcdn.githack.com/KRTirtho/spotube/62055018feade0b895663a0bfc5f85f265ae2154/assets/spotube-screenshot.png + + Spotube screenshot com.github.KRTirtho.Spotube.desktop diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index c69c17c0..0f93d754 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,23 +6,23 @@ #include "generated_plugin_registrant.h" -#include +#include #include #include #include #include #include #include +#include #include -#include +#include #include #include -#include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) dart_discord_rpc_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "DartDiscordRpcPlugin"); - dart_discord_rpc_plugin_register_with_registrar(dart_discord_rpc_registrar); + g_autoptr(FlPluginRegistrar) desktop_webview_window_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopWebviewWindowPlugin"); + desktop_webview_window_plugin_register_with_registrar(desktop_webview_window_registrar); g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); @@ -41,19 +41,19 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) screen_retriever_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin"); screen_retriever_plugin_register_with_registrar(screen_retriever_registrar); + g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); + sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); g_autoptr(FlPluginRegistrar) system_theme_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "SystemThemePlugin"); system_theme_plugin_register_with_registrar(system_theme_registrar); - g_autoptr(FlPluginRegistrar) system_tray_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "SystemTrayPlugin"); - system_tray_plugin_register_with_registrar(system_tray_registrar); + g_autoptr(FlPluginRegistrar) tray_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin"); + tray_manager_plugin_register_with_registrar(tray_manager_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); g_autoptr(FlPluginRegistrar) window_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); window_manager_plugin_register_with_registrar(window_manager_registrar); - g_autoptr(FlPluginRegistrar) window_size_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "WindowSizePlugin"); - window_size_plugin_register_with_registrar(window_size_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index a4487f4d..ff642696 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,21 +3,22 @@ # list(APPEND FLUTTER_PLUGIN_LIST - dart_discord_rpc + desktop_webview_window file_selector_linux flutter_secure_storage_linux gtk local_notifier media_kit_libs_linux screen_retriever + sqlite3_flutter_libs system_theme - system_tray + tray_manager url_launcher_linux window_manager - window_size ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_discord_rpc media_kit_native_event_loop metadata_god ) diff --git a/linux/my_application.cc b/linux/my_application.cc index d1ac5d12..0aa4d905 100644 --- a/linux/my_application.cc +++ b/linux/my_application.cc @@ -23,7 +23,7 @@ static void my_application_activate(GApplication* application) { gtk_window_present(GTK_WINDOW(windows->data)); return; } - + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); @@ -55,10 +55,11 @@ static void my_application_activate(GApplication* application) { } gtk_window_set_default_size(window, 1280, 720); - gtk_widget_realize(GTK_WIDGET(window)); + gtk_widget_show(GTK_WIDGET(window)); g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); FlView* view = fl_view_new(project); gtk_widget_show(GTK_WIDGET(view)); @@ -70,16 +71,18 @@ static void my_application_activate(GApplication* application) { } // Implements GApplication::local_command_line. -static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { MyApplication* self = MY_APPLICATION(application); // Strip out the first argument as it is the binary name. self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); g_autoptr(GError) error = nullptr; if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; } g_application_activate(application); @@ -97,15 +100,31 @@ static void my_application_dispose(GObject* object) { static void my_application_class_init(MyApplicationClass* klass) { G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; G_OBJECT_CLASS(klass)->dispose = my_application_dispose; } static void my_application_init(MyApplication* self) {} -MyApplication* my_application_new() { - return MY_APPLICATION(g_object_new(my_application_get_type(), - "com.github.KRTirtho.Spotube", APPLICATION_ID, - "flags", G_APPLICATION_HANDLES_COMMAND_LINE | G_APPLICATION_HANDLES_OPEN, - nullptr)); +bool is_flatpak(void) { + if (getenv("container") || getenv("FLATPAK_ID") || getenv("FLATPAK")) { + /* flatpak */ + return true; + } + return false; /* No container detected */ } + +MyApplication* my_application_new() { + // gchar based alternate MY_APPLICATION_ID + const char* my_application_id = APPLICATION_ID; + + if (is_flatpak()) { + my_application_id = "com.github.KRTirtho.Spotube"; + } + + return MY_APPLICATION(g_object_new( + my_application_get_type(), "application-id", my_application_id, "flags", + G_APPLICATION_HANDLES_COMMAND_LINE | G_APPLICATION_HANDLES_OPEN, + nullptr)); +} \ No newline at end of file diff --git a/linux/packaging/deb/make_config.yaml b/linux/packaging/deb/make_config.yaml index f4c279b4..a7bea1aa 100644 --- a/linux/packaging/deb/make_config.yaml +++ b/linux/packaging/deb/make_config.yaml @@ -18,6 +18,13 @@ dependencies: - libjsoncpp25 - libmpv1 | libmpv2 - xdg-user-dirs + - avahi-daemon + - avahi-discover + - avahi-utils + - libnss-mdns + - mdns-scan + - libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-0 + - libsoup-3.0-0 | libsoup-2.4-0 essential: false icon: assets/spotube-logo.png diff --git a/linux/packaging/rpm/make_config.yaml b/linux/packaging/rpm/make_config.yaml index 1f952d0e..3d4a3b7e 100644 --- a/linux/packaging/rpm/make_config.yaml +++ b/linux/packaging/rpm/make_config.yaml @@ -13,6 +13,11 @@ requires: - libsecret - libnotify - xdg-user-dirs + - avahi + - mdns-scan + - nss-mdns + - webkit2gtk4.1 + - libsoup3 display_name: Spotube diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a7965e14..ea94bf6d 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,8 +8,11 @@ import Foundation import app_links import audio_service import audio_session +import bonsoir_darwin +import desktop_webview_window import device_info_plus import file_selector_macos +import flutter_inappwebview_macos import flutter_secure_storage_macos import local_notifier import media_kit_libs_macos_audio @@ -18,29 +21,32 @@ import path_provider_foundation import screen_retriever import shared_preferences_foundation import sqflite +import sqlite3_flutter_libs import system_theme -import system_tray +import tray_manager import url_launcher_macos import window_manager -import window_size func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin")) + DesktopWebviewWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewWindowPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin")) MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin")) - FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) SystemThemePlugin.register(with: registry.registrar(forPlugin: "SystemThemePlugin")) - SystemTrayPlugin.register(with: registry.registrar(forPlugin: "SystemTrayPlugin")) + TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) - WindowSizePlugin.register(with: registry.registrar(forPlugin: "WindowSizePlugin")) } diff --git a/macos/Podfile b/macos/Podfile index 049abe29..9ec46f8c 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 566e8196..acc50c99 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -5,23 +5,32 @@ PODS: - FlutterMacOS - audio_session (0.0.1): - FlutterMacOS + - bonsoir_darwin (0.0.1): + - Flutter + - FlutterMacOS + - desktop_webview_window (0.0.1): + - FlutterMacOS - device_info_plus (0.0.1): - FlutterMacOS - file_selector_macos (0.0.1): - FlutterMacOS + - flutter_discord_rpc (0.0.1): + - FlutterMacOS + - flutter_inappwebview_macos (0.0.1): + - FlutterMacOS + - OrderedSet (~> 6.0.3) - flutter_secure_storage_macos (6.1.1): - FlutterMacOS - FlutterMacOS (1.0.0) - - FMDB (2.7.5): - - FMDB/standard (= 2.7.5) - - FMDB/standard (2.7.5) - local_notifier (0.1.0): - FlutterMacOS - media_kit_libs_macos_audio (1.0.4): - FlutterMacOS - media_kit_native_event_loop (1.0.0): - FlutterMacOS - - metadata_god (0.0.1) + - metadata_god (0.0.1): + - FlutterMacOS + - OrderedSet (6.0.3) - package_info_plus (0.0.1): - FlutterMacOS - path_provider_foundation (0.0.1): @@ -32,26 +41,43 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - sqflite (0.0.2): + - sqflite (0.0.3): + - Flutter - FlutterMacOS - - FMDB (>= 2.7.5) + - "sqlite3 (3.46.0+1)": + - "sqlite3/common (= 3.46.0+1)" + - "sqlite3/common (3.46.0+1)" + - "sqlite3/fts5 (3.46.0+1)": + - sqlite3/common + - "sqlite3/perf-threadsafe (3.46.0+1)": + - sqlite3/common + - "sqlite3/rtree (3.46.0+1)": + - sqlite3/common + - sqlite3_flutter_libs (0.0.1): + - FlutterMacOS + - sqlite3 (~> 3.46.0) + - sqlite3/fts5 + - sqlite3/perf-threadsafe + - sqlite3/rtree - system_theme (0.0.1): - FlutterMacOS - - system_tray (0.0.1): + - tray_manager (0.0.1): - FlutterMacOS - url_launcher_macos (0.0.1): - FlutterMacOS - window_manager (0.2.0): - FlutterMacOS - - window_size (0.0.2): - - FlutterMacOS DEPENDENCIES: - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) - audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/macos`) - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) + - bonsoir_darwin (from `Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin`) + - desktop_webview_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_webview_window/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - flutter_discord_rpc (from `Flutter/ephemeral/.symlinks/plugins/flutter_discord_rpc/macos`) + - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`) - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`) @@ -62,16 +88,17 @@ DEPENDENCIES: - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/macos`) + - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`) + - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos`) - system_theme (from `Flutter/ephemeral/.symlinks/plugins/system_theme/macos`) - - system_tray (from `Flutter/ephemeral/.symlinks/plugins/system_tray/macos`) + - tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) - - window_size (from `Flutter/ephemeral/.symlinks/plugins/window_size/macos`) SPEC REPOS: trunk: - - FMDB + - OrderedSet + - sqlite3 EXTERNAL SOURCES: app_links: @@ -80,10 +107,18 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/audio_service/macos audio_session: :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos + bonsoir_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin + desktop_webview_window: + :path: Flutter/ephemeral/.symlinks/plugins/desktop_webview_window/macos device_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos file_selector_macos: :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + flutter_discord_rpc: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_discord_rpc/macos + flutter_inappwebview_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos flutter_secure_storage_macos: :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos FlutterMacOS: @@ -105,42 +140,47 @@ EXTERNAL SOURCES: shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin sqflite: - :path: Flutter/ephemeral/.symlinks/plugins/sqflite/macos + :path: Flutter/ephemeral/.symlinks/plugins/sqflite/darwin + sqlite3_flutter_libs: + :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos system_theme: :path: Flutter/ephemeral/.symlinks/plugins/system_theme/macos - system_tray: - :path: Flutter/ephemeral/.symlinks/plugins/system_tray/macos + tray_manager: + :path: Flutter/ephemeral/.symlinks/plugins/tray_manager/macos url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos window_manager: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos - window_size: - :path: Flutter/ephemeral/.symlinks/plugins/window_size/macos SPEC CHECKSUMS: - app_links: 4481ed4d71f384b0c3ae5016f4633aa73d32ff67 + app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a audio_service: b88ff778e0e3915efd4cd1a5ad6f0beef0c950a9 audio_session: dea1f41890dbf1718f04a56f1d6150fd50039b72 - device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f + bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 + desktop_webview_window: 89bb3d691f4c80314a10be312f4cd35db93a9d5a + device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 + flutter_discord_rpc: 67a7c10ea24d9d3bf35d01af643f48fbcfa7c24f + flutter_inappwebview_macos: bdf207b8f4ebd58e86ae06cd96b147de99a67c9b flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff media_kit_libs_macos_audio: 3871782a4f3f84c77f04d7666c87800a781c24da media_kit_native_event_loop: 7321675377cb9ae8596a29bddf3a3d2b5e8792c5 - metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7 - package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce - path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + metadata_god: 829f61208b44ac1173e7cd32ab740d8776be5435 + OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 + package_info_plus: fa739dd842b393193c5ca93c26798dff6e3d0e0c + path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 - sqflite: a5789cceda41d54d23f31d6de539d65bb14100ea + shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 + sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec + sqlite3: 292c3e1bfe89f64e51ea7fc7dab9182a017c8630 + sqlite3_flutter_libs: 1be4459672f8168ded2d8667599b8e3ca5e72b83 system_theme: c7b9f6659a5caa26c9bc2284da096781e9a6fcbc - system_tray: e53c972838c69589ff2e77d6d3abfd71332f9e5d + tray_manager: 9064e219c56d75c476e46b9a21182087930baf90 url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 - window_size: 339dafa0b27a95a62a843042038fa6c3c48de195 -PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 +PODFILE CHECKSUM: 0d3963a09fc94f580682bd88480486da345dc3f0 COCOAPODS: 1.15.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 0ee2c9fa..bf5d70cf 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -428,6 +428,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_HARDENED_RUNTIME = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Spotube; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; @@ -435,7 +436,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -558,6 +559,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_HARDENED_RUNTIME = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Spotube; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; @@ -565,7 +567,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -582,7 +584,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_HARDENED_RUNTIME = YES; + ENABLE_HARDENED_RUNTIME = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Spotube; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; @@ -590,7 +592,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 218f93e0..a6f73a80 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return false diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index e9de2261..6e73fa3c 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -1,20 +1,18 @@ - - com.apple.security.app-sandbox - - com.apple.security.assets.music.read-write - - com.apple.security.cs.allow-jit - - com.apple.security.files.downloads.read-write - - com.apple.security.files.user-selected.read-write - - com.apple.security.network.client - - com.apple.security.network.server - - - + + com.apple.security.app-sandbox + + com.apple.security.assets.music.read-write + + com.apple.security.files.downloads.read-write + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + + \ No newline at end of file diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index f05277de..6e73fa3c 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -1,18 +1,18 @@ - - com.apple.security.app-sandbox - - com.apple.security.assets.music.read-write - - com.apple.security.files.downloads.read-write - - com.apple.security.files.user-selected.read-write - - com.apple.security.network.client - - com.apple.security.network.server - - - + + com.apple.security.app-sandbox + + com.apple.security.assets.music.read-write + + com.apple.security.files.downloads.read-write + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + + \ No newline at end of file diff --git a/macos/Runner/RunnerDebug.entitlements b/macos/Runner/RunnerDebug.entitlements index e9de2261..6e73fa3c 100644 --- a/macos/Runner/RunnerDebug.entitlements +++ b/macos/Runner/RunnerDebug.entitlements @@ -1,20 +1,18 @@ - - com.apple.security.app-sandbox - - com.apple.security.assets.music.read-write - - com.apple.security.cs.allow-jit - - com.apple.security.files.downloads.read-write - - com.apple.security.files.user-selected.read-write - - com.apple.security.network.client - - com.apple.security.network.server - - - + + com.apple.security.app-sandbox + + com.apple.security.assets.music.read-write + + com.apple.security.files.downloads.read-write + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + + \ No newline at end of file diff --git a/metadata/tr/full_description.txt b/metadata/tr/full_description.txt new file mode 100644 index 00000000..8b8b814c --- /dev/null +++ b/metadata/tr/full_description.txt @@ -0,0 +1,14 @@ +Premium gerektirmeyen ve Electron kullanmayan açık kaynaklı Spotify istemcisi! Hem masaüstü hem de mobil için kullanılabilir! + + +Özellikler: +* Herkese açık ve ücretsiz Spotify ve YT Music API'lerinin kullanımı sayesinde reklam yok¹ +* İndirilebilir parçalar +* Çapraz platform desteği +* Küçük boyut ve daha az veri kullanımı +* Anonim/misafir girişi +* Zaman senkronize şarkı sözleri +* Telemetri, tanılama veya kullanıcı verisi toplama yok +* Yerel performans +* Açık kaynak yazılım +* Oynatma kontrolü sunucu üzerinde değil, yerel olarak yapılır diff --git a/metadata/tr/images/icon.png b/metadata/tr/images/icon.png new file mode 100644 index 00000000..b24a8c23 Binary files /dev/null and b/metadata/tr/images/icon.png differ diff --git a/metadata/tr/images/phoneScreenshots/android-1.jpg b/metadata/tr/images/phoneScreenshots/android-1.jpg new file mode 100644 index 00000000..ae1ef8ac Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/android-1.jpg differ diff --git a/metadata/tr/images/phoneScreenshots/android-2.jpg b/metadata/tr/images/phoneScreenshots/android-2.jpg new file mode 100644 index 00000000..b6668d2b Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/android-2.jpg differ diff --git a/metadata/tr/images/phoneScreenshots/android-3.jpg b/metadata/tr/images/phoneScreenshots/android-3.jpg new file mode 100644 index 00000000..87619b21 Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/android-3.jpg differ diff --git a/metadata/tr/images/phoneScreenshots/android-4.jpg b/metadata/tr/images/phoneScreenshots/android-4.jpg new file mode 100644 index 00000000..2d1e58e2 Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/android-4.jpg differ diff --git a/metadata/tr/images/phoneScreenshots/android-5.jpg b/metadata/tr/images/phoneScreenshots/android-5.jpg new file mode 100644 index 00000000..fc4b2c9a Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/android-5.jpg differ diff --git a/metadata/tr/short_description.txt b/metadata/tr/short_description.txt new file mode 100644 index 00000000..2a0d24cd --- /dev/null +++ b/metadata/tr/short_description.txt @@ -0,0 +1 @@ +Spotify Premium gerektirmeyen hafif ve kaynak dostu spotify istemcisi \ No newline at end of file diff --git a/metadata/tr/title.txt b/metadata/tr/title.txt new file mode 100644 index 00000000..0271be7e --- /dev/null +++ b/metadata/tr/title.txt @@ -0,0 +1 @@ +Spotube \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index cc69663d..77193ca0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" url: "https://pub.dev" source: hosted - version: "61.0.0" + version: "67.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" url: "https://pub.dev" source: hosted - version: "5.13.0" + version: "6.4.1" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" + url: "https://pub.dev" + source: hosted + version: "0.11.3" ansicolor: dependency: transitive description: @@ -29,90 +37,58 @@ packages: dependency: "direct main" description: name: app_links - sha256: "4e392b5eba997df356ca6021f28431ce1cfeb16758699553a94b13add874a3bb" + sha256: ad1a6d598e7e39b46a34f746f9a8b011ee147e4c275d407fa457e7a62f84dd99 url: "https://pub.dev" source: hosted - version: "3.5.0" - app_package_maker: + version: "6.3.2" + app_links_linux: dependency: transitive description: - name: app_package_maker - sha256: "0dc1949e09a60ec2d0b79b43c5237734e6d03f7a022919bdfe1b4789f4c3bfb0" + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 url: "https://pub.dev" source: hosted - version: "0.0.9" - app_package_maker_aab: + version: "1.0.3" + app_links_platform_interface: dependency: transitive description: - name: app_package_maker_aab - sha256: "44810e77dff3b3b54011270b01a42876e838766d6e85c98f9a33bfe61db51651" + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" url: "https://pub.dev" source: hosted - version: "0.0.9" - app_package_maker_apk: + version: "2.0.2" + app_links_web: dependency: transitive description: - name: app_package_maker_apk - sha256: "974e639cda26c2e18fffaba18f88797523731f5b5457056b25e92243c9191f61" + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 url: "https://pub.dev" source: hosted - version: "0.0.9" - app_package_maker_deb: - dependency: transitive - description: - name: app_package_maker_deb - sha256: dcd4047cb67648e53afd61079a8baa3c8ea383668f068e3ce8da841f3728eb29 - url: "https://pub.dev" - source: hosted - version: "0.0.9" - app_package_maker_dmg: - dependency: transitive - description: - name: app_package_maker_dmg - sha256: e0410a51304f3fff3e3850696c8e56f53f71c990e097f1c325126ebe90d242c4 - url: "https://pub.dev" - source: hosted - version: "0.0.9" - app_package_maker_exe: - dependency: transitive - description: - name: app_package_maker_exe - sha256: "07e3899a3ae12e8b6cd80efc7281ccca6c9050d2810e0fdc0e7e614cf4bd8a02" - url: "https://pub.dev" - source: hosted - version: "0.0.9" - app_package_maker_ipa: - dependency: transitive - description: - name: app_package_maker_ipa - sha256: "1a11498506ba975d02a4715650701981a382a2161c81481911517b50b378cd65" - url: "https://pub.dev" - source: hosted - version: "0.0.9" - app_package_maker_zip: - dependency: transitive - description: - name: app_package_maker_zip - sha256: cef07a47c589036a4762fdc9e61b9022f0a2a2a9f69538109a0a952a7e668306 - url: "https://pub.dev" - source: hosted - version: "0.0.9" + version: "1.0.4" archive: dependency: transitive description: name: archive - sha256: ca12e6c9ac022f33fd89128e7007fb5e97ab6e814d4fa05dd8d4f2db1e3c69cb + sha256: ecf4273855368121b1caed0d10d4513c7241dfc813f7d3c8933b36622ae9b265 url: "https://pub.dev" source: hosted - version: "3.4.5" + version: "3.5.1" args: dependency: "direct main" description: name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.5.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "58082b3f0dca697204dbab0ef9ff208bfaea7767ea771076af9a343488428dda" + url: "https://pub.dev" + source: hosted + version: "1.5.3" async: dependency: "direct main" description: @@ -125,18 +101,18 @@ packages: dependency: "direct main" description: name: audio_service - sha256: a4d989f1225ea9621898d60f23236dcbfc04876fa316086c23c5c4af075dbac4 + sha256: "4547c312a94f9cb2c48b60823fb190767cbd63454a83c73049384d5d3cba4650" url: "https://pub.dev" source: hosted - version: "0.18.12" + version: "0.18.13" audio_service_mpris: dependency: "direct main" description: name: audio_service_mpris - sha256: "31be5de2db0c71b217157afce1974ac6d0ad329bd91deb1f19ad094d29340d8e" + sha256: b16db3584a4b2464c0bfd575c1a21765723d257931222f8adfcb0511f940d352 url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.1.5" audio_service_platform_interface: dependency: transitive description: @@ -149,18 +125,18 @@ packages: dependency: transitive description: name: audio_service_web - sha256: "523e64ddc914c714d53eec2da85bba1074f08cf26c786d4efb322de510815ea7" + sha256: "9d7d5ae5f98a5727f2580fad73062f2484f400eef6cef42919413268e62a363e" url: "https://pub.dev" source: hosted - version: "0.1.1" + version: "0.1.2" audio_session: dependency: "direct main" description: name: audio_session - sha256: "6fdf255ed3af86535c96452c33ecff1245990bb25a605bfb1958661ccc3d467f" + sha256: a49af9981eec5d7cd73b37bacb6ee73f8143a6a9f9bd5b6021e6c346b9b6cf4e url: "https://pub.dev" source: hosted - version: "0.1.18" + version: "0.1.19" auto_size_text: dependency: "direct main" description: @@ -169,6 +145,54 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + bonsoir: + dependency: "direct main" + description: + name: bonsoir + sha256: b7697a954c772a6ddc68d52b3e4768947cc98613127f7720a05b14ed1e59d68b + url: "https://pub.dev" + source: hosted + version: "5.1.10" + bonsoir_android: + dependency: transitive + description: + name: bonsoir_android + sha256: a72d83a78780c1f238e3178d0585e5604fbd9f2503206293737cdfab899ce8d0 + url: "https://pub.dev" + source: hosted + version: "5.1.5" + bonsoir_darwin: + dependency: transitive + description: + name: bonsoir_darwin + sha256: "2d25c70f0d09260be1c2ab583b80dd89cbbfd59997579dadf789c5af00c7b2e4" + url: "https://pub.dev" + source: hosted + version: "5.1.3" + bonsoir_linux: + dependency: transitive + description: + name: bonsoir_linux + sha256: f2639aded6e15943a9822de98a663a1056f37cbfd0a74d72c9eaa941965945c2 + url: "https://pub.dev" + source: hosted + version: "5.1.3" + bonsoir_platform_interface: + dependency: transitive + description: + name: bonsoir_platform_interface + sha256: "08bb8b35d0198168b3bce87dbc718e4e510336cff1d97e43762e030c01636d45" + url: "https://pub.dev" + source: hosted + version: "5.1.3" + bonsoir_windows: + dependency: transitive + description: + name: bonsoir_windows + sha256: d4a0ca479d4f3679487a61f3174fb9fe1651e323c778b02dfa630490366be65d + url: "https://pub.dev" + source: hosted + version: "5.1.5" boolean_selector: dependency: transitive description: @@ -205,34 +229,34 @@ packages: dependency: transitive description: name: build_daemon - sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" + sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.2" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: d912852cce27c9e80a93603db721c267716894462e7033165178b91138587972 + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.2" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "10c6bcdbf9d049a0b666702cf1cee4ddfdc38f02a19d35ae392863b47519848b" + sha256: dd09dd4e2b078992f42aac7f1a622f01882a8492fef08486b27ddde929c19f04 url: "https://pub.dev" source: hosted - version: "2.4.6" + version: "2.4.12" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: "6d6ee4276b1c5f34f21fdf39425202712d2be82019983d52f351c94aafbc2c41" + sha256: "4ae8ffe5ac758da294ecf1802f2aff01558d8b1b00616aa7538ea9a8a5d50799" url: "https://pub.dev" source: hosted - version: "7.2.10" + version: "7.3.0" built_collection: dependency: transitive description: @@ -245,50 +269,42 @@ packages: dependency: transitive description: name: built_value - sha256: ff627b645b28fb8bdb69e645f910c2458fd6b65f6585c3a53e0626024897dedf + sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb url: "https://pub.dev" source: hosted - version: "8.6.2" + version: "8.9.2" buttons_tabbar: dependency: "direct main" description: name: buttons_tabbar - sha256: "781128180f3e76cf93c093183f10395c664983dbee20bc4da2025be70085c2da" + sha256: "3f0969c26574ef15c0c9ff1dee42c3c4b0d3563d2c8607804372490fb8b76896" url: "https://pub.dev" source: hosted - version: "1.3.7+1" + version: "1.3.8" cached_network_image: dependency: "direct main" description: name: cached_network_image - sha256: f98972704692ba679db144261172a8e20feb145636c617af0eb4022132a6797f + sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" url: "https://pub.dev" source: hosted - version: "3.3.0" + version: "3.3.1" cached_network_image_platform_interface: dependency: transitive description: name: cached_network_image_platform_interface - sha256: "56aa42a7a01e3c9db8456d9f3f999931f1e05535b5a424271e9a38cabf066613" + sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "4.0.0" cached_network_image_web: dependency: transitive description: name: cached_network_image_web - sha256: "759b9a9f8f6ccbb66c185df805fac107f05730b1dab9c64626d1008cca532257" + sha256: "42a835caa27c220d1294311ac409a43361088625a4f23c820b006dd9bffb3316" url: "https://pub.dev" source: hosted - version: "1.1.0" - catcher_2: - dependency: "direct main" - description: - name: catcher_2 - sha256: "73e251057b1b59442d58b5109d26401cfed850df21da44b028338d4f85f69105" - url: "https://pub.dev" - source: hosted - version: "1.0.0" + version: "1.1.1" change_case: dependency: transitive description: @@ -305,6 +321,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + url: "https://pub.dev" + source: hosted + version: "1.3.1" checked_yaml: dependency: transitive description: @@ -313,14 +337,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + ci: + dependency: transitive + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" cli_util: dependency: transitive description: name: cli_util - sha256: b8db3080e59b2503ca9e7922c3df2072cf13992354d5e944074ffa836fba43b7 + sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 url: "https://pub.dev" source: hosted - version: "0.4.0" + version: "0.4.1" clock: dependency: transitive description: @@ -333,10 +365,10 @@ packages: dependency: transitive description: name: code_builder - sha256: "315a598c7fbe77f22de1c9da7cfd6fd21816312f16ffa124453b4fc679e540f1" + sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 url: "https://pub.dev" source: hosted - version: "4.6.0" + version: "4.10.0" collection: dependency: "direct main" description: @@ -365,12 +397,12 @@ packages: dependency: transitive description: name: cross_file - sha256: fd832b5384d0d6da4f6df60b854d33accaaeb63aa9e10e736a87381f08dee2cb + sha256: "55d7b444feb71301ef6b8838dbc1ae02e63dd48c8773f3810ff53bb1e2945b32" url: "https://pub.dev" source: hosted - version: "0.3.3+5" + version: "0.3.4+1" crypto: - dependency: transitive + dependency: "direct dev" description: name: crypto sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab @@ -385,14 +417,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d - url: "https://pub.dev" - source: hosted - version: "1.0.6" curved_navigation_bar: dependency: "direct main" description: @@ -401,6 +425,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.3" + custom_lint: + dependency: "direct dev" + description: + name: custom_lint + sha256: "7c0aec12df22f9082146c354692056677f1e70bc43471644d1fdb36c6fdda799" + url: "https://pub.dev" + source: hosted + version: "0.6.4" + custom_lint_builder: + dependency: transitive + description: + name: custom_lint_builder + sha256: d7dc41e709dde223806660268678be7993559e523eb3164e2a1425fd6f7615a9 + url: "https://pub.dev" + source: hosted + version: "0.6.4" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: a85e8f78f4c52f6c63cdaf8c872eb573db0231dcdf3c3a5906d493c1f8bc20e6 + url: "https://pub.dev" + source: hosted + version: "0.6.3" dart_des: dependency: transitive description: @@ -409,23 +457,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" - dart_discord_rpc: - dependency: "direct main" + dart_mappable: + dependency: transitive description: - path: "." - ref: HEAD - resolved-ref: "4d05017838ebeadcdb832e1893fabad1506fddba" - url: "https://github.com/Tommypop2/dart_discord_rpc.git" - source: git - version: "0.0.3" + name: dart_mappable + sha256: "47269caf2060533c29b823ff7fa9706502355ffcb61e7f2a374e3a0fb2f2c3f0" + url: "https://pub.dev" + source: hosted + version: "4.2.2" dart_style: dependency: transitive description: name: dart_style - sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.6" dartx: dependency: transitive description: @@ -435,29 +482,30 @@ packages: source: hosted version: "1.2.0" dbus: - dependency: "direct main" - description: - name: dbus - sha256: "6f07cba3f7b3448d42d015bfd3d53fe12e5b36da2423f23838efc1d5fb31a263" - url: "https://pub.dev" - source: hosted - version: "0.7.8" - device_frame: dependency: transitive description: - name: device_frame - sha256: afe76182aec178d171953d9b4a50a43c57c7cf3c77d8b09a48bf30c8fa04dd9d + name: dbus + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "0.7.10" + desktop_webview_window: + dependency: "direct main" + description: + path: "packages/desktop_webview_window" + ref: "feat/cookies" + resolved-ref: f20e433d4a948515b35089d40069f7dd9bced9e4 + url: "https://github.com/KRTirtho/flutter-plugins.git" + source: git + version: "0.2.4" device_info_plus: dependency: "direct main" description: name: device_info_plus - sha256: "86add5ef97215562d2e090535b0a16f197902b10c369c558a100e74ea06e8659" + sha256: eead12d1a1ed83d8283ab4c2f3fca23ac4082f29f25f29dff0f758f57d06ec91 url: "https://pub.dev" source: hosted - version: "9.0.3" + version: "10.1.0" device_info_plus_platform_interface: dependency: transitive description: @@ -466,38 +514,22 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" - device_preview: - dependency: "direct main" - description: - name: device_preview - sha256: "2f097bf31b929e15e6756dbe0ec1bcb63952ab9ed51c25dc5a2c722d2b21fdaf" - url: "https://pub.dev" - source: hosted - version: "1.1.0" dio: dependency: "direct main" description: name: dio - sha256: "49af28382aefc53562459104f64d16b9dfd1e8ef68c862d5af436cc8356ce5a8" + sha256: "11e40df547d418cc0c4900a9318b26304e665da6fa4755399a9ff9efd09034b5" url: "https://pub.dev" source: hosted - version: "5.4.1" + version: "5.4.3+1" disable_battery_optimization: dependency: "direct main" description: name: disable_battery_optimization - sha256: b3441975ab2a3ab0c19ed78e909a88d245ce689d43d17f9b23582b1ed41c047b + sha256: "6b2ba802f984af141faf1b6b5fb956d5ef01f9cd555597c35b9cc335a03185ba" url: "https://pub.dev" source: hosted - version: "1.1.0+1" - dots_indicator: - dependency: transitive - description: - name: dots_indicator - sha256: f1599baa429936ba87f06ae5f2adc920a367b16d08f74db58c3d0f6e93bcdb5c - url: "https://pub.dev" - source: hosted - version: "2.1.2" + version: "1.1.1" draggable_scrollbar: dependency: "direct main" description: @@ -507,6 +539,22 @@ packages: url: "https://github.com/thielepaul/flutter-draggable-scrollbar.git" source: git version: "0.1.0" + drift: + dependency: "direct main" + description: + name: drift + sha256: "6acedc562ffeed308049f78fb1906abad3d65714580b6745441ee6d50ec564cd" + url: "https://pub.dev" + source: hosted + version: "2.18.0" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: d9b020736ea85fff1568699ce18b89fabb3f0f042e8a7a05e84a3ec20d39acde + url: "https://pub.dev" + source: hosted + version: "2.18.0" duration: dependency: "direct main" description: @@ -515,22 +563,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.13" + encrypt: + dependency: "direct main" + description: + name: encrypt + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + url: "https://pub.dev" + source: hosted + version: "5.0.3" envied: dependency: "direct main" description: name: envied - sha256: "60d3f5606c7b35bc6ef493e650d916b34351d8af2e58b7ac45881ba59dfcf039" + sha256: bbff9c76120e4dc5e2e36a46690cf0a26feb65e7765633f4e8d916bcd173a450 url: "https://pub.dev" source: hosted - version: "0.3.0+3" + version: "0.5.4+1" envied_generator: dependency: "direct dev" description: name: envied_generator - sha256: dfdbe5dc52863e54c036a4c4042afbdf1bd528cb4c1e638ecba26228ba72e9e5 + sha256: "517b70de08d13dcd40e97b4e5347e216a0b1c75c99e704f3c85c0474a392d14a" url: "https://pub.dev" source: hosted - version: "0.3.0+3" + version: "0.5.4+1" + equatable: + dependency: transitive + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" fake_async: dependency: transitive description: @@ -543,10 +607,10 @@ packages: dependency: transitive description: name: ffi - sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.3" file: dependency: transitive description: @@ -559,34 +623,34 @@ packages: dependency: "direct main" description: name: file_picker - sha256: "4e42aacde3b993c5947467ab640882c56947d9d27342a5b6f2895b23956954a6" + sha256: d1d0ac3966b36dc3e66eeefb40280c17feb87fa2099c6e22e6a1fc959327bd03 url: "https://pub.dev" source: hosted - version: "6.1.1" + version: "8.0.0+1" file_selector: dependency: "direct main" description: name: file_selector - sha256: "84eaf3e034d647859167d1f01cfe7b6352488f34c1b4932635012b202014c25b" + sha256: "5019692b593455127794d5718304ff1ae15447dea286cdda9f0db2a796a1b828" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.3" file_selector_android: dependency: transitive description: name: file_selector_android - sha256: d41e165d6f798ca941d536e5dc93494d50e78c571c28ad60cfe0b0fefeb9f1e7 + sha256: "1cd66575f063b689e041aec836905ba7be18d76c9f0634d0d75daec825f67095" url: "https://pub.dev" source: hosted - version: "0.5.0+3" + version: "0.5.0+7" file_selector_ios: dependency: transitive description: name: file_selector_ios - sha256: b3fbdda64aa2e335df6e111f6b0f1bb968402ed81d2dd1fa4274267999aa32c2 + sha256: "0a1196a9c5795858aa315332da2fb5c4bcfdcb312d8a4e27651f765b87904431" url: "https://pub.dev" source: hosted - version: "0.5.1+6" + version: "0.5.1+9" file_selector_linux: dependency: transitive description: @@ -599,26 +663,26 @@ packages: dependency: transitive description: name: file_selector_macos - sha256: "182c3f8350cee659f7b115e956047ee3dc672a96665883a545e81581b9a82c72" + sha256: b15c3da8bd4908b9918111fa486903f5808e388b8d1c559949f584725a6594d6 url: "https://pub.dev" source: hosted - version: "0.9.3+2" + version: "0.9.3+3" file_selector_platform_interface: dependency: transitive description: name: file_selector_platform_interface - sha256: "0aa47a725c346825a2bd396343ce63ac00bda6eff2fbc43eabe99737dede8262" + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b url: "https://pub.dev" source: hosted - version: "2.6.1" + version: "2.6.2" file_selector_web: dependency: transitive description: name: file_selector_web - sha256: dc6622c4d66cb1bee623ddcc029036603c6cc45c85e4a775bb06008d61c809c1 + sha256: "619e431b224711a3869e30dbd7d516f5f5a4f04b265013a50912f39e1abc88c8" url: "https://pub.dev" source: hosted - version: "0.9.2+1" + version: "0.9.4+1" file_selector_windows: dependency: transitive description: @@ -635,59 +699,19 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" - fl_query: - dependency: "direct main" - description: - name: fl_query - sha256: daee5ab0ed8899baa201b89b5813107df5258144a9e2bcf192dbcf922c57d985 - url: "https://pub.dev" - source: hosted - version: "1.0.0" - fl_query_devtools: - dependency: "direct main" - description: - name: fl_query_devtools - sha256: "2ae8905fd4a95f1d245a1b54057c31c8d27fc961223bcb7ce13088bcf6595059" - url: "https://pub.dev" - source: hosted - version: "0.1.0" - fl_query_hooks: - dependency: "direct main" - description: - name: fl_query_hooks - sha256: "6c88b3bfbdc3e1330931b927903929d7351f86fc63266ac93b3acb9f133a09a9" - url: "https://pub.dev" - source: hosted - version: "1.0.0" fluentui_system_icons: dependency: "direct main" description: name: fluentui_system_icons - sha256: "7637cab80bd8d1ba762144cd85df79a7318c12ed5a66d166a9e4acbf24a4c412" + sha256: "1c860f10a0e74c5788ff8a650ae6074d9a544463ae269714f1044b32df52b978" url: "https://pub.dev" source: hosted - version: "1.1.214" + version: "1.1.234" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" - flutter_app_builder: - dependency: transitive - description: - name: flutter_app_builder - sha256: "9e5527919f62424f0fafaa3e8dfda8469caf63e465862e9866a0d60a37c00fcf" - url: "https://pub.dev" - source: hosted - version: "0.0.9" - flutter_app_packager: - dependency: transitive - description: - name: flutter_app_packager - sha256: b5bfb7113b49710c004c5f1ab6f08ac121418540d49e14825dd75e99810fa695 - url: "https://pub.dev" - source: hosted - version: "0.0.9" flutter_broadcasts: dependency: "direct main" description: @@ -704,15 +728,15 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.1" - flutter_desktop_tools: + flutter_discord_rpc: dependency: "direct main" description: - path: "." - ref: "1f0bec3283626dcbd8ee2f54e238d096d8dea50e" - resolved-ref: "1f0bec3283626dcbd8ee2f54e238d096d8dea50e" - url: "https://github.com/KRTirtho/flutter_desktop_tools.git" + path: "packages/flutter_discord_rpc" + ref: cargokit + resolved-ref: "331636d8e378e3ac9ad30a4b0d3eed17d5a85fe9" + url: "https://github.com/KRTirtho/frb_plugins.git" source: git - version: "0.0.1" + version: "0.1.0+1" flutter_displaymode: dependency: "direct main" description: @@ -721,14 +745,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" - flutter_distributor: - dependency: "direct dev" - description: - name: flutter_distributor - sha256: "50d56df265e97396427ec42cc02374b72d08c71b3442d662b97fc089bd1705ea" - url: "https://pub.dev" - source: hosted - version: "0.0.2" flutter_driver: dependency: transitive description: flutter @@ -746,82 +762,90 @@ packages: dependency: transitive description: name: flutter_gen_core - sha256: e8637dd6a59860f89e5e71be0a27101ec32dad1a0ed7fd879fd23b6e91d5004d + sha256: "3a6c3dbc1c0e260088e9c7ed1ba905436844e8c01a44799f6281edada9e45308" url: "https://pub.dev" source: hosted - version: "5.3.1" + version: "5.4.0" flutter_gen_runner: dependency: "direct dev" description: name: flutter_gen_runner - sha256: "7de1bf4fc0439be0fef3178b6423d5c7f1f9f3a38a7c6fafe75d7f70ff4856d7" + sha256: "24889d5140b03997f7148066a9c5fab8b606dff36093434c782d7a7fb22c6fb6" url: "https://pub.dev" source: hosted - version: "5.3.1" + version: "5.4.0" flutter_hooks: dependency: "direct main" description: name: flutter_hooks - sha256: "6ae13b1145c589112cbd5c4fda6c65908993a9cb18d4f82042e9c28dd9fbf611" + sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70 url: "https://pub.dev" source: hosted - version: "0.20.1" + version: "0.20.5" flutter_inappwebview: dependency: "direct main" description: name: flutter_inappwebview - sha256: f73505c792cf083d5566e1a94002311be497d984b5607f25be36d685cf6361cf + sha256: "274edbb07196944e316722d9f6f641c77d0e71261200869887e10f59614c0458" url: "https://pub.dev" source: hosted - version: "5.7.2+3" - flutter_keyboard_visibility: + version: "6.1.3" + flutter_inappwebview_android: dependency: transitive description: - name: flutter_keyboard_visibility - sha256: "4983655c26ab5b959252ee204c2fffa4afeb4413cd030455194ec0caa3b8e7cb" + name: flutter_inappwebview_android + sha256: f48203a11c5eb0c23dd5a3cb3638ae678056b6ceae22819373e36c6cb4f1d46a url: "https://pub.dev" source: hosted - version: "5.4.1" - flutter_keyboard_visibility_linux: + version: "1.1.1" + flutter_inappwebview_internal_annotations: dependency: transitive description: - name: flutter_keyboard_visibility_linux - sha256: "6fba7cd9bb033b6ddd8c2beb4c99ad02d728f1e6e6d9b9446667398b2ac39f08" + name: flutter_inappwebview_internal_annotations + sha256: "5f80fd30e208ddded7dbbcd0d569e7995f9f63d45ea3f548d8dd4c0b473fb4c8" url: "https://pub.dev" source: hosted - version: "1.0.0" - flutter_keyboard_visibility_macos: + version: "1.1.1" + flutter_inappwebview_ios: dependency: transitive description: - name: flutter_keyboard_visibility_macos - sha256: c5c49b16fff453dfdafdc16f26bdd8fb8d55812a1d50b0ce25fc8d9f2e53d086 + name: flutter_inappwebview_ios + sha256: f6f88d464b38f2fc1c5f2ae74024498115eb1470715bd8b40f902dd4ac99ccc8 url: "https://pub.dev" source: hosted - version: "1.0.0" - flutter_keyboard_visibility_platform_interface: + version: "1.1.1" + flutter_inappwebview_macos: dependency: transitive description: - name: flutter_keyboard_visibility_platform_interface - sha256: e43a89845873f7be10cb3884345ceb9aebf00a659f479d1c8f4293fcb37022a4 + name: flutter_inappwebview_macos + sha256: "68e0c3785d8d789710cda7d7efe6effa337c91bf300dd28af7efc2d358fa1a98" url: "https://pub.dev" source: hosted - version: "2.0.0" - flutter_keyboard_visibility_web: + version: "1.1.1" + flutter_inappwebview_platform_interface: dependency: transitive description: - name: flutter_keyboard_visibility_web - sha256: d3771a2e752880c79203f8d80658401d0c998e4183edca05a149f5098ce6e3d1 + name: flutter_inappwebview_platform_interface + sha256: "97b4ab116d949ede20c90c7e3d15d24afaf1b706cc0af96b060770293cd6c49d" url: "https://pub.dev" source: hosted - version: "2.0.0" - flutter_keyboard_visibility_windows: + version: "1.2.0" + flutter_inappwebview_web: dependency: transitive description: - name: flutter_keyboard_visibility_windows - sha256: fc4b0f0b6be9b93ae527f3d527fb56ee2d918cd88bbca438c478af7bcfd0ef73 + name: flutter_inappwebview_web + sha256: f7f97b6faa39416e4e86da1184edd4de6c27b271d036f0838ea3ff9a250a1de2 url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.1.1" + flutter_inappwebview_windows: + dependency: transitive + description: + name: flutter_inappwebview_windows + sha256: "86702d2109384311f8ea634855e90ee143b9bfabddd3858696d905a2c28808aa" + url: "https://pub.dev" + source: hosted + version: "0.4.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -834,55 +858,47 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7 + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" flutter_localizations: dependency: "direct main" description: flutter source: sdk version: "0.0.0" - flutter_mailer: - dependency: transitive - description: - name: flutter_mailer - sha256: a935e9caa842877e8ed56109afb75b86e6488edbcd4696a5ac02b327a48fcd8a - url: "https://pub.dev" - source: hosted - version: "2.1.1" flutter_native_splash: dependency: "direct main" description: name: flutter_native_splash - sha256: "558f10070f03ee71f850a78f7136ab239a67636a294a44a06b6b7345178edb1e" + sha256: edf39bcf4d74aca1eb2c1e43c3e445fd9f494013df7f0da752fefe72020eedc0 url: "https://pub.dev" source: hosted - version: "2.3.10" + version: "2.4.0" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: f185ac890306b5779ecbd611f52502d8d4d63d27703ef73161ca0407e815f02c + sha256: "8cf40eebf5dec866a6d1956ad7b4f7016e6c0cc69847ab946833b7d43743809f" url: "https://pub.dev" source: hosted - version: "2.0.16" + version: "2.0.19" flutter_riverpod: dependency: "direct main" description: name: flutter_riverpod - sha256: "4bce556b7ecbfea26109638d5237684538d4abc509d253e6c5c4c5733b360098" + sha256: "0f1974eff5bbe774bf1d870e406fc6f29e3d6f1c46bd9c58e7172ff68a785d7d" url: "https://pub.dev" source: hosted - version: "2.4.10" + version: "2.5.1" flutter_rust_bridge: dependency: transitive description: name: flutter_rust_bridge - sha256: e12415c3bce49bcbc3fed383f0ea41ad7d828f6cf0eccba0588ffa5a812fe522 + sha256: fac14d2dd67eeba29a20e5d99fac0d4d9fcd552cdf6bf4f8945f7679c6b07b1d url: "https://pub.dev" source: hosted - version: "1.82.1" + version: "2.1.0" flutter_secure_storage: dependency: "direct main" description: @@ -935,10 +951,10 @@ packages: dependency: "direct main" description: name: flutter_sharing_intent - sha256: "6eb896e6523b735e8230eeb206fd3b9f220f11ce879c2400a90b443147036ff9" + sha256: "785ffc391822641457f930eb477c91c2f598a888f50b8fbb40d481ee01c7e719" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" flutter_svg: dependency: "direct main" description: @@ -957,14 +973,6 @@ packages: description: flutter source: sdk version: "0.0.0" - fluttertoast: - dependency: transitive - description: - name: fluttertoast - sha256: "474f7d506230897a3cd28c965ec21c5328ae5605fc9c400cd330e9e9d6ac175c" - url: "https://pub.dev" - source: hosted - version: "8.2.2" form_validator: dependency: "direct main" description: @@ -977,10 +985,10 @@ packages: dependency: "direct dev" description: name: freezed - sha256: "6c5031daae12c7072b3a87eff98983076434b4889ef2a44384d0cae3f82372ba" + sha256: a434911f643466d78462625df76fd9eb13e57348ff43fe1f77bbe909522c67a1 url: "https://pub.dev" source: hosted - version: "2.4.6" + version: "2.5.2" freezed_annotation: dependency: "direct main" description: @@ -993,10 +1001,10 @@ packages: dependency: transitive description: name: frontend_server_client - sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "4.0.0" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -1030,18 +1038,18 @@ packages: dependency: "direct main" description: name: go_router - sha256: c5fa45fa502ee880839e3b2152d987c44abae26d064a2376d4aad434cf0f7b15 + sha256: "2ddb88e9ad56ae15ee144ed10e33886777eb5ca2509a914850a5faa7b52ff459" url: "https://pub.dev" source: hosted - version: "12.1.3" + version: "14.2.7" google_fonts: dependency: "direct main" description: name: google_fonts - sha256: f0b8d115a13ecf827013ec9fc883390ccc0e87a96ed5347a3114cac177ef18e8 + sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.2.1" graphs: dependency: transitive description: @@ -1094,10 +1102,18 @@ packages: dependency: "direct main" description: name: hooks_riverpod - sha256: "758b07eba336e3cbacbd81dba481f2228a14102083fdde07045e8514e8054c49" + sha256: "45b2030a18bcd6dbd680c2c91bc3b33e3fe7c323e3acb5ecec93a613e2fbaa8a" url: "https://pub.dev" source: hosted - version: "2.4.10" + version: "2.5.1" + hotreloader: + dependency: transitive + description: + name: hotreloader + sha256: ed56fdc1f3a8ac924e717257621d09e9ec20e308ab6352a73a50a1d7a4d9158e + url: "https://pub.dev" + source: hosted + version: "4.2.0" html: dependency: "direct main" description: @@ -1122,6 +1138,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + http_methods: + dependency: transitive + description: + name: http_methods + sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566" + url: "https://pub.dev" + source: hosted + version: "1.1.1" http_multi_server: dependency: transitive description: @@ -1142,42 +1166,42 @@ packages: dependency: transitive description: name: image - sha256: "028f61960d56f26414eb616b48b04eb37d700cbe477b7fb09bf1d7ce57fd9271" + sha256: "4c68bfd5ae83e700b5204c1e74451e7bf3cf750e6843c6e158289cf56bda018e" url: "https://pub.dev" source: hosted - version: "4.1.3" + version: "4.1.7" image_picker: dependency: "direct main" description: name: image_picker - sha256: "7d7f2768df2a8b0a3cefa5ef4f84636121987d403130e70b17ef7e2cf650ba84" + sha256: fe9ee64ccb8d599a5dfb0e21cc6652232c610bcf667af4e79b9eb175cc30a7a5 url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.1.0" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: "47da2161c2e9f8f8a9cbbd89d466d174333fbdd769aeed848912e0b16d9cb369" + sha256: "8e75431a62b7feb4fd55cb4a5c6f0ac4564460ec5dc09f9c4a0d50a5ce7c4cb9" url: "https://pub.dev" source: hosted - version: "0.8.8" + version: "0.8.10" image_picker_for_web: dependency: transitive description: name: image_picker_for_web - sha256: "50bc9ae6a77eea3a8b11af5eb6c661eeb858fdd2f734c2a4fd17086922347ef7" + sha256: "5d6eb13048cd47b60dbf1a5495424dea226c5faf3950e20bf8120a58efb5b5f3" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.4" image_picker_ios: dependency: transitive description: name: image_picker_ios - sha256: c5538cacefacac733c724be7484377923b476216ad1ead35a0d2eadcdc0fc497 + sha256: f4a6f62be96d6fd268f32a6bf8ef444cd8e3fff64d16923c6e6fe55e0c84a761 url: "https://pub.dev" source: hosted - version: "0.8.8+2" + version: "0.8.10" image_picker_linux: dependency: transitive description: @@ -1198,10 +1222,10 @@ packages: dependency: transitive description: name: image_picker_platform_interface - sha256: ed9b00e63977c93b0d2d2b343685bed9c324534ba5abafbb3dfbd6a780b1b514 + sha256: "9ec26d410ff46f483c5519c29c02ef0e02e13a543f882b152d4bfd2f06802f80" url: "https://pub.dev" source: hosted - version: "2.9.1" + version: "2.10.0" image_picker_windows: dependency: transitive description: @@ -1219,20 +1243,12 @@ packages: dependency: "direct main" description: name: intl - sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf url: "https://pub.dev" source: hosted - version: "0.18.1" - introduction_screen: - dependency: "direct main" - description: - name: introduction_screen - sha256: ef5a5479a8e06a84b9a7eff16c698b9b82f70cd1b6203b264bc3686f9bfb77e2 - url: "https://pub.dev" - source: hosted - version: "3.1.11" + version: "0.19.0" io: - dependency: transitive + dependency: "direct dev" description: name: io sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" @@ -1271,38 +1287,30 @@ packages: url: "https://pub.dev" source: hosted version: "6.7.1" - json_view: - dependency: transitive - description: - name: json_view - sha256: "905c69f9e69d1eab5406b87ab6c10c3706c04c70c6a4959621bd2b43c2d27374" - url: "https://pub.dev" - source: hosted - version: "0.4.2" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.1" lints: dependency: transitive description: @@ -1312,21 +1320,21 @@ packages: source: hosted version: "3.0.0" local_notifier: - dependency: transitive + dependency: "direct main" description: name: local_notifier - sha256: cc855aa6362c8840e3d3b35b1c3b058a3a8becdb2b03d5a9aa3f3a1e861f0a03 + sha256: f6cfc933c6fbc961f4e52b5c880f68e41b2d3cd29aad557cc654fd211093a025 url: "https://pub.dev" source: hosted - version: "0.1.5" + version: "0.1.6" logger: dependency: "direct main" description: name: logger - sha256: ba3bc83117b2b49bdd723c0ea7848e8285a0fbc597ba09203b20d329d020c24a + sha256: "8c94b8c219e7e50194efc8771cd0e9f10807d8d3e219af473d89b06cc2ee4e04" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.2.0" logging: dependency: transitive description: @@ -1335,14 +1343,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" - mailer: - dependency: transitive + lrc: + dependency: "direct main" description: - name: mailer - sha256: "57f6dd1496699999a7bfd0aa6be0645384f477f4823e16d4321c40a434346382" + name: lrc + sha256: "5100362b5c8e97f4d3f03ff87efeb40e73a6dd780eca2cbde9312e0d44b8e5ba" url: "https://pub.dev" source: hosted - version: "6.0.1" + version: "1.0.2" matcher: dependency: transitive description: @@ -1355,34 +1363,34 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" media_kit: dependency: "direct main" description: name: media_kit - sha256: "92c7f59e075d74471b31e703f81ccc1d7102739ebcce945b30a6417fa2f751d5" + sha256: "3289062540e3b8b9746e5c50d95bd78a9289826b7227e253dff806d002b9e67a" url: "https://pub.dev" source: hosted - version: "1.1.7" + version: "1.1.10+1" media_kit_libs_android_audio: dependency: transitive description: name: media_kit_libs_android_audio - sha256: "1d9ba8814e58a12ae69014884abf96893d38e444abfb806ff38896dfe089dd15" + sha256: "3d2df5c09d3f3ff7c55b53bf955e46712f76483e77562a5a017439a3ea85ce88" url: "https://pub.dev" source: hosted - version: "1.3.5" + version: "1.3.6" media_kit_libs_audio: dependency: "direct main" description: name: media_kit_libs_audio - sha256: "9ccf9019edc220b8d0bfa1f53a91aa2cf2506ac860b4d5774c9d6bbdd49796ca" + sha256: f3f91df69848005363b3ae0ef7971a90edbd80a9365195684ef26c9a6ac8833f url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" media_kit_libs_ios_audio: dependency: transitive description: @@ -1423,46 +1431,39 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + menu_base: + dependency: transitive + description: + name: menu_base + sha256: "820368014a171bd1241030278e6c2617354f492f5c703d7b7d4570a6b8b84405" + url: "https://pub.dev" + source: hosted + version: "0.1.1" meta: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.15.0" metadata_god: dependency: "direct main" description: - name: metadata_god - sha256: cf13931c39eba0b9443d16e8940afdabee125bf08945f18d4c0d02bcae2a3317 - url: "https://pub.dev" - source: hosted - version: "0.5.2+1" + path: "packages/metadata_god" + ref: cargokit + resolved-ref: "331636d8e378e3ac9ad30a4b0d3eed17d5a85fe9" + url: "https://github.com/KRTirtho/frb_plugins.git" + source: git + version: "0.5.3" mime: dependency: "direct main" description: name: mime - sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" url: "https://pub.dev" source: hosted - version: "1.0.4" - mutex: - dependency: transitive - description: - name: mutex - sha256: "03116a4e46282a671b46c12de649d72c0ed18188ffe12a8d0fc63e83f4ad88f4" - url: "https://pub.dev" - source: hosted - version: "3.0.1" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" + version: "1.0.5" oauth2: dependency: transitive description: @@ -1491,10 +1492,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "6ff267fcd9d48cb61c8df74a82680e8b82e940231bb5f68356672fde0397334a" + sha256: cb44f49b6e690fa766f023d5b22cac6b9affe741dd792b6ac7ad4fabe0d7b097 url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "6.0.0" package_info_plus_platform_interface: dependency: transitive description: @@ -1539,26 +1540,26 @@ packages: dependency: "direct main" description: name: path_provider - sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa + sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.3" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "6b8b19bd80da4f11ce91b2d1fb931f3006911477cec227cce23d3253d80df3f1" + sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.4" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d" + sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" path_provider_linux: dependency: transitive description: @@ -1571,10 +1572,10 @@ packages: dependency: transitive description: name: path_provider_platform_interface - sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" path_provider_windows: dependency: transitive description: @@ -1587,42 +1588,50 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: "284a66179cabdf942f838543e10413246f06424d960c92ba95c84439154fcac8" + sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" url: "https://pub.dev" source: hosted - version: "11.0.1" + version: "11.3.1" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: ace7d15a3d1a4a0b91c041d01e5405df221edb9de9116525efc773c74e6fc790 + sha256: "1acac6bae58144b442f11e66621c062aead9c99841093c38f5bcdcc24c1c3474" url: "https://pub.dev" source: hosted - version: "11.0.5" + version: "12.0.5" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: "99e220bce3f8877c78e4ace901082fb29fa1b4ebde529ad0932d8d664b34f3f5" + sha256: e9ad66020b89ff1b63908f247c2c6f931c6e62699b756ef8b3c4569350cd8662 url: "https://pub.dev" source: hosted - version: "9.1.4" + version: "9.4.4" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "54bf176b90f6eddd4ece307e2c06cf977fb3973719c35a93b85cc7093eb6070d" + url: "https://pub.dev" + source: hosted + version: "0.1.1" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: f2343e9fa9c22ae4fd92d4732755bfe452214e7189afcc097380950cf567b4b2 + sha256: "48d4fcf201a1dad93ee869ab0d4101d084f49136ec82a8a06ed9cfeacab9fd20" url: "https://pub.dev" source: hosted - version: "3.11.5" + version: "4.2.1" permission_handler_windows: dependency: transitive description: name: permission_handler_windows - sha256: cc074aace208760f1eee6aa4fae766b45d947df85bc831cde77009cdb4720098 + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" url: "https://pub.dev" source: hosted - version: "0.1.3" + version: "0.2.1" petitparser: dependency: transitive description: @@ -1634,36 +1643,35 @@ packages: piped_client: dependency: "direct main" description: - path: "." - ref: HEAD - resolved-ref: "64631732eefe3d93889756dc2e4ff5c8523ed763" - url: "https://github.com/KRTirtho/piped_client.git" - source: git + name: piped_client + sha256: "87b04b2ebf4e008cfbb0ac85e9920ab3741f5aa697be2dd44919658a3297a4bc" + url: "https://pub.dev" + source: hosted version: "0.1.1" platform: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" plugin_platform_interface: dependency: transitive description: name: plugin_platform_interface - sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" url: "https://pub.dev" source: hosted - version: "2.1.6" + version: "2.1.8" pointycastle: dependency: transitive description: name: pointycastle - sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" url: "https://pub.dev" source: hosted - version: "3.7.3" + version: "3.9.1" pool: dependency: transitive description: @@ -1676,10 +1684,10 @@ packages: dependency: "direct main" description: name: popover - sha256: "59f4a55ebb484d012c8aaa273ad58eee571945231b71fb938c5a69f63b5a94d4" + sha256: ca3bef9d88ebf5c5c3823946a5de3ce8360018fbb6a3e25819586a7d5a203db2 url: "https://pub.dev" source: hosted - version: "0.2.8+2" + version: "0.3.0" process: dependency: transitive description: @@ -1688,22 +1696,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.2" - provider: - dependency: transitive + process_run: + dependency: "direct dev" description: - name: provider - sha256: cdbe7530b12ecd9eb455bdaa2fcb8d4dad22e80b8afb4798b41479d5ce26847f + name: process_run + sha256: "8d9c6198b98fbbfb511edd42e7364e24d85c163e47398919871b952dc86a423e" url: "https://pub.dev" source: hosted - version: "6.0.5" + version: "0.14.2" pub_api_client: dependency: "direct dev" description: name: pub_api_client - sha256: d456816ef5142906a22dc56e37be6bef6cb0276f0a26c11d1f7d277868202e71 + sha256: cc3d2c93df3823553de6a3e7d3ac09a3f43f8c271af4f43c2795266090ac9625 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.7.0" pub_semver: dependency: transitive description: @@ -1724,18 +1732,10 @@ packages: dependency: "direct dev" description: name: pubspec_parse - sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 + sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 url: "https://pub.dev" source: hosted - version: "1.2.3" - puppeteer: - dependency: transitive - description: - name: puppeteer - sha256: eedeaae6ec5d2e54f9ae22ab4d6b3dda2e8791c356cc783046d06c287ffe11d8 - url: "https://pub.dev" - source: hosted - version: "3.6.0" + version: "1.3.0" quiver: dependency: transitive description: @@ -1744,14 +1744,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" - riverpod: + recase: dependency: transitive description: - name: riverpod - sha256: "548e2192eb7aeb826eb89387f814edb76594f3363e2c0bb99dd733d795ba3589" + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "4.1.0" + riverpod: + dependency: "direct main" + description: + name: riverpod + sha256: f21b32ffd26a36555e501b04f4a5dca43ed59e16343f1a30c13632b2351dfa4d + url: "https://pub.dev" + source: hosted + version: "2.5.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "8b71f03fc47ae27d13769496a1746332df4cec43918aeba9aff1e232783a780f" + url: "https://pub.dev" + source: hosted + version: "0.5.1" + riverpod_lint: + dependency: "direct dev" + description: + name: riverpod_lint + sha256: "3c67c14ccd16f0c9d53e35ef70d06cd9d072e2fb14557326886bbde903b230a5" + url: "https://pub.dev" + source: hosted + version: "2.3.10" rxdart: dependency: transitive description: @@ -1793,38 +1817,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" - sentry: - dependency: transitive - description: - name: sentry - sha256: "39c23342fc96105da449914f7774139a17a0ca8a4e70d9ad5200171f7e47d6ba" - url: "https://pub.dev" - source: hosted - version: "7.9.0" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" + sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" + sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "7bf53a9f2d007329ee6f3df7268fd498f8373602f943c975598bbb34649b62a7" + sha256: "7708d83064f38060c7b39db12aefe449cb8cdc031d6062280087bc4cdb988f5c" url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.3.5" shared_preferences_linux: dependency: transitive description: @@ -1837,18 +1853,18 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: d4ec5fc9ebb2f2e056c617112aa75dcf92fc2e4faaf2ae999caa297473f75d8a + sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - sha256: d762709c2bbe80626ecc819143013cc820fa49ca5e363620ee20a8b15a3e3daf + sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.0" shared_preferences_windows: dependency: transitive description: @@ -1858,61 +1874,61 @@ packages: source: hosted version: "2.3.2" shelf: - dependency: transitive + dependency: "direct main" description: name: shelf sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 url: "https://pub.dev" source: hosted version: "1.4.1" - shelf_static: - dependency: transitive + shelf_router: + dependency: "direct main" description: - name: shelf_static - sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + name: shelf_router + sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864 url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.4" shelf_web_socket: - dependency: transitive + dependency: "direct main" description: name: shelf_web_socket - sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "2.0.0" + shortid: + dependency: transitive + description: + name: shortid + sha256: d0b40e3dbb50497dad107e19c54ca7de0d1a274eb9b4404991e443dadb9ebedb + url: "https://pub.dev" + source: hosted + version: "0.1.2" sidebarx: dependency: "direct main" description: name: sidebarx - sha256: "7042d64844b8e64ca5c17e70d89b49df35b54a26c015b90000da9741eab70bc0" + sha256: abe39d6db237fb8e25c600e8039ffab80fa7fe71acab03e9c378c31f912d2766 url: "https://pub.dev" source: hosted - version: "0.16.3" + version: "0.17.1" simple_icons: dependency: "direct main" description: name: simple_icons - sha256: "8aa6832dc7a263a3213e40ecbf1328a392308c809d534a3b860693625890483b" + sha256: "30067d70a9d72923fbc80e142e17fa46085dfa970e66bc4bede3be4819d05901" url: "https://pub.dev" source: hosted - version: "7.10.0" - skeleton_text: - dependency: "direct main" - description: - name: skeleton_text - sha256: bacd536bf0664efe1cae53bcbd78c3d4040a120f300f69dc85d83f358471cc6c - url: "https://pub.dev" - source: hosted - version: "3.0.1" + version: "10.1.3" skeletonizer: dependency: "direct main" description: name: skeletonizer - sha256: ff4c36e826efd5288d7a84e7619a6e9be8185d3064cecf101a9133762f3b401b + sha256: "9a3ae2f4ee4349bdbed3292d04586a1315a44745d2c454684f82f0c46dbeabf9" url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "1.1.1" sky_engine: dependency: transitive description: flutter @@ -1929,19 +1945,20 @@ packages: smtc_windows: dependency: "direct main" description: - name: smtc_windows - sha256: aba2bad5ddfaf595496db04df3d9fdb54fb128fc1f39c8f024945a67455388fe - url: "https://pub.dev" - source: hosted - version: "0.1.1" + path: "packages/smtc_windows" + ref: cargokit + resolved-ref: "331636d8e378e3ac9ad30a4b0d3eed17d5a85fe9" + url: "https://github.com/KRTirtho/frb_plugins.git" + source: git + version: "0.1.3" source_gen: dependency: transitive description: name: source_gen - sha256: fc0da689e5302edb6177fdd964efcb7f58912f43c28c2047a808f5bfff643d16 + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" source_helper: dependency: transitive description: @@ -1962,26 +1979,58 @@ packages: dependency: "direct main" description: name: spotify - sha256: "2308a84511c18ec1e72515a57e28abb1467389549d571c460732b4538c2e34de" + sha256: "705f09a457a893973451c15f4072670ac4783d67e42c35c080c55a48dee3a01f" url: "https://pub.dev" source: hosted - version: "0.13.3" + version: "0.13.7" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" sqflite: dependency: transitive description: name: sqflite - sha256: "591f1602816e9c31377d5f008c2d9ef7b8aca8941c3f89cc5fd9d84da0c38a9a" + sha256: "5ce2e1a15e822c3b4bfb5400455775e421da7098eed8adc8f26298ada7c9308c" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.3.3" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "1b92f368f44b0dee2425bb861cfa17b6f6cf3961f762ff6f941d20b33355660a" + sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.5.4" + sqlite3: + dependency: "direct main" + description: + name: sqlite3 + sha256: b384f598b813b347c5a7e5ffad82cbaff1bec3d1561af267041e66f6f0899295 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqlite3_flutter_libs: + dependency: "direct main" + description: + name: sqlite3_flutter_libs + sha256: "9f89a7e7dc36eac2035808427eba1c3fbd79e59c3a22093d8dace6d36b1fe89e" + url: "https://pub.dev" + source: hosted + version: "0.5.23" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: ade9a67fd70d0369329ed3373208de7ebd8662470e8c396fc8d0d60f9acdfc9f + url: "https://pub.dev" + source: hosted + version: "0.36.0" stack_trace: dependency: transitive description: @@ -1994,10 +2043,10 @@ packages: dependency: transitive description: name: state_notifier - sha256: "8fe42610f179b843b12371e40db58c9444f8757f8b69d181c97e50787caed289" + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb url: "https://pub.dev" source: hosted - version: "0.7.2+1" + version: "1.0.0" stream_channel: dependency: transitive description: @@ -2042,10 +2091,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60" + sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.0+1" system_theme: dependency: "direct main" description: @@ -2062,14 +2111,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.2" - system_tray: - dependency: "direct overridden" - description: - name: system_tray - sha256: "087edb877f22f286d82d42f330fa640138c192e98aa9d20c2b83aa4e406bb432" - url: "https://pub.dev" - source: hosted - version: "2.0.2" term_glyph: dependency: transitive description: @@ -2082,18 +2123,26 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.2" time: dependency: transitive description: name: time - sha256: "83427e11d9072e038364a5e4da559e85869b227cf699a541be0da74f14140124" + sha256: ad8e018a6c9db36cb917a031853a1aae49467a93e0d464683e029537d848c221 url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0" + url: "https://pub.dev" + source: hosted + version: "0.9.2" timing: dependency: transitive description: @@ -2110,14 +2159,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - tuple: - dependency: transitive + tray_manager: + dependency: "direct main" description: - name: tuple - sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + name: tray_manager + sha256: e0ac9a88b2700f366b8629b97e8663b6ef450a2f169560a685dc167bfe9c9c29 url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "0.2.2" + type_plus: + dependency: transitive + description: + name: type_plus + sha256: d5d1019471f0d38b91603adb9b5fd4ce7ab903c879d2fbf1a3f80a630a03fcc9 + url: "https://pub.dev" + source: hosted + version: "2.1.1" typed_data: dependency: transitive description: @@ -2162,74 +2219,74 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "47e208a6711459d813ba18af120d9663c20bdf6985d6ad39fe165d2538378d27" + sha256: "6ce1e04375be4eed30548f10a315826fd933c1e493206eab82eed01f438c8d2e" url: "https://pub.dev" source: hosted - version: "6.1.14" + version: "6.2.6" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: b04af59516ab45762b2ca6da40fa830d72d0f6045cd97744450b73493fa76330 + sha256: "360a6ed2027f18b73c8d98e159dda67a61b7f2e0f6ec26e86c3ada33b0621775" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.3.1" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "7c65021d5dee51813d652357bc65b8dd4a6177082a9966bc8ba6ee477baa795f" + sha256: "9149d493b075ed740901f3ee844a38a00b33116c7c5c10d7fb27df8987fb51d5" url: "https://pub.dev" source: hosted - version: "6.1.5" + version: "6.2.5" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: b651aad005e0cb06a01dbd84b428a301916dc75f0e7ea6165f80057fee2d8e8e + sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.1.1" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: b55486791f666e62e0e8ff825e58a023fd6b1f71c49926483f1128d3bbd8fe88 + sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "3.1.0" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface - sha256: "95465b39f83bfe95fcb9d174829d6476216f2d548b79c38ab2506e0458787618" + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.3.2" url_launcher_web: dependency: transitive description: name: url_launcher_web - sha256: "2942294a500b4fa0b918685aff406773ba0a4cd34b7f42198742a94083020ce5" + sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a" url: "https://pub.dev" source: hosted - version: "2.0.20" + version: "2.3.1" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "95fef3129dc7cfaba2bc3d5ba2e16063bb561fc6d78e63eee16162bc70029069" + sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 url: "https://pub.dev" source: hosted - version: "3.0.8" + version: "3.1.1" uuid: dependency: "direct main" description: name: uuid - sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "4.5.1" vector_math: dependency: transitive description: @@ -2266,10 +2323,10 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "14.2.5" watcher: dependency: transitive description: @@ -2279,21 +2336,29 @@ packages: source: hosted version: "1.1.0" web: - dependency: transitive + dependency: "direct overridden" description: name: web - sha256: "1d9158c616048c38f712a6646e317a3426da10e884447626167240d45209cbad" + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb url: "https://pub.dev" source: hosted - version: "0.5.0" - web_socket_channel: + version: "1.1.0" + web_socket: dependency: transitive description: - name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + name: web_socket + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "0.1.6" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + url: "https://pub.dev" + source: hosted + version: "3.0.1" webdriver: dependency: transitive description: @@ -2314,45 +2379,36 @@ packages: dependency: transitive description: name: win32 - sha256: "9e82a402b7f3d518fb9c02d0e9ae45952df31b9bf34d77baf19da2de03fc2aaa" + sha256: "68d1e89a91ed61ad9c370f9f8b6effed9ae5e0ede22a270bdfa6daf79fc2290a" url: "https://pub.dev" source: hosted - version: "5.0.7" + version: "5.5.4" win32_registry: dependency: "direct main" description: name: win32_registry - sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a" + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.5" window_manager: dependency: "direct main" description: name: window_manager - sha256: "6ee795be9124f90660ea9d05e581a466de19e1c89ee74fc4bf528f60c8600edd" + sha256: "8699323b30da4cdbe2aa2e7c9de567a6abd8a97d9a5c850a3c86dcd0b34bbfbf" url: "https://pub.dev" source: hosted - version: "0.3.6" - window_size: - dependency: "direct main" - description: - path: "plugins/window_size" - ref: a738913c8ce2c9f47515382d40827e794a334274 - resolved-ref: a738913c8ce2c9f47515382d40827e794a334274 - url: "https://github.com/google/flutter-desktop-embedding.git" - source: git - version: "0.1.0" + version: "0.3.9" xdg_directories: dependency: transitive description: name: xdg_directories - sha256: "589ada45ba9e39405c198fe34eb0f607cddb2108527e658136120892beac46d2" + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" xml: - dependency: transitive + dependency: "direct dev" description: name: xml sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 @@ -2371,10 +2427,10 @@ packages: dependency: "direct main" description: name: youtube_explode_dart - sha256: "98fd11b51adbbca76cbdb17f560168f1d7a9835cecceea965f49eb1e5eed155c" + sha256: "28dca07fefb4b6518beab95f0c1ef81031f921ed0fe87ebcd9c51378546edfee" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.2.3" sdks: - dart: ">=3.3.0 <4.0.0" - flutter: ">=3.13.0" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/pubspec.yaml b/pubspec.yaml index 5ccc5bb1..df8e668d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Open source Spotify client that doesn't require Premium nor uses El publish_to: "none" -version: 3.4.1+28 +version: 3.8.3+37 homepage: https://spotube.krtirtho.dev repository: https://github.com/KRTirtho/spotube @@ -13,99 +13,90 @@ environment: flutter: ">=3.10.0" dependencies: - args: ^2.3.2 + args: ^2.5.0 async: ^2.9.0 - audio_service: ^0.18.9 - audio_session: ^0.1.18 + audio_service: ^0.18.13 + audio_service_mpris: ^0.1.5 + audio_session: ^0.1.19 auto_size_text: ^3.0.0 - buttons_tabbar: ^1.3.6 - cached_network_image: ^3.3.0 - catcher_2: 1.0.0 - collection: ^1.15.0 - cupertino_icons: ^1.0.5 + buttons_tabbar: ^1.3.8 + cached_network_image: ^3.3.1 + collection: ^1.18.0 curved_navigation_bar: ^1.0.3 - dbus: ^0.7.8 - device_info_plus: ^9.0.3 - device_preview: ^1.1.0 - dio: ^5.4.1 - disable_battery_optimization: ^1.1.0+1 + desktop_webview_window: + git: + url: https://github.com/KRTirtho/flutter-plugins.git + ref: feat/cookies + path: packages/desktop_webview_window + device_info_plus: ^10.1.0 + dio: ^5.4.3+1 + disable_battery_optimization: ^1.1.1 duration: ^3.0.12 - envied: ^0.3.0 - file_selector: ^1.0.1 - fl_query: ^1.0.0 - fl_query_hooks: ^1.0.0 - fl_query_devtools: ^0.1.0 - fluentui_system_icons: ^1.1.189 + envied: ^0.5.4+1 + file_picker: ^8.0.0+1 + file_selector: ^1.0.3 + fluentui_system_icons: ^1.1.234 flutter: sdk: flutter flutter_cache_manager: ^3.3.0 - flutter_desktop_tools: - git: - url: https://github.com/KRTirtho/flutter_desktop_tools.git - ref: 1f0bec3283626dcbd8ee2f54e238d096d8dea50e flutter_displaymode: ^0.6.0 flutter_feather_icons: ^2.0.0+1 - flutter_hooks: ^0.20.0 - flutter_inappwebview: ^5.7.2+3 + flutter_hooks: ^0.20.5 + flutter_inappwebview: ^6.1.3 flutter_localizations: sdk: flutter - flutter_native_splash: ^2.3.10 - flutter_riverpod: ^2.4.10 + flutter_native_splash: ^2.4.0 + flutter_riverpod: ^2.5.1 flutter_secure_storage: ^9.0.0 flutter_svg: ^1.1.6 form_validator: ^2.1.1 fuzzywuzzy: ^1.1.6 - go_router: 12.1.3 # Stuck on this https://github.com/flutter/flutter/issues/140869 - google_fonts: ^6.1.0 + google_fonts: ^6.2.1 hive: ^2.2.3 hive_flutter: ^1.1.0 - hooks_riverpod: ^2.4.3 + hooks_riverpod: ^2.5.1 html: ^0.15.1 - http: ^1.2.0 - image_picker: ^1.0.4 - intl: ^0.18.0 - introduction_screen: ^3.0.2 + image_picker: ^1.1.0 + intl: any json_annotation: ^4.8.1 logger: ^2.0.2 - media_kit: ^1.1.3 - media_kit_libs_audio: ^1.0.3 - metadata_god: ^0.5.2+1 - mime: ^1.0.2 - package_info_plus: ^4.1.0 - palette_generator: ^0.3.3 - path: ^1.8.0 - path_provider: ^2.0.8 - permission_handler: ^11.0.1 - piped_client: + media_kit: ^1.1.10+1 + media_kit_libs_audio: ^1.0.4 + metadata_god: git: - url: https://github.com/KRTirtho/piped_client.git - popover: ^0.2.6+3 + url: https://github.com/KRTirtho/frb_plugins.git + path: packages/metadata_god + ref: cargokit + mime: ^1.0.2 + package_info_plus: ^6.0.0 + palette_generator: ^0.3.3 + path: ^1.9.0 + path_provider: ^2.1.3 + permission_handler: ^11.3.1 + piped_client: ^0.1.1 + popover: ^0.3.0 scrobblenaut: git: url: https://github.com/KRTirtho/scrobblenaut.git ref: dart-3-support scroll_to_index: ^3.0.1 - sidebarx: ^0.16.3 - shared_preferences: ^2.2.2 - skeleton_text: ^3.0.1 - smtc_windows: ^0.1.1 + sidebarx: ^0.17.1 + shared_preferences: ^2.2.3 + smtc_windows: + git: + url: https://github.com/KRTirtho/frb_plugins.git + path: packages/smtc_windows + ref: cargokit stroke_text: ^0.0.2 system_theme: ^2.1.0 titlebar_buttons: ^1.0.0 - url_launcher: ^6.1.7 - uuid: ^3.0.7 + url_launcher: ^6.2.6 + uuid: ^4.4.0 version: ^3.0.2 visibility_detector: ^0.4.0+2 - window_manager: ^0.3.1 - window_size: - git: - url: https://github.com/google/flutter-desktop-embedding.git - ref: a738913c8ce2c9f47515382d40827e794a334274 - path: plugins/window_size - youtube_explode_dart: ^2.0.1 - simple_icons: ^7.10.0 - audio_service_mpris: ^0.1.0 - file_picker: ^6.0.0 + window_manager: ^0.3.9 + youtube_explode_dart: ^2.2.3 + simple_icons: ^10.1.3 jiosaavn: ^0.1.0 draggable_scrollbar: git: @@ -114,24 +105,42 @@ dependencies: very_good_infinite_list: ^0.7.1 gap: ^3.0.1 sliver_tools: ^0.2.12 - dart_discord_rpc: + flutter_discord_rpc: git: - url: https://github.com/Tommypop2/dart_discord_rpc.git + url: https://github.com/KRTirtho/frb_plugins.git + path: packages/flutter_discord_rpc + ref: cargokit html_unescape: ^2.0.0 wikipedia_api: ^0.1.0 - skeletonizer: ^0.8.0 - app_links: ^3.5.0 - win32_registry: ^1.1.2 + skeletonizer: ^1.1.1 + app_links: ^6.3.2 + win32_registry: ^1.1.5 flutter_sharing_intent: ^1.1.0 flutter_broadcasts: ^0.4.0 freezed_annotation: ^2.4.1 - spotify: ^0.13.3 + spotify: ^0.13.7 + bonsoir: ^5.1.10 + shelf: ^1.4.1 + shelf_router: ^1.1.4 + shelf_web_socket: ^2.0.0 + web_socket_channel: ^3.0.1 + lrc: ^1.0.2 + timezone: ^0.9.2 + local_notifier: ^0.1.6 + tray_manager: ^0.2.2 + http: ^1.2.1 + riverpod: ^2.5.1 + drift: ^2.18.0 + sqlite3_flutter_libs: ^0.5.23 + sqlite3: ^2.4.3 + encrypt: ^5.0.3 + go_router: ^14.2.7 dev_dependencies: - build_runner: ^2.3.2 - envied_generator: ^0.3.0+3 - flutter_distributor: ^0.0.2 - flutter_gen_runner: ^5.1.0+1 + build_runner: ^2.4.9 + crypto: ^3.0.3 + envied_generator: ^0.5.4+1 + flutter_gen_runner: ^5.4.0 flutter_launcher_icons: ^0.13.1 flutter_lints: ^3.0.1 flutter_test: @@ -140,12 +149,18 @@ dev_dependencies: sdk: flutter hive_generator: ^2.0.0 json_serializable: ^6.6.2 - pub_api_client: ^2.4.0 - pubspec_parse: ^1.2.2 - freezed: ^2.4.6 + freezed: ^2.5.2 + custom_lint: ^0.6.4 + riverpod_lint: ^2.3.10 + process_run: ^0.14.2 + pubspec_parse: ^1.3.0 + pub_api_client: ^2.7.0 + xml: ^6.5.0 + io: ^1.0.4 + drift_dev: ^2.18.0 dependency_overrides: - system_tray: 2.0.2 + web: ^1.1.0 flutter: generate: true diff --git a/scripts/windows-setup-creator.iss b/scripts/windows-setup-creator.iss deleted file mode 100644 index 93302234..00000000 --- a/scripts/windows-setup-creator.iss +++ /dev/null @@ -1,59 +0,0 @@ -; Script generated by the Inno Setup Script Wizard. -; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! - -#define MyAppName "Spotube" -#define MyAppVersion "2.0.0" -#define MyAppPublisher "KRTirtho, OSS" -#define MyAppURL "https://github.com/KRTirtho/spotube" -#define MyAppExeName "spotube.exe" - -[Setup] -; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. -; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) -AppId={{80B901C8-D6FE-494E-8AF7-A2BD440E8644} -AppName={#MyAppName} -AppVersion={#MyAppVersion} -;AppVerName={#MyAppName} {#MyAppVersion} -AppPublisher={#MyAppPublisher} -AppPublisherURL={#MyAppURL} -AppSupportURL={#MyAppURL} -AppUpdatesURL={#MyAppURL} -DefaultDirName={autopf}\{#MyAppName} -DisableProgramGroupPage=yes -; Remove the following line to run in administrative install mode (install for all users.) -PrivilegesRequired=lowest -PrivilegesRequiredOverridesAllowed=dialog -OutputDir=..\build\installer -OutputBaseFilename=Spotube-windows-x86_64-setup -SetupIconFile=..\windows\runner\resources\app_icon.ico -Compression=lzma -SolidCompression=yes -WizardStyle=modern - -[Languages] -Name: "english"; MessagesFile: "compiler:Default.isl" - -[Tasks] -Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked - -[Files] -Source: "..\build\windows\runner\Release\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\bitsdojo_window_windows_plugin.lib"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\flutter_windows.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\hotkey_manager_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\libwinmedia.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\libwinmedia_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\permission_handler_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\spotube.exp"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\spotube.lib"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\url_launcher_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs -; NOTE: Don't use "Flags: ignoreversion" on any shared system files - -[Icons] -Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" -Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon - -[Run] -Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent - diff --git a/server/.pocketbase b/server/.pocketbase deleted file mode 100644 index bcb8312e..00000000 --- a/server/.pocketbase +++ /dev/null @@ -1 +0,0 @@ -version=0.12.1 \ No newline at end of file diff --git a/server/pb_migrations/1675256468_created_tracks.js b/server/pb_migrations/1675256468_created_tracks.js deleted file mode 100644 index 46d03fbb..00000000 --- a/server/pb_migrations/1675256468_created_tracks.js +++ /dev/null @@ -1,63 +0,0 @@ -migrate((db) => { - const collection = new Collection({ - "id": "pevn93oxbnovw0s", - "created": "2023-02-01 13:01:08.893Z", - "updated": "2023-02-01 13:01:08.893Z", - "name": "tracks", - "type": "base", - "system": false, - "schema": [ - { - "system": false, - "id": "ycnix0ai", - "name": "spotify_id", - "type": "text", - "required": true, - "unique": false, - "options": { - "min": 20, - "max": 22, - "pattern": "" - } - }, - { - "system": false, - "id": "ih8fxzgh", - "name": "youtube_id", - "type": "text", - "required": true, - "unique": false, - "options": { - "min": 10, - "max": 11, - "pattern": "" - } - }, - { - "system": false, - "id": "vzvqgsjf", - "name": "votes", - "type": "number", - "required": true, - "unique": false, - "options": { - "min": null, - "max": null - } - } - ], - "listRule": null, - "viewRule": null, - "createRule": null, - "updateRule": null, - "deleteRule": null, - "options": {} - }); - - return Dao(db).saveCollection(collection); -}, (db) => { - const dao = new Dao(db); - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s"); - - return dao.deleteCollection(collection); -}) diff --git a/server/pb_migrations/1675256557_updated_tracks.js b/server/pb_migrations/1675256557_updated_tracks.js deleted file mode 100644 index cdcf19bc..00000000 --- a/server/pb_migrations/1675256557_updated_tracks.js +++ /dev/null @@ -1,17 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.listRule = "" - collection.viewRule = "" - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.listRule = null - collection.viewRule = null - - return dao.saveCollection(collection) -}) diff --git a/server/pb_migrations/1675256593_updated_users.js b/server/pb_migrations/1675256593_updated_users.js deleted file mode 100644 index 5643c3a0..00000000 --- a/server/pb_migrations/1675256593_updated_users.js +++ /dev/null @@ -1,19 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("_pb_users_auth_") - - collection.createRule = null - collection.updateRule = null - collection.deleteRule = null - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("_pb_users_auth_") - - collection.createRule = "" - collection.updateRule = "id = @request.auth.id" - collection.deleteRule = "id = @request.auth.id" - - return dao.saveCollection(collection) -}) diff --git a/server/pb_migrations/1675256678_updated_tracks.js b/server/pb_migrations/1675256678_updated_tracks.js deleted file mode 100644 index 4b472ad1..00000000 --- a/server/pb_migrations/1675256678_updated_tracks.js +++ /dev/null @@ -1,17 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.createRule = "@request.auth.id != ''" - collection.updateRule = "@request.auth.id != ''" - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.createRule = null - collection.updateRule = null - - return dao.saveCollection(collection) -}) diff --git a/server/pb_migrations/1675257121_updated_tracks.js b/server/pb_migrations/1675257121_updated_tracks.js deleted file mode 100644 index a1b7604f..00000000 --- a/server/pb_migrations/1675257121_updated_tracks.js +++ /dev/null @@ -1,17 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.createRule = "@request.auth.id != '' && ((spotify_id ?= @collection.tracks.spotify_id && youtube_id ?= @collection.tracks.youtube_id) || (spotify_id ?!= @collection.tracks.spotify_id && youtube_id ?!= @collection.tracks.youtube_id))" - collection.updateRule = "@request.auth.id != '' && ((spotify_id ?= @collection.tracks.spotify_id && youtube_id ?= @collection.tracks.youtube_id) || (spotify_id ?!= @collection.tracks.spotify_id && youtube_id ?!= @collection.tracks.youtube_id))" - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.createRule = null - collection.updateRule = null - - return dao.saveCollection(collection) -}) diff --git a/server/pb_migrations/1675257148_updated_tracks.js b/server/pb_migrations/1675257148_updated_tracks.js deleted file mode 100644 index 544d0e85..00000000 --- a/server/pb_migrations/1675257148_updated_tracks.js +++ /dev/null @@ -1,39 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - // update - collection.schema.addField(new SchemaField({ - "system": false, - "id": "vzvqgsjf", - "name": "votes", - "type": "number", - "required": false, - "unique": false, - "options": { - "min": null, - "max": null - } - })) - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - // update - collection.schema.addField(new SchemaField({ - "system": false, - "id": "vzvqgsjf", - "name": "votes", - "type": "number", - "required": true, - "unique": false, - "options": { - "min": null, - "max": null - } - })) - - return dao.saveCollection(collection) -}) diff --git a/untranslated_messages.json b/untranslated_messages.json index 14eead0f..9e26dfee 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1,574 +1 @@ -{ - "ar": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "bn": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "ca": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "de": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "es": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "fa": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "fr": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "hi": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "it": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "ja": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "ne": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "nl": [ - "sort_duration", - "audio_source", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "pl": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "pt": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "ru": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "tr": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "uk": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "vi": [ - "sort_duration", - "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", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "zh": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ] -} +{} \ No newline at end of file diff --git a/website/README.md b/website/README.md index 5ce67661..ad252bd7 100644 --- a/website/README.md +++ b/website/README.md @@ -8,21 +8,21 @@ If you're seeing this, you've probably already done this step. Congrats! ```bash # create a new project in the current directory -npm create svelte@latest +pnpm create svelte@latest # create a new project in my-app -npm create svelte@latest my-app +pnpm create svelte@latest my-app ``` ## Developing -Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: +Once you've created a project and installed dependencies with `pnpm install` (or `pnpm install` or `yarn`), start a development server: ```bash -npm run dev +pnpm run dev # or start the server and open the app in a new browser tab -npm run dev -- --open +pnpm run dev -- --open ``` ## Building @@ -30,9 +30,9 @@ npm run dev -- --open To create a production version of your app: ```bash -npm run build +pnpm run build ``` -You can preview the production build with `npm run preview`. +You can preview the production build with `pnpm run preview`. > To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. diff --git a/website/package-lock.json b/website/package-lock.json deleted file mode 100644 index 89323983..00000000 --- a/website/package-lock.json +++ /dev/null @@ -1,6391 +0,0 @@ -{ - "name": "website", - "version": "0.0.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "website", - "version": "0.0.1", - "dependencies": { - "@floating-ui/dom": "1.6.1", - "@fortawesome/free-brands-svg-icons": "^6.5.1", - "@octokit/openapi-types": "^19.1.0", - "@octokit/rest": "^20.0.2", - "date-fns": "^3.3.1", - "highlight.js": "11.9.0", - "lucide-svelte": "^0.323.0", - "mdsvex-relative-images": "^1.0.3", - "rehype-autolink-headings": "^7.1.0", - "rehype-slug": "^6.0.0", - "remark-container": "^0.1.2", - "remark-external-links": "^9.0.1", - "remark-gfm": "^4.0.0", - "remark-github": "^12.0.0", - "remark-reading-time": "^1.0.1", - "svelte-fa": "^4.0.2", - "svelte-markdown": "^0.4.1" - }, - "devDependencies": { - "@playwright/test": "^1.28.1", - "@skeletonlabs/skeleton": "2.8.0", - "@skeletonlabs/tw-plugin": "0.3.1", - "@sveltejs/adapter-cloudflare": "^4.1.0", - "@sveltejs/kit": "^2.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0", - "@tailwindcss/typography": "0.5.10", - "@types/eslint": "8.56.0", - "@types/node": "^20.11.16", - "@typescript-eslint/eslint-plugin": "^6.0.0", - "@typescript-eslint/parser": "^6.0.0", - "autoprefixer": "10.4.17", - "eslint": "^8.56.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-svelte": "^2.35.1", - "mdsvex": "^0.11.0", - "postcss": "8.4.35", - "prettier": "^3.1.1", - "prettier-plugin-svelte": "^3.1.2", - "svelte": "^4.2.7", - "svelte-check": "^3.6.0", - "tailwindcss": "3.4.1", - "tslib": "^2.4.1", - "typescript": "^5.0.0", - "vite": "^5.0.3", - "vite-plugin-tailwind-purgecss": "0.2.0" - } - }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", - "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@cloudflare/workers-types": { - "version": "4.20240208.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20240208.0.tgz", - "integrity": "sha512-MVGTTjZpJu4kJONvai5SdJzWIhOJbuweVZ3goI7FNyG+JdoQH41OoB+nMhLsX626vPLZVWGPIWsiSo/WZHzgQw==", - "dev": true - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", - "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", - "dev": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", - "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@floating-ui/core": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", - "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", - "dependencies": { - "@floating-ui/utils": "^0.2.1" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz", - "integrity": "sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==", - "dependencies": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.1" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", - "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" - }, - "node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.1.tgz", - "integrity": "sha512-GkWzv+L6d2bI5f/Vk6ikJ9xtl7dfXtoRu3YGE6nq0p/FFqA1ebMOAWg3XgRyb0I6LYyYkiAo+3/KrwuBp8xG7A==", - "hasInstallScript": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/@fortawesome/free-brands-svg-icons": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.5.1.tgz", - "integrity": "sha512-093l7DAkx0aEtBq66Sf19MgoZewv1zeY9/4C7vSKPO4qMwEsW/2VYTUTpBtLwfb9T2R73tXaRDPmE4UqLCYHfg==", - "hasInstallScript": true, - "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", - "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", - "dev": true - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", - "dependencies": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.22", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", - "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@octokit/auth-token": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", - "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/core": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.1.0.tgz", - "integrity": "sha512-BDa2VAMLSh3otEiaMJ/3Y36GU4qf6GI+VivQ/P41NC6GHcdxpKlqV0ikSZ5gdQsmS3ojXeRx5vasgNTinF0Q4g==", - "dependencies": { - "@octokit/auth-token": "^4.0.0", - "@octokit/graphql": "^7.0.0", - "@octokit/request": "^8.0.2", - "@octokit/request-error": "^5.0.0", - "@octokit/types": "^12.0.0", - "before-after-hook": "^2.2.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/endpoint": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.4.tgz", - "integrity": "sha512-DWPLtr1Kz3tv8L0UvXTDP1fNwM0S+z6EJpRcvH66orY6Eld4XBMCSYsaWp4xIm61jTWxK68BrR7ibO+vSDnZqw==", - "dependencies": { - "@octokit/types": "^12.0.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/graphql": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.0.2.tgz", - "integrity": "sha512-OJ2iGMtj5Tg3s6RaXH22cJcxXRi7Y3EBqbHTBRq+PQAqfaS8f/236fUrWhfSn8P4jovyzqucxme7/vWSSZBX2Q==", - "dependencies": { - "@octokit/request": "^8.0.1", - "@octokit/types": "^12.0.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/openapi-types": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-19.1.0.tgz", - "integrity": "sha512-6G+ywGClliGQwRsjvqVYpklIfa7oRPA0vyhPQG/1Feh+B+wU0vGH1JiJ5T25d3g1JZYBHzR2qefLi9x8Gt+cpw==" - }, - "node_modules/@octokit/plugin-paginate-rest": { - "version": "9.1.5", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-9.1.5.tgz", - "integrity": "sha512-WKTQXxK+bu49qzwv4qKbMMRXej1DU2gq017euWyKVudA6MldaSSQuxtz+vGbhxV4CjxpUxjZu6rM2wfc1FiWVg==", - "dependencies": { - "@octokit/types": "^12.4.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": ">=5" - } - }, - "node_modules/@octokit/plugin-request-log": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-4.0.0.tgz", - "integrity": "sha512-2uJI1COtYCq8Z4yNSnM231TgH50bRkheQ9+aH8TnZanB6QilOnx8RMD2qsnamSOXtDj0ilxvevf5fGsBhBBzKA==", - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": ">=5" - } - }, - "node_modules/@octokit/plugin-rest-endpoint-methods": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-10.2.0.tgz", - "integrity": "sha512-ePbgBMYtGoRNXDyKGvr9cyHjQ163PbwD0y1MkDJCpkO2YH4OeXX40c4wYHKikHGZcpGPbcRLuy0unPUuafco8Q==", - "dependencies": { - "@octokit/types": "^12.3.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": ">=5" - } - }, - "node_modules/@octokit/request": { - "version": "8.1.6", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.1.6.tgz", - "integrity": "sha512-YhPaGml3ncZC1NfXpP3WZ7iliL1ap6tLkAp6MvbK2fTTPytzVUyUesBBogcdMm86uRYO5rHaM1xIWxigWZ17MQ==", - "dependencies": { - "@octokit/endpoint": "^9.0.0", - "@octokit/request-error": "^5.0.0", - "@octokit/types": "^12.0.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/request-error": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.0.1.tgz", - "integrity": "sha512-X7pnyTMV7MgtGmiXBwmO6M5kIPrntOXdyKZLigNfQWSEQzVxR4a4vo49vJjTWX70mPndj8KhfT4Dx+2Ng3vnBQ==", - "dependencies": { - "@octokit/types": "^12.0.0", - "deprecation": "^2.0.0", - "once": "^1.4.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/rest": { - "version": "20.0.2", - "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-20.0.2.tgz", - "integrity": "sha512-Ux8NDgEraQ/DMAU1PlAohyfBBXDwhnX2j33Z1nJNziqAfHi70PuxkFYIcIt8aIAxtRE7KVuKp8lSR8pA0J5iOQ==", - "dependencies": { - "@octokit/core": "^5.0.0", - "@octokit/plugin-paginate-rest": "^9.0.0", - "@octokit/plugin-request-log": "^4.0.0", - "@octokit/plugin-rest-endpoint-methods": "^10.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/types": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.4.0.tgz", - "integrity": "sha512-FLWs/AvZllw/AGVs+nJ+ELCDZZJk+kY0zMen118xhL2zD0s1etIUHm1odgjP7epxYU1ln7SZxEUWYop5bhsdgQ==", - "dependencies": { - "@octokit/openapi-types": "^19.1.0" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@playwright/test": { - "version": "1.41.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.2.tgz", - "integrity": "sha512-qQB9h7KbibJzrDpkXkYvsmiDJK14FULCCZgEcoe2AvFAS64oCirWTwzTlAYEbKaRxWs5TFesE1Na6izMv3HfGg==", - "dev": true, - "dependencies": { - "playwright": "1.41.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.24", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.24.tgz", - "integrity": "sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==", - "dev": true - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.6.tgz", - "integrity": "sha512-MVNXSSYN6QXOulbHpLMKYi60ppyO13W9my1qogeiAqtjb2yR4LSmfU2+POvDkLzhjYLXz9Rf9+9a3zFHW1Lecg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.6.tgz", - "integrity": "sha512-T14aNLpqJ5wzKNf5jEDpv5zgyIqcpn1MlwCrUXLrwoADr2RkWA0vOWP4XxbO9aiO3dvMCQICZdKeDrFl7UMClw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.6.tgz", - "integrity": "sha512-CqNNAyhRkTbo8VVZ5R85X73H3R5NX9ONnKbXuHisGWC0qRbTTxnF1U4V9NafzJbgGM0sHZpdO83pLPzq8uOZFw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.6.tgz", - "integrity": "sha512-zRDtdJuRvA1dc9Mp6BWYqAsU5oeLixdfUvkTHuiYOHwqYuQ4YgSmi6+/lPvSsqc/I0Omw3DdICx4Tfacdzmhog==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.6.tgz", - "integrity": "sha512-oNk8YXDDnNyG4qlNb6is1ojTOGL/tRhbbKeE/YuccItzerEZT68Z9gHrY3ROh7axDc974+zYAPxK5SH0j/G+QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.6.tgz", - "integrity": "sha512-Z3O60yxPtuCYobrtzjo0wlmvDdx2qZfeAWTyfOjEDqd08kthDKexLpV97KfAeUXPosENKd8uyJMRDfFMxcYkDQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.6.tgz", - "integrity": "sha512-gpiG0qQJNdYEVad+1iAsGAbgAnZ8j07FapmnIAQgODKcOTjLEWM9sRb+MbQyVsYCnA0Im6M6QIq6ax7liws6eQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.6.tgz", - "integrity": "sha512-+uCOcvVmFUYvVDr27aiyun9WgZk0tXe7ThuzoUTAukZJOwS5MrGbmSlNOhx1j80GdpqbOty05XqSl5w4dQvcOA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.6.tgz", - "integrity": "sha512-HUNqM32dGzfBKuaDUBqFB7tP6VMN74eLZ33Q9Y1TBqRDn+qDonkAUyKWwF9BR9unV7QUzffLnz9GrnKvMqC/fw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.6.tgz", - "integrity": "sha512-ch7M+9Tr5R4FK40FHQk8VnML0Szi2KRujUgHXd/HjuH9ifH72GUmw6lStZBo3c3GB82vHa0ZoUfjfcM7JiiMrQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.6.tgz", - "integrity": "sha512-VD6qnR99dhmTQ1mJhIzXsRcTBvTjbfbGGwKAHcu+52cVl15AC/kplkhxzW/uT0Xl62Y/meBKDZvoJSJN+vTeGA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.6.tgz", - "integrity": "sha512-J9AFDq/xiRI58eR2NIDfyVmTYGyIZmRcvcAoJ48oDld/NTR8wyiPUu2X/v1navJ+N/FGg68LEbX3Ejd6l8B7MQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.6.tgz", - "integrity": "sha512-jqzNLhNDvIZOrt69Ce4UjGRpXJBzhUBzawMwnaDAwyHriki3XollsewxWzOzz+4yOFDkuJHtTsZFwMxhYJWmLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@skeletonlabs/skeleton": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@skeletonlabs/skeleton/-/skeleton-2.8.0.tgz", - "integrity": "sha512-R6spSJSyW9MA6cnVQ8IV7uoYSXxHmP/oWJ9IHdGDU9epPZaZMmOXUHJSzA1gngccB2jFaA/6jXfS1O1CsIlGMg==", - "dev": true, - "dependencies": { - "esm-env": "1.0.0" - }, - "peerDependencies": { - "svelte": "^3.56.0 || ^4.0.0" - } - }, - "node_modules/@skeletonlabs/tw-plugin": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@skeletonlabs/tw-plugin/-/tw-plugin-0.3.1.tgz", - "integrity": "sha512-DjjeOHN3HhFQf6gYPT2MUZMkIdw1jeB9mbuKC8etQxUlOR4XitfC7hssRWFJ8RJsvrrN0myCBbdWkVG1JVA96g==", - "dev": true, - "peerDependencies": { - "tailwindcss": ">=3.0.0" - } - }, - "node_modules/@sveltejs/adapter-cloudflare": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-cloudflare/-/adapter-cloudflare-4.1.0.tgz", - "integrity": "sha512-AQQdZAZpcFDcBiMEmxbMYhn4yKZYoPZrKUrYpVejjbO+9obIGIuj/jWjVzAEkHqZMZuoRRqPbq+Zq+AWRm4x1Q==", - "dev": true, - "dependencies": { - "@cloudflare/workers-types": "^4.20231121.0", - "esbuild": "^0.19.11", - "worktop": "0.8.0-next.18" - }, - "peerDependencies": { - "@sveltejs/kit": "^2.0.0" - } - }, - "node_modules/@sveltejs/kit": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.0.tgz", - "integrity": "sha512-1uyXvzC2Lu1FZa30T4y5jUAC21R309ZMRG0TPt+PPPbNUoDpy8zSmSNVWYaBWxYDqLGQ5oPNWvjvvF2IjJ1jmA==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "@types/cookie": "^0.6.0", - "cookie": "^0.6.0", - "devalue": "^4.3.2", - "esm-env": "^1.0.0", - "import-meta-resolve": "^4.0.0", - "kleur": "^4.1.5", - "magic-string": "^0.30.5", - "mrmime": "^2.0.0", - "sade": "^1.8.1", - "set-cookie-parser": "^2.6.0", - "sirv": "^2.0.4", - "tiny-glob": "^0.2.9" - }, - "bin": { - "svelte-kit": "svelte-kit.js" - }, - "engines": { - "node": ">=18.13" - }, - "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.0", - "svelte": "^4.0.0 || ^5.0.0-next.0", - "vite": "^5.0.3" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.0.2.tgz", - "integrity": "sha512-MpmF/cju2HqUls50WyTHQBZUV3ovV/Uk8k66AN2gwHogNAG8wnW8xtZDhzNBsFJJuvmq1qnzA5kE7YfMJNFv2Q==", - "dev": true, - "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^2.0.0", - "debug": "^4.3.4", - "deepmerge": "^4.3.1", - "kleur": "^4.1.5", - "magic-string": "^0.30.5", - "svelte-hmr": "^0.15.3", - "vitefu": "^0.2.5" - }, - "engines": { - "node": "^18.0.0 || >=20" - }, - "peerDependencies": { - "svelte": "^4.0.0 || ^5.0.0-next.0", - "vite": "^5.0.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.0.0.tgz", - "integrity": "sha512-gjr9ZFg1BSlIpfZ4PRewigrvYmHWbDrq2uvvPB1AmTWKuM+dI1JXQSUu2pIrYLb/QncyiIGkFDFKTwJ0XqQZZg==", - "dev": true, - "dependencies": { - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.0.0 || >=20" - }, - "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.0", - "svelte": "^4.0.0 || ^5.0.0-next.0", - "vite": "^5.0.0" - } - }, - "node_modules/@tailwindcss/typography": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.10.tgz", - "integrity": "sha512-Pe8BuPJQJd3FfRnm6H0ulKIGoMEQS+Vq01R6M5aCrFB/ccR/shT+0kXLjouGC1gFLm9hopTFN+DMP0pfwRWzPw==", - "dev": true, - "dependencies": { - "lodash.castarray": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2", - "postcss-selector-parser": "6.0.10" - }, - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders" - } - }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true - }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.0.tgz", - "integrity": "sha512-FlsN0p4FhuYRjIxpbdXovvHQhtlG05O1GG/RNWvdAxTboR438IOTwmrY/vLA+Xfgg06BTkP045M3vpFwTMv1dg==", - "dev": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true - }, - "node_modules/@types/marked": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz", - "integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==" - }, - "node_modules/@types/mdast": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.3.tgz", - "integrity": "sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/ms": { - "version": "0.7.34", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", - "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" - }, - "node_modules/@types/node": { - "version": "20.11.16", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.16.tgz", - "integrity": "sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==", - "dev": true, - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@types/pug": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz", - "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==", - "dev": true - }, - "node_modules/@types/semver": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", - "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==", - "dev": true - }, - "node_modules/@types/unist": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", - "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", - "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", - "dev": true, - "dependencies": { - "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/type-utils": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4", - "graphemer": "^1.4.0", - "ignore": "^5.2.4", - "natural-compare": "^1.4.0", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", - "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", - "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", - "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", - "dev": true, - "dependencies": { - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", - "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", - "dev": true, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", - "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", - "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "semver": "^7.5.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", - "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.21.0", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" - }, - "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/autoprefixer": { - "version": "10.4.17", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", - "integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "browserslist": "^4.22.2", - "caniuse-lite": "^1.0.30001578", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/axobject-query": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.0.0.tgz", - "integrity": "sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/before-after-hook": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", - "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" - }, - "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "dependencies": { - "fill-range": "^7.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.22.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz", - "integrity": "sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001580", - "electron-to-chromium": "^1.4.648", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001585", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001585.tgz", - "integrity": "sha512-yr2BWR1yLXQ8fMpdS/4ZZXpseBgE7o4g41x3a6AJOqZuOi+iE/WdJYAuZ6Y95i4Ohd2Y+9MzIWRR+uGABH4s3Q==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/code-red": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz", - "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15", - "@types/estree": "^1.0.1", - "acorn": "^8.10.0", - "estree-walker": "^3.0.3", - "periscopic": "^3.1.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-tree": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", - "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", - "dependencies": { - "mdn-data": "2.0.30", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/date-fns": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.3.1.tgz", - "integrity": "sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decode-named-character-reference": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", - "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/deprecation": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", - "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-indent": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", - "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/devalue": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.2.tgz", - "integrity": "sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==", - "dev": true - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, - "node_modules/electron-to-chromium": { - "version": "1.4.661", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.661.tgz", - "integrity": "sha512-AFg4wDHSOk5F+zA8aR+SVIOabu7m0e7BiJnigCvPXzIGy731XENw/lmNxTySpVFtkFEy+eyt4oHhh5FF3NjQNw==", - "dev": true - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "node_modules/es6-promise": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", - "integrity": "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==", - "dev": true - }, - "node_modules/esbuild": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", - "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.12", - "@esbuild/android-arm": "0.19.12", - "@esbuild/android-arm64": "0.19.12", - "@esbuild/android-x64": "0.19.12", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", - "@esbuild/freebsd-arm64": "0.19.12", - "@esbuild/freebsd-x64": "0.19.12", - "@esbuild/linux-arm": "0.19.12", - "@esbuild/linux-arm64": "0.19.12", - "@esbuild/linux-ia32": "0.19.12", - "@esbuild/linux-loong64": "0.19.12", - "@esbuild/linux-mips64el": "0.19.12", - "@esbuild/linux-ppc64": "0.19.12", - "@esbuild/linux-riscv64": "0.19.12", - "@esbuild/linux-s390x": "0.19.12", - "@esbuild/linux-x64": "0.19.12", - "@esbuild/netbsd-x64": "0.19.12", - "@esbuild/openbsd-x64": "0.19.12", - "@esbuild/sunos-x64": "0.19.12", - "@esbuild/win32-arm64": "0.19.12", - "@esbuild/win32-ia32": "0.19.12", - "@esbuild/win32-x64": "0.19.12" - } - }, - "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", - "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.56.0", - "@humanwhocodes/config-array": "^0.11.13", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-compat-utils": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.1.2.tgz", - "integrity": "sha512-Jia4JDldWnFNIru1Ehx1H5s9/yxiRHY/TimCuUc0jNexew3cF1gI6CYZil1ociakfWO3rRqFjl1mskBblB3RYg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "eslint": ">=6.0.0" - } - }, - "node_modules/eslint-config-prettier": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", - "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", - "dev": true, - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-plugin-svelte": { - "version": "2.35.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.35.1.tgz", - "integrity": "sha512-IF8TpLnROSGy98Z3NrsKXWDSCbNY2ReHDcrYTuXZMbfX7VmESISR78TWgO9zdg4Dht1X8coub5jKwHzP0ExRug==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@jridgewell/sourcemap-codec": "^1.4.14", - "debug": "^4.3.1", - "eslint-compat-utils": "^0.1.2", - "esutils": "^2.0.3", - "known-css-properties": "^0.29.0", - "postcss": "^8.4.5", - "postcss-load-config": "^3.1.4", - "postcss-safe-parser": "^6.0.0", - "postcss-selector-parser": "^6.0.11", - "semver": "^7.5.3", - "svelte-eslint-parser": ">=0.33.0 <1.0.0" - }, - "engines": { - "node": "^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ota-meshi" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0-0", - "svelte": "^3.37.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "svelte": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-svelte/node_modules/postcss-selector-parser": { - "version": "6.0.15", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz", - "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/esm-env": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.0.0.tgz", - "integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==", - "dev": true - }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", - "dev": true, - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flatted": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", - "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", - "dev": true - }, - "node_modules/foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/github-slugger": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", - "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==" - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globalyzer": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", - "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==", - "dev": true - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globrex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", - "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", - "dev": true - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hast-util-heading-rank": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz", - "integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-is-element": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", - "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-string": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.0.tgz", - "integrity": "sha512-OGkAxX1Ua3cbcW6EJ5pT/tslVb90uViVkcJ4ZZIMW/R33DX/AkcJcRrPebPwJkHYwlDHXz4aIwvAAaAdtrACFA==", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/highlight.js": { - "version": "11.9.0", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.9.0.tgz", - "integrity": "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-meta-resolve": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.0.0.tgz", - "integrity": "sha512-okYUR7ZQPH+efeuMJGlq4f8ubUgO50kByRPyt/Cy1Io4PSRsPjxME+YlVaCOx+NIToW7hCsZNFJyTPFFKepRSA==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/is-absolute-url": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-4.0.1.tgz", - "integrity": "sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "engines": { - "node": ">=4" - } - }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, - "dependencies": { - "hasown": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-reference": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz", - "integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/jackspeak": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", - "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", - "dev": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jiti": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", - "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", - "dev": true, - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, - "node_modules/just-camel-case": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/just-camel-case/-/just-camel-case-4.0.2.tgz", - "integrity": "sha512-df6QI/EIq+6uHe/wtaa9Qq7/pp4wr4pJC/r1+7XhVL6m5j03G6h9u9/rIZr8rDASX7CxwDPQnZjffCo2e6PRLw==" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/known-css-properties": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.29.0.tgz", - "integrity": "sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==", - "dev": true - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.castarray": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", - "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", - "dev": true - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/lucide-svelte": { - "version": "0.323.0", - "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.323.0.tgz", - "integrity": "sha512-3GEFk1vCwB8BtHTHZTocFJfX6AtTLQw9a74JSuihAGx+MzhxqeWk8W1TkM4WUlvE0x9UdONM2rJGRyx9IyjkJg==", - "peerDependencies": { - "svelte": "^3 || ^4 || ^5.0.0-next.42" - } - }, - "node_modules/magic-string": { - "version": "0.30.7", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", - "integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/markdown-table": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.3.tgz", - "integrity": "sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/marked": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-5.1.2.tgz", - "integrity": "sha512-ahRPGXJpjMjwSOlBoTMZAK7ATXkli5qCPxZ21TG44rx1KEo44bii4ekgTDQPNRQ4Kh7JMb9Ub1PVk1NxRSsorg==", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 16" - } - }, - "node_modules/mdast-util-definitions": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.2.tgz", - "integrity": "sha512-8SVPMuHqlPME/z3gqVwWY4zVXn8lqKv/pAhC57FuJ40ImXyBpmO5ukh98zB2v7Blql2FiHjHv9LVztSIqjY+MA==", - "dependencies": { - "@types/mdast": "^3.0.0", - "@types/unist": "^2.0.0", - "unist-util-visit": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-definitions/node_modules/@types/mdast": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", - "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", - "dependencies": { - "@types/unist": "^2" - } - }, - "node_modules/mdast-util-definitions/node_modules/unist-util-is": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-5.2.1.tgz", - "integrity": "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-definitions/node_modules/unist-util-visit": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-4.1.2.tgz", - "integrity": "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0", - "unist-util-visit-parents": "^5.1.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-definitions/node_modules/unist-util-visit-parents": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-5.1.3.tgz", - "integrity": "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-find-and-replace": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.1.tgz", - "integrity": "sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==", - "dependencies": { - "@types/mdast": "^4.0.0", - "escape-string-regexp": "^5.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.0.tgz", - "integrity": "sha512-n7MTOr/z+8NAX/wmhhDji8O3bRvPTV/U0oTCaZJkjhPSKTPhS3xufVhKGF8s1pJ7Ox4QgoIU7KHseh09S+9rTA==", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-from-markdown/node_modules/@types/unist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" - }, - "node_modules/mdast-util-from-markdown/node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.0.0.tgz", - "integrity": "sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==", - "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-gfm-autolink-literal": "^2.0.0", - "mdast-util-gfm-footnote": "^2.0.0", - "mdast-util-gfm-strikethrough": "^2.0.0", - "mdast-util-gfm-table": "^2.0.0", - "mdast-util-gfm-task-list-item": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-autolink-literal": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.0.tgz", - "integrity": "sha512-FyzMsduZZHSc3i0Px3PQcBT4WJY/X/RCtEJKuybiC6sjPqLv7h1yqAkmILZtuxMSsUyaLUWNp71+vQH2zqp5cg==", - "dependencies": { - "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-footnote": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.0.0.tgz", - "integrity": "sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ==", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", - "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "markdown-table": "^3.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-task-list-item": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", - "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.0.tgz", - "integrity": "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ==", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown/node_modules/@types/unist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdn-data": { - "version": "2.0.30", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", - "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==" - }, - "node_modules/mdsvex": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/mdsvex/-/mdsvex-0.11.0.tgz", - "integrity": "sha512-gJF1s0N2nCmdxcKn8HDn0LKrN8poStqAicp6bBcsKFd/zkUBGLP5e7vnxu+g0pjBbDFOscUyI1mtHz+YK2TCDw==", - "dev": true, - "dependencies": { - "@types/unist": "^2.0.3", - "prism-svelte": "^0.4.7", - "prismjs": "^1.17.1", - "vfile-message": "^2.0.4" - }, - "peerDependencies": { - "svelte": ">=3 <5" - } - }, - "node_modules/mdsvex-relative-images": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/mdsvex-relative-images/-/mdsvex-relative-images-1.0.3.tgz", - "integrity": "sha512-3XvpnaguRAhC5gchpqCH+A5Yl28xG9WDPylVla0+k90c5LT+QqSM+hwHd1v5C7gB2cAT0AIhuMsY/g6aCw+WDg==", - "dependencies": { - "just-camel-case": "^4.0.2", - "unist-util-visit": "^3.1.0" - } - }, - "node_modules/mdsvex-relative-images/node_modules/unist-util-is": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-5.2.1.tgz", - "integrity": "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdsvex-relative-images/node_modules/unist-util-visit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-3.1.0.tgz", - "integrity": "sha512-Szoh+R/Ll68QWAyQyZZpQzZQm2UPbxibDvaY8Xc9SUtYgPsDzx5AWSk++UUt2hJuow8mvwR+rG+LQLw+KsuAKA==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0", - "unist-util-visit-parents": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdsvex-relative-images/node_modules/unist-util-visit-parents": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-4.1.1.tgz", - "integrity": "sha512-1xAFJXAKpnnJl8G7K5KgU7FY55y3GcLIXqkzUj5QF/QVP7biUm0K0O2oqVkYsdjzJKifYeWn9+o6piAK2hGSHw==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromark": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.0.tgz", - "integrity": "sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.0.tgz", - "integrity": "sha512-jThOz/pVmAYUtkroV3D5c1osFXAMv9e0ypGDOIZuCeAe91/sD6BoE2Sjzt30yuXtwOYUmySOhMas/PVyh02itA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", - "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", - "dependencies": { - "micromark-extension-gfm-autolink-literal": "^2.0.0", - "micromark-extension-gfm-footnote": "^2.0.0", - "micromark-extension-gfm-strikethrough": "^2.0.0", - "micromark-extension-gfm-table": "^2.0.0", - "micromark-extension-gfm-tagfilter": "^2.0.0", - "micromark-extension-gfm-task-list-item": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.0.0.tgz", - "integrity": "sha512-rTHfnpt/Q7dEAK1Y5ii0W8bhfJlVJFnJMHIPisfPK3gpVNuOP0VnRl96+YJ3RYWV/P4gFeQoGKNlT3RhuvpqAg==", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-footnote": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.0.0.tgz", - "integrity": "sha512-6Rzu0CYRKDv3BfLAUnZsSlzx3ak6HAoI85KTiijuKIz5UxZxbUI+pD6oHgw+6UtQuiRwnGRhzMmPRv4smcz0fg==", - "dependencies": { - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-c3BR1ClMp5fxxmwP6AoOY2fXO9U8uFMKs4ADD66ahLTNcwzSCyRVU4k7LPV5Nxo/VJiR4TdzxRQY2v3qIUceCw==", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.0.0.tgz", - "integrity": "sha512-PoHlhypg1ItIucOaHmKE8fbin3vTLpDOUg8KAr8gRCF1MOZI9Nquq2i/44wFvviM4WuxJzc3demT8Y3dkfvYrw==", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-tagfilter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", - "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", - "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-task-list-item": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.0.1.tgz", - "integrity": "sha512-cY5PzGcnULaN5O7T+cOzfMoHjBW7j+T9D2sucA5d/KbsBTPcYdebm9zUd9zzdgJGCwahV+/W78Z3nbulBYVbTw==", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.0.tgz", - "integrity": "sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.0.tgz", - "integrity": "sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", - "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.0.tgz", - "integrity": "sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.0.tgz", - "integrity": "sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", - "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.0.tgz", - "integrity": "sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.0.tgz", - "integrity": "sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.0.tgz", - "integrity": "sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.1.tgz", - "integrity": "sha512-bmkNc7z8Wn6kgjZmVHOX3SowGmVdhYS7yBpMnuMnPzDq/6xwVA604DuOXMZTO1lvq01g+Adfa0pE2UKGlxL1XQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.0.tgz", - "integrity": "sha512-r4Sc6leeUTn3P6gk20aFMj2ntPwn6qpDZqWvYmAG6NgvFTIlj4WtrAudLi65qYoaGdXYViXYw2pkmn7QnIFasA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz", - "integrity": "sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.0.tgz", - "integrity": "sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.0.tgz", - "integrity": "sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.0.tgz", - "integrity": "sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz", - "integrity": "sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.0.tgz", - "integrity": "sha512-vc93L1t+gpR3p8jxeVdaYlbV2jTYteDje19rNSS/H5dlhxUYll5Fy6vJ2cDwP8RnsXi818yGty1ayP55y3W6fg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.0.tgz", - "integrity": "sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, - "dependencies": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/mrmime": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", - "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", - "dev": true - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", - "dev": true, - "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "node_modules/path-scurry": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", - "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", - "dev": true, - "dependencies": { - "lru-cache": "^9.1.1 || ^10.0.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", - "dev": true, - "engines": { - "node": "14 || >=16.14" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/periscopic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", - "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^3.0.0", - "is-reference": "^3.0.0" - } - }, - "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/playwright": { - "version": "1.41.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.2.tgz", - "integrity": "sha512-v0bOa6H2GJChDL8pAeLa/LZC4feoAMbSQm1/jF/ySsWWoaNItvrMP7GEkvEEFyCTUYKMxjQKaTSg5up7nR6/8A==", - "dev": true, - "dependencies": { - "playwright-core": "1.41.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.41.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.2.tgz", - "integrity": "sha512-VaTvwCA4Y8kxEe+kfm2+uUUw5Lubf38RxF7FpBxLPmGe5sdNkSg5e3ChEigaGrX7qdqT3pt2m/98LiyvU2x6CA==", - "dev": true, - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/postcss": { - "version": "8.4.35", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", - "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", - "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "dev": true, - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", - "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", - "dev": true, - "dependencies": { - "lilconfig": "^2.0.5", - "yaml": "^1.10.2" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", - "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", - "dev": true, - "dependencies": { - "postcss-selector-parser": "^6.0.11" - }, - "engines": { - "node": ">=12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-nested/node_modules/postcss-selector-parser": { - "version": "6.0.15", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz", - "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-safe-parser": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz", - "integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==", - "dev": true, - "engines": { - "node": ">=12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.3.3" - } - }, - "node_modules/postcss-scss": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", - "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss-scss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.4.29" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", - "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", - "dev": true, - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-plugin-svelte": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.1.2.tgz", - "integrity": "sha512-7xfMZtwgAWHMT0iZc8jN4o65zgbAQ3+O32V6W7pXrqNvKnHnkoyQCGCbKeUyXKZLbYE0YhFRnamfxfkEGxm8qA==", - "dev": true, - "peerDependencies": { - "prettier": "^3.0.0", - "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" - } - }, - "node_modules/prism-svelte": { - "version": "0.4.7", - "resolved": "https://registry.npmjs.org/prism-svelte/-/prism-svelte-0.4.7.tgz", - "integrity": "sha512-yABh19CYbM24V7aS7TuPYRNMqthxwbvx6FF/Rw920YbyBWO3tnyPIqRMgHuSVsLmuHkkBS1Akyof463FVdkeDQ==", - "dev": true - }, - "node_modules/prismjs": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", - "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/purgecss": { - "version": "6.0.0-alpha.0", - "resolved": "https://registry.npmjs.org/purgecss/-/purgecss-6.0.0-alpha.0.tgz", - "integrity": "sha512-UC7d7uIyZsky+srEsSXny9BkbTcVn3ZtBCNX3rW3DsqJKhvUXFRpufA4ktcHzWF0+JLZgmsqjUm/8R82x9bHpw==", - "dev": true, - "dependencies": { - "commander": "^10.0.0", - "glob": "^8.0.3", - "postcss": "^8.4.4", - "postcss-selector-parser": "^6.0.7" - }, - "bin": { - "purgecss": "bin/purgecss.js" - } - }, - "node_modules/purgecss/node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "dev": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/purgecss/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/purgecss/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/reading-time": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/reading-time/-/reading-time-1.5.0.tgz", - "integrity": "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==" - }, - "node_modules/regexparam": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz", - "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/rehype-autolink-headings": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz", - "integrity": "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==", - "dependencies": { - "@types/hast": "^3.0.0", - "@ungap/structured-clone": "^1.0.0", - "hast-util-heading-rank": "^3.0.0", - "hast-util-is-element": "^3.0.0", - "unified": "^11.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rehype-slug": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz", - "integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==", - "dependencies": { - "@types/hast": "^3.0.0", - "github-slugger": "^2.0.0", - "hast-util-heading-rank": "^3.0.0", - "hast-util-to-string": "^3.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-container": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/remark-container/-/remark-container-0.1.2.tgz", - "integrity": "sha512-E+G7dSALm3aMqyi15N4DxnRFQmBbHwxVc+9GrbijqwbdHzagUDvi2A3oI27y/PwLkSDRjwMfoc1rCIZayZ2PFg==" - }, - "node_modules/remark-external-links": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/remark-external-links/-/remark-external-links-9.0.1.tgz", - "integrity": "sha512-EYw+p8Zqy5oT5+W8iSKzInfRLY+zeKWHCf0ut+Q5SwnaSIDGXd2zzvp4SWqyAuVbinNmZ0zjMrDKaExWZnTYqQ==", - "dependencies": { - "@types/hast": "^2.3.2", - "@types/mdast": "^3.0.0", - "extend": "^3.0.0", - "is-absolute-url": "^4.0.0", - "mdast-util-definitions": "^5.0.0", - "space-separated-tokens": "^2.0.0", - "unified": "^10.0.0", - "unist-util-visit": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-external-links/node_modules/@types/hast": { - "version": "2.3.10", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", - "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", - "dependencies": { - "@types/unist": "^2" - } - }, - "node_modules/remark-external-links/node_modules/@types/mdast": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", - "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", - "dependencies": { - "@types/unist": "^2" - } - }, - "node_modules/remark-external-links/node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/remark-external-links/node_modules/unified": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz", - "integrity": "sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==", - "dependencies": { - "@types/unist": "^2.0.0", - "bail": "^2.0.0", - "extend": "^3.0.0", - "is-buffer": "^2.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-external-links/node_modules/unist-util-is": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-5.2.1.tgz", - "integrity": "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-external-links/node_modules/unist-util-stringify-position": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz", - "integrity": "sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-external-links/node_modules/unist-util-visit": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-4.1.2.tgz", - "integrity": "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0", - "unist-util-visit-parents": "^5.1.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-external-links/node_modules/unist-util-visit-parents": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-5.1.3.tgz", - "integrity": "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-external-links/node_modules/vfile": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-5.3.7.tgz", - "integrity": "sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==", - "dependencies": { - "@types/unist": "^2.0.0", - "is-buffer": "^2.0.0", - "unist-util-stringify-position": "^3.0.0", - "vfile-message": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-external-links/node_modules/vfile-message": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-3.1.4.tgz", - "integrity": "sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-stringify-position": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-gfm": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.0.tgz", - "integrity": "sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-gfm": "^3.0.0", - "micromark-extension-gfm": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-github": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/remark-github/-/remark-github-12.0.0.tgz", - "integrity": "sha512-ByefQKFN184LeiGRCabfl7zUJsdlMYWEhiLX1gpmQ11yFg6xSuOTW7LVCv0oc1x+YvUMJW23NU36sJX2RWGgvg==", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "mdast-util-to-string": "^4.0.0", - "to-vfile": "^8.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-reading-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/remark-reading-time/-/remark-reading-time-1.0.1.tgz", - "integrity": "sha512-Z3yW1JSNgQcjpPavsKmWgY7wmqRQMXIKoh8r5RtvJdpDIWWf7O7MkhuFDZh+Ge/1Olv0tvD1pN4T7LEhwBQnUA==", - "dependencies": { - "reading-time": "^1.3.0", - "unist-util-visit": "^3.1.0" - } - }, - "node_modules/remark-reading-time/node_modules/unist-util-is": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-5.2.1.tgz", - "integrity": "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-reading-time/node_modules/unist-util-visit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-3.1.0.tgz", - "integrity": "sha512-Szoh+R/Ll68QWAyQyZZpQzZQm2UPbxibDvaY8Xc9SUtYgPsDzx5AWSk++UUt2hJuow8mvwR+rG+LQLw+KsuAKA==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0", - "unist-util-visit-parents": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-reading-time/node_modules/unist-util-visit-parents": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-4.1.1.tgz", - "integrity": "sha512-1xAFJXAKpnnJl8G7K5KgU7FY55y3GcLIXqkzUj5QF/QVP7biUm0K0O2oqVkYsdjzJKifYeWn9+o6piAK2hGSHw==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-stringify": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", - "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-to-markdown": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rollup": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.9.6.tgz", - "integrity": "sha512-05lzkCS2uASX0CiLFybYfVkwNbKZG5NFQ6Go0VWyogFTXXbR039UVsegViTntkk4OglHBdF54ccApXRRuXRbsg==", - "dev": true, - "dependencies": { - "@types/estree": "1.0.5" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.9.6", - "@rollup/rollup-android-arm64": "4.9.6", - "@rollup/rollup-darwin-arm64": "4.9.6", - "@rollup/rollup-darwin-x64": "4.9.6", - "@rollup/rollup-linux-arm-gnueabihf": "4.9.6", - "@rollup/rollup-linux-arm64-gnu": "4.9.6", - "@rollup/rollup-linux-arm64-musl": "4.9.6", - "@rollup/rollup-linux-riscv64-gnu": "4.9.6", - "@rollup/rollup-linux-x64-gnu": "4.9.6", - "@rollup/rollup-linux-x64-musl": "4.9.6", - "@rollup/rollup-win32-arm64-msvc": "4.9.6", - "@rollup/rollup-win32-ia32-msvc": "4.9.6", - "@rollup/rollup-win32-x64-msvc": "4.9.6", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "dev": true, - "dependencies": { - "mri": "^1.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/sander": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/sander/-/sander-0.5.1.tgz", - "integrity": "sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==", - "dev": true, - "dependencies": { - "es6-promise": "^3.1.2", - "graceful-fs": "^4.1.3", - "mkdirp": "^0.5.1", - "rimraf": "^2.5.2" - } - }, - "node_modules/sander/node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-cookie-parser": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", - "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==", - "dev": true - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", - "dev": true, - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/sorcery": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.0.tgz", - "integrity": "sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.14", - "buffer-crc32": "^0.2.5", - "minimist": "^1.2.0", - "sander": "^0.5.0" - }, - "bin": { - "sorcery": "bin/sorcery" - } - }, - "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "^10.3.10", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/sucrase/node_modules/glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/svelte": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.10.tgz", - "integrity": "sha512-Ep06yCaCdgG1Mafb/Rx8sJ1QS3RW2I2BxGp2Ui9LBHSZ2/tO/aGLc5WqPjgiAP6KAnLJGaIr/zzwQlOo1b8MxA==", - "dependencies": { - "@ampproject/remapping": "^2.2.1", - "@jridgewell/sourcemap-codec": "^1.4.15", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/estree": "^1.0.1", - "acorn": "^8.9.0", - "aria-query": "^5.3.0", - "axobject-query": "^4.0.0", - "code-red": "^1.0.3", - "css-tree": "^2.3.1", - "estree-walker": "^3.0.3", - "is-reference": "^3.0.1", - "locate-character": "^3.0.0", - "magic-string": "^0.30.4", - "periscopic": "^3.1.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/svelte-check": { - "version": "3.6.3", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.6.3.tgz", - "integrity": "sha512-Q2nGnoysxUnB9KjnjpQLZwdjK62DHyW6nuH/gm2qteFnDk0lCehe/6z8TsIvYeKjC6luKaWxiNGyOcWiLLPSwA==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.17", - "chokidar": "^3.4.1", - "fast-glob": "^3.2.7", - "import-fresh": "^3.2.1", - "picocolors": "^1.0.0", - "sade": "^1.7.4", - "svelte-preprocess": "^5.1.0", - "typescript": "^5.0.3" - }, - "bin": { - "svelte-check": "bin/svelte-check" - }, - "peerDependencies": { - "svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0" - } - }, - "node_modules/svelte-eslint-parser": { - "version": "0.33.1", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.33.1.tgz", - "integrity": "sha512-vo7xPGTlKBGdLH8T5L64FipvTrqv3OQRx9d2z5X05KKZDlF4rQk8KViZO4flKERY+5BiVdOh7zZ7JGJWo5P0uA==", - "dev": true, - "dependencies": { - "eslint-scope": "^7.0.0", - "eslint-visitor-keys": "^3.0.0", - "espree": "^9.0.0", - "postcss": "^8.4.29", - "postcss-scss": "^4.0.8" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ota-meshi" - }, - "peerDependencies": { - "svelte": "^3.37.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "svelte": { - "optional": true - } - } - }, - "node_modules/svelte-fa": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/svelte-fa/-/svelte-fa-4.0.2.tgz", - "integrity": "sha512-lza8Jfii6jcpMQB73mBStONxaLfZsUS+rKJ/hH6WxsHUd+g68+oHIL9yQTk4a0uY9HQk78T/CPvQnED0msqJfg==", - "peerDependencies": { - "svelte": "^4.0.0" - } - }, - "node_modules/svelte-hmr": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.3.tgz", - "integrity": "sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==", - "dev": true, - "engines": { - "node": "^12.20 || ^14.13.1 || >= 16" - }, - "peerDependencies": { - "svelte": "^3.19.0 || ^4.0.0" - } - }, - "node_modules/svelte-markdown": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/svelte-markdown/-/svelte-markdown-0.4.1.tgz", - "integrity": "sha512-pOlLY6EruKJaWI9my/2bKX8PdTeP5CM0s4VMmwmC2prlOkjAf+AOmTM4wW/l19Y6WZ87YmP8+ZCJCCwBChWjYw==", - "dependencies": { - "@types/marked": "^5.0.1", - "marked": "^5.1.2" - }, - "peerDependencies": { - "svelte": "^4.0.0" - } - }, - "node_modules/svelte-preprocess": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.3.tgz", - "integrity": "sha512-xxAkmxGHT+J/GourS5mVJeOXZzne1FR5ljeOUAMXUkfEhkLEllRreXpbl3dIYJlcJRfL1LO1uIAPpBpBfiqGPw==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "@types/pug": "^2.0.6", - "detect-indent": "^6.1.0", - "magic-string": "^0.30.5", - "sorcery": "^0.11.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">= 16.0.0", - "pnpm": "^8.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.10.2", - "coffeescript": "^2.5.1", - "less": "^3.11.3 || ^4.0.0", - "postcss": "^7 || ^8", - "postcss-load-config": "^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", - "pug": "^3.0.0", - "sass": "^1.26.8", - "stylus": "^0.55.0", - "sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0", - "svelte": "^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0", - "typescript": ">=3.9.5 || ^4.0.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "coffeescript": { - "optional": true - }, - "less": { - "optional": true - }, - "postcss": { - "optional": true - }, - "postcss-load-config": { - "optional": true - }, - "pug": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/tailwindcss": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", - "integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==", - "dev": true, - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.5.3", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.0", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.19.1", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tailwindcss/node_modules/postcss-load-config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", - "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" - }, - "engines": { - "node": ">= 14" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/tailwindcss/node_modules/postcss-load-config/node_modules/lilconfig": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.0.0.tgz", - "integrity": "sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==", - "dev": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/tailwindcss/node_modules/postcss-selector-parser": { - "version": "6.0.15", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz", - "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", - "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", - "dev": true, - "engines": { - "node": ">= 14" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tiny-glob": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", - "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", - "dev": true, - "dependencies": { - "globalyzer": "0.1.0", - "globrex": "^0.1.2" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/to-vfile": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/to-vfile/-/to-vfile-8.0.0.tgz", - "integrity": "sha512-IcmH1xB5576MJc9qcfEC/m/nQCFt3fzMHz45sSlgJyTWjRbKW1HAkJpuf3DgE57YzIlZcwcBZA5ENQbBo4aLkg==", - "dependencies": { - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/ts-api-utils": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz", - "integrity": "sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "typescript": ">=4.2.0" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true - }, - "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true - }, - "node_modules/unified": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.4.tgz", - "integrity": "sha512-apMPnyLjAX+ty4OrNap7yumyVAMlKx5IWU2wlzzUdYJO9A8f1p9m/gywF/GM2ZDFcjQPrx59Mc90KwmxsoklxQ==", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unified/node_modules/@types/unist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" - }, - "node_modules/unified/node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", - "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-is/node_modules/@types/unist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" - }, - "node_modules/unist-util-stringify-position": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", - "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", - "dev": true, - "dependencies": { - "@types/unist": "^2.0.2" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", - "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", - "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents/node_modules/@types/unist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" - }, - "node_modules/unist-util-visit/node_modules/@types/unist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" - }, - "node_modules/universal-user-agent": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", - "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==" - }, - "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true - }, - "node_modules/vfile": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.1.tgz", - "integrity": "sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz", - "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", - "dev": true, - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-stringify-position": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile/node_modules/@types/unist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" - }, - "node_modules/vfile/node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile/node_modules/vfile-message": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", - "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.0.tgz", - "integrity": "sha512-STmSFzhY4ljuhz14bg9LkMTk3d98IO6DIArnTY6MeBwiD1Za2StcQtz7fzOUnRCqrHSD5+OS2reg4HOz1eoLnw==", - "dev": true, - "dependencies": { - "esbuild": "^0.19.3", - "postcss": "^8.4.35", - "rollup": "^4.2.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-plugin-tailwind-purgecss": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/vite-plugin-tailwind-purgecss/-/vite-plugin-tailwind-purgecss-0.2.0.tgz", - "integrity": "sha512-6Q+SaalUd0t3BOIIiCQPlbZQuYARVgjoC78X+fLbQJqIEy/9fC58aQgHMgi+CmYfVfZmJToA8YiLueSGEo2mng==", - "dev": true, - "dependencies": { - "estree-walker": "^3.0.3", - "purgecss": "6.0.0-alpha.0" - }, - "peerDependencies": { - "vite": "^4.1.1 || ^5.0.0" - } - }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/vitefu": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz", - "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==", - "dev": true, - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/worktop": { - "version": "0.8.0-next.18", - "resolved": "https://registry.npmjs.org/worktop/-/worktop-0.8.0-next.18.tgz", - "integrity": "sha512-+TvsA6VAVoMC3XDKR5MoC/qlLqDixEfOBysDEKnPIPou/NvoPWCAuXHXMsswwlvmEuvX56lQjvELLyLuzTKvRw==", - "dev": true, - "dependencies": { - "mrmime": "^2.0.0", - "regexparam": "^3.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/website/package.json b/website/package.json index 0f8e138a..b17f57c7 100644 --- a/website/package.json +++ b/website/package.json @@ -14,38 +14,38 @@ "format": "prettier --write ." }, "devDependencies": { - "@playwright/test": "^1.28.1", + "@playwright/test": "^1.41.2", "@skeletonlabs/skeleton": "2.8.0", "@skeletonlabs/tw-plugin": "0.3.1", "@sveltejs/adapter-cloudflare": "^4.1.0", - "@sveltejs/kit": "^2.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@sveltejs/kit": "^2.5.0", + "@sveltejs/vite-plugin-svelte": "^3.0.2", "@tailwindcss/typography": "0.5.10", "@types/eslint": "8.56.0", "@types/node": "^20.11.16", - "@typescript-eslint/eslint-plugin": "^6.0.0", - "@typescript-eslint/parser": "^6.0.0", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", "autoprefixer": "10.4.17", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.35.1", "mdsvex": "^0.11.0", "postcss": "8.4.35", - "prettier": "^3.1.1", + "prettier": "^3.2.5", "prettier-plugin-svelte": "^3.1.2", - "svelte": "^4.2.7", - "svelte-check": "^3.6.0", + "svelte": "^4.2.10", + "svelte-check": "^3.6.3", "tailwindcss": "3.4.1", - "tslib": "^2.4.1", - "typescript": "^5.0.0", - "vite": "^5.0.3", + "tslib": "^2.6.2", + "typescript": "^5.3.3", + "vite": "^5.1.0", "vite-plugin-tailwind-purgecss": "0.2.0" }, "dependencies": { "@floating-ui/dom": "1.6.1", "@fortawesome/free-brands-svg-icons": "^6.5.1", - "@octokit/openapi-types": "^19.1.0", - "@octokit/rest": "^20.0.2", + "@octokit/openapi-types": "^22.2.0", + "@octokit/rest": "^21.0.2", "date-fns": "^3.3.1", "highlight.js": "11.9.0", "lucide-svelte": "^0.323.0", diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml new file mode 100644 index 00000000..d2e9f5fe --- /dev/null +++ b/website/pnpm-lock.yaml @@ -0,0 +1,4153 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@floating-ui/dom': + specifier: 1.6.1 + version: 1.6.1 + '@fortawesome/free-brands-svg-icons': + specifier: ^6.5.1 + version: 6.5.1 + '@octokit/openapi-types': + specifier: ^22.2.0 + version: 22.2.0 + '@octokit/rest': + specifier: ^21.0.2 + version: 21.0.2 + date-fns: + specifier: ^3.3.1 + version: 3.3.1 + highlight.js: + specifier: 11.9.0 + version: 11.9.0 + lucide-svelte: + specifier: ^0.323.0 + version: 0.323.0(svelte@4.2.10) + mdsvex-relative-images: + specifier: ^1.0.3 + version: 1.0.3 + rehype-autolink-headings: + specifier: ^7.1.0 + version: 7.1.0 + rehype-slug: + specifier: ^6.0.0 + version: 6.0.0 + remark-container: + specifier: ^0.1.2 + version: 0.1.2 + remark-external-links: + specifier: ^9.0.1 + version: 9.0.1 + remark-gfm: + specifier: ^4.0.0 + version: 4.0.0 + remark-github: + specifier: ^12.0.0 + version: 12.0.0 + remark-reading-time: + specifier: ^1.0.1 + version: 1.0.1 + svelte-fa: + specifier: ^4.0.2 + version: 4.0.2(svelte@4.2.10) + svelte-markdown: + specifier: ^0.4.1 + version: 0.4.1(svelte@4.2.10) + devDependencies: + '@playwright/test': + specifier: ^1.41.2 + version: 1.41.2 + '@skeletonlabs/skeleton': + specifier: 2.8.0 + version: 2.8.0(svelte@4.2.10) + '@skeletonlabs/tw-plugin': + specifier: 0.3.1 + version: 0.3.1(tailwindcss@3.4.1) + '@sveltejs/adapter-cloudflare': + specifier: ^4.1.0 + version: 4.1.0(@sveltejs/kit@2.5.0(@sveltejs/vite-plugin-svelte@3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)))(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16))) + '@sveltejs/kit': + specifier: ^2.5.0 + version: 2.5.0(@sveltejs/vite-plugin-svelte@3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)))(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)) + '@sveltejs/vite-plugin-svelte': + specifier: ^3.0.2 + version: 3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)) + '@tailwindcss/typography': + specifier: 0.5.10 + version: 0.5.10(tailwindcss@3.4.1) + '@types/eslint': + specifier: 8.56.0 + version: 8.56.0 + '@types/node': + specifier: ^20.11.16 + version: 20.11.16 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.56.0)(typescript@5.3.3) + autoprefixer: + specifier: 10.4.17 + version: 10.4.17(postcss@8.4.35) + eslint: + specifier: ^8.56.0 + version: 8.56.0 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@8.56.0) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.35.1(eslint@8.56.0)(svelte@4.2.10) + mdsvex: + specifier: ^0.11.0 + version: 0.11.0(svelte@4.2.10) + postcss: + specifier: 8.4.35 + version: 8.4.35 + prettier: + specifier: ^3.2.5 + version: 3.2.5 + prettier-plugin-svelte: + specifier: ^3.1.2 + version: 3.1.2(prettier@3.2.5)(svelte@4.2.10) + svelte: + specifier: ^4.2.10 + version: 4.2.10 + svelte-check: + specifier: ^3.6.3 + version: 3.6.3(postcss-load-config@4.0.2(postcss@8.4.35))(postcss@8.4.35)(svelte@4.2.10) + tailwindcss: + specifier: 3.4.1 + version: 3.4.1 + tslib: + specifier: ^2.6.2 + version: 2.6.2 + typescript: + specifier: ^5.3.3 + version: 5.3.3 + vite: + specifier: ^5.1.0 + version: 5.1.0(@types/node@20.11.16) + vite-plugin-tailwind-purgecss: + specifier: 0.2.0 + version: 0.2.0(vite@5.1.0(@types/node@20.11.16)) + +packages: + + '@aashutoshrathi/word-wrap@1.2.6': + resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} + engines: {node: '>=0.10.0'} + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@ampproject/remapping@2.2.1': + resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==} + engines: {node: '>=6.0.0'} + + '@cloudflare/workers-types@4.20240208.0': + resolution: {integrity: sha512-MVGTTjZpJu4kJONvai5SdJzWIhOJbuweVZ3goI7FNyG+JdoQH41OoB+nMhLsX626vPLZVWGPIWsiSo/WZHzgQw==} + + '@esbuild/aix-ppc64@0.19.12': + resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.19.12': + resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.19.12': + resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.19.12': + resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.19.12': + resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.19.12': + resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.19.12': + resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.19.12': + resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.19.12': + resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.19.12': + resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.19.12': + resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.19.12': + resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.19.12': + resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.19.12': + resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.19.12': + resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.19.12': + resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.19.12': + resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.19.12': + resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.19.12': + resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.19.12': + resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.19.12': + resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.19.12': + resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.19.12': + resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.4.0': + resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.10.0': + resolution: {integrity: sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.56.0': + resolution: {integrity: sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@floating-ui/core@1.6.0': + resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==} + + '@floating-ui/dom@1.6.1': + resolution: {integrity: sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==} + + '@floating-ui/utils@0.2.1': + resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==} + + '@fortawesome/fontawesome-common-types@6.5.1': + resolution: {integrity: sha512-GkWzv+L6d2bI5f/Vk6ikJ9xtl7dfXtoRu3YGE6nq0p/FFqA1ebMOAWg3XgRyb0I6LYyYkiAo+3/KrwuBp8xG7A==} + engines: {node: '>=6'} + + '@fortawesome/free-brands-svg-icons@6.5.1': + resolution: {integrity: sha512-093l7DAkx0aEtBq66Sf19MgoZewv1zeY9/4C7vSKPO4qMwEsW/2VYTUTpBtLwfb9T2R73tXaRDPmE4UqLCYHfg==} + engines: {node: '>=6'} + + '@humanwhocodes/config-array@0.11.14': + resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.2': + resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} + deprecated: Use @eslint/object-schema instead + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.3': + resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.1': + resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.1.2': + resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.4.15': + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + + '@jridgewell/trace-mapping@0.3.22': + resolution: {integrity: sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@octokit/auth-token@5.1.1': + resolution: {integrity: sha512-rh3G3wDO8J9wSjfI436JUKzHIxq8NaiL0tVeB2aXmG6p/9859aUOAjA9pmSPNGGZxfwmaJ9ozOJImuNVJdpvbA==} + engines: {node: '>= 18'} + + '@octokit/core@6.1.2': + resolution: {integrity: sha512-hEb7Ma4cGJGEUNOAVmyfdB/3WirWMg5hDuNFVejGEDFqupeOysLc2sG6HJxY2etBp5YQu5Wtxwi020jS9xlUwg==} + engines: {node: '>= 18'} + + '@octokit/endpoint@10.1.1': + resolution: {integrity: sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==} + engines: {node: '>= 18'} + + '@octokit/graphql@8.1.1': + resolution: {integrity: sha512-ukiRmuHTi6ebQx/HFRCXKbDlOh/7xEV6QUXaE7MJEKGNAncGI/STSbOkl12qVXZrfZdpXctx5O9X1AIaebiDBg==} + engines: {node: '>= 18'} + + '@octokit/openapi-types@22.2.0': + resolution: {integrity: sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==} + + '@octokit/plugin-paginate-rest@11.3.5': + resolution: {integrity: sha512-cgwIRtKrpwhLoBi0CUNuY83DPGRMaWVjqVI/bGKsLJ4PzyWZNaEmhHroI2xlrVXkk6nFv0IsZpOp+ZWSWUS2AQ==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-request-log@5.3.1': + resolution: {integrity: sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-rest-endpoint-methods@13.2.6': + resolution: {integrity: sha512-wMsdyHMjSfKjGINkdGKki06VEkgdEldIGstIEyGX0wbYHGByOwN/KiM+hAAlUwAtPkP3gvXtVQA9L3ITdV2tVw==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/request-error@6.1.5': + resolution: {integrity: sha512-IlBTfGX8Yn/oFPMwSfvugfncK2EwRLjzbrpifNaMY8o/HTEAFqCA1FZxjD9cWvSKBHgrIhc4CSBIzMxiLsbzFQ==} + engines: {node: '>= 18'} + + '@octokit/request@9.1.3': + resolution: {integrity: sha512-V+TFhu5fdF3K58rs1pGUJIDH5RZLbZm5BI+MNF+6o/ssFNT4vWlCh/tVpF3NxGtP15HUxTTMUbsG5llAuU2CZA==} + engines: {node: '>= 18'} + + '@octokit/rest@21.0.2': + resolution: {integrity: sha512-+CiLisCoyWmYicH25y1cDfCrv41kRSvTq6pPWtRroRJzhsCZWZyCqGyI8foJT5LmScADSwRAnr/xo+eewL04wQ==} + engines: {node: '>= 18'} + + '@octokit/types@13.6.1': + resolution: {integrity: sha512-PHZE9Z+kWXb23Ndik8MKPirBPziOc0D2/3KH1P+6jK5nGWe96kadZuE4jev2/Jq7FvIfTlT2Ltg8Fv2x1v0a5g==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@playwright/test@1.41.2': + resolution: {integrity: sha512-qQB9h7KbibJzrDpkXkYvsmiDJK14FULCCZgEcoe2AvFAS64oCirWTwzTlAYEbKaRxWs5TFesE1Na6izMv3HfGg==} + engines: {node: '>=16'} + hasBin: true + + '@polka/url@1.0.0-next.24': + resolution: {integrity: sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==} + + '@rollup/rollup-android-arm-eabi@4.9.6': + resolution: {integrity: sha512-MVNXSSYN6QXOulbHpLMKYi60ppyO13W9my1qogeiAqtjb2yR4LSmfU2+POvDkLzhjYLXz9Rf9+9a3zFHW1Lecg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.9.6': + resolution: {integrity: sha512-T14aNLpqJ5wzKNf5jEDpv5zgyIqcpn1MlwCrUXLrwoADr2RkWA0vOWP4XxbO9aiO3dvMCQICZdKeDrFl7UMClw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.9.6': + resolution: {integrity: sha512-CqNNAyhRkTbo8VVZ5R85X73H3R5NX9ONnKbXuHisGWC0qRbTTxnF1U4V9NafzJbgGM0sHZpdO83pLPzq8uOZFw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.9.6': + resolution: {integrity: sha512-zRDtdJuRvA1dc9Mp6BWYqAsU5oeLixdfUvkTHuiYOHwqYuQ4YgSmi6+/lPvSsqc/I0Omw3DdICx4Tfacdzmhog==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-linux-arm-gnueabihf@4.9.6': + resolution: {integrity: sha512-oNk8YXDDnNyG4qlNb6is1ojTOGL/tRhbbKeE/YuccItzerEZT68Z9gHrY3ROh7axDc974+zYAPxK5SH0j/G+QQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.9.6': + resolution: {integrity: sha512-Z3O60yxPtuCYobrtzjo0wlmvDdx2qZfeAWTyfOjEDqd08kthDKexLpV97KfAeUXPosENKd8uyJMRDfFMxcYkDQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.9.6': + resolution: {integrity: sha512-gpiG0qQJNdYEVad+1iAsGAbgAnZ8j07FapmnIAQgODKcOTjLEWM9sRb+MbQyVsYCnA0Im6M6QIq6ax7liws6eQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.9.6': + resolution: {integrity: sha512-+uCOcvVmFUYvVDr27aiyun9WgZk0tXe7ThuzoUTAukZJOwS5MrGbmSlNOhx1j80GdpqbOty05XqSl5w4dQvcOA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.9.6': + resolution: {integrity: sha512-HUNqM32dGzfBKuaDUBqFB7tP6VMN74eLZ33Q9Y1TBqRDn+qDonkAUyKWwF9BR9unV7QUzffLnz9GrnKvMqC/fw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.9.6': + resolution: {integrity: sha512-ch7M+9Tr5R4FK40FHQk8VnML0Szi2KRujUgHXd/HjuH9ifH72GUmw6lStZBo3c3GB82vHa0ZoUfjfcM7JiiMrQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-win32-arm64-msvc@4.9.6': + resolution: {integrity: sha512-VD6qnR99dhmTQ1mJhIzXsRcTBvTjbfbGGwKAHcu+52cVl15AC/kplkhxzW/uT0Xl62Y/meBKDZvoJSJN+vTeGA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.9.6': + resolution: {integrity: sha512-J9AFDq/xiRI58eR2NIDfyVmTYGyIZmRcvcAoJ48oDld/NTR8wyiPUu2X/v1navJ+N/FGg68LEbX3Ejd6l8B7MQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.9.6': + resolution: {integrity: sha512-jqzNLhNDvIZOrt69Ce4UjGRpXJBzhUBzawMwnaDAwyHriki3XollsewxWzOzz+4yOFDkuJHtTsZFwMxhYJWmLQ==} + cpu: [x64] + os: [win32] + + '@skeletonlabs/skeleton@2.8.0': + resolution: {integrity: sha512-R6spSJSyW9MA6cnVQ8IV7uoYSXxHmP/oWJ9IHdGDU9epPZaZMmOXUHJSzA1gngccB2jFaA/6jXfS1O1CsIlGMg==} + peerDependencies: + svelte: ^3.56.0 || ^4.0.0 + + '@skeletonlabs/tw-plugin@0.3.1': + resolution: {integrity: sha512-DjjeOHN3HhFQf6gYPT2MUZMkIdw1jeB9mbuKC8etQxUlOR4XitfC7hssRWFJ8RJsvrrN0myCBbdWkVG1JVA96g==} + peerDependencies: + tailwindcss: '>=3.0.0' + + '@sveltejs/adapter-cloudflare@4.1.0': + resolution: {integrity: sha512-AQQdZAZpcFDcBiMEmxbMYhn4yKZYoPZrKUrYpVejjbO+9obIGIuj/jWjVzAEkHqZMZuoRRqPbq+Zq+AWRm4x1Q==} + peerDependencies: + '@sveltejs/kit': ^2.0.0 + + '@sveltejs/kit@2.5.0': + resolution: {integrity: sha512-1uyXvzC2Lu1FZa30T4y5jUAC21R309ZMRG0TPt+PPPbNUoDpy8zSmSNVWYaBWxYDqLGQ5oPNWvjvvF2IjJ1jmA==} + engines: {node: '>=18.13'} + hasBin: true + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^3.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + vite: ^5.0.3 + + '@sveltejs/vite-plugin-svelte-inspector@2.0.0': + resolution: {integrity: sha512-gjr9ZFg1BSlIpfZ4PRewigrvYmHWbDrq2uvvPB1AmTWKuM+dI1JXQSUu2pIrYLb/QncyiIGkFDFKTwJ0XqQZZg==} + engines: {node: ^18.0.0 || >=20} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^3.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + vite: ^5.0.0 + + '@sveltejs/vite-plugin-svelte@3.0.2': + resolution: {integrity: sha512-MpmF/cju2HqUls50WyTHQBZUV3ovV/Uk8k66AN2gwHogNAG8wnW8xtZDhzNBsFJJuvmq1qnzA5kE7YfMJNFv2Q==} + engines: {node: ^18.0.0 || >=20} + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + vite: ^5.0.0 + + '@tailwindcss/typography@0.5.10': + resolution: {integrity: sha512-Pe8BuPJQJd3FfRnm6H0ulKIGoMEQS+Vq01R6M5aCrFB/ccR/shT+0kXLjouGC1gFLm9hopTFN+DMP0pfwRWzPw==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/eslint@8.56.0': + resolution: {integrity: sha512-FlsN0p4FhuYRjIxpbdXovvHQhtlG05O1GG/RNWvdAxTboR438IOTwmrY/vLA+Xfgg06BTkP045M3vpFwTMv1dg==} + + '@types/estree@1.0.5': + resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + + '@types/hast@2.3.10': + resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/marked@5.0.2': + resolution: {integrity: sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==} + + '@types/mdast@3.0.15': + resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} + + '@types/mdast@4.0.3': + resolution: {integrity: sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==} + + '@types/ms@0.7.34': + resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} + + '@types/node@20.11.16': + resolution: {integrity: sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==} + + '@types/pug@2.0.10': + resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==} + + '@types/semver@7.5.6': + resolution: {integrity: sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==} + + '@types/unist@2.0.10': + resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==} + + '@types/unist@3.0.2': + resolution: {integrity: sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==} + + '@typescript-eslint/eslint-plugin@6.21.0': + resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@6.21.0': + resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@6.21.0': + resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/type-utils@6.21.0': + resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@6.21.0': + resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/typescript-estree@6.21.0': + resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@6.21.0': + resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + + '@typescript-eslint/visitor-keys@6.21.0': + resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@ungap/structured-clone@1.2.0': + resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.11.3: + resolution: {integrity: sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.0.1: + resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + autoprefixer@10.4.17: + resolution: {integrity: sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + axobject-query@4.0.0: + resolution: {integrity: sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + before-after-hook@3.0.2: + resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==} + + binary-extensions@2.2.0: + resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + engines: {node: '>=8'} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + braces@3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + + browserslist@4.22.3: + resolution: {integrity: sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001668: + resolution: {integrity: sha512-nWLrdxqCdblixUO+27JtGJJE/txpJlyUy5YN1u53wLZkP0emYCo5zgS6QYft7VUYR42LGgi/S5hdLZTrnyIddw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + code-red@1.0.4: + resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + + cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + date-fns@3.3.1: + resolution: {integrity: sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==} + + debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.0.2: + resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + devalue@4.3.2: + resolution: {integrity: sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + electron-to-chromium@1.4.661: + resolution: {integrity: sha512-AFg4wDHSOk5F+zA8aR+SVIOabu7m0e7BiJnigCvPXzIGy731XENw/lmNxTySpVFtkFEy+eyt4oHhh5FF3NjQNw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + es6-promise@3.3.1: + resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} + + esbuild@0.19.12: + resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.1.2: + resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-compat-utils@0.1.2: + resolution: {integrity: sha512-Jia4JDldWnFNIru1Ehx1H5s9/yxiRHY/TimCuUc0jNexew3cF1gI6CYZil1ociakfWO3rRqFjl1mskBblB3RYg==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-config-prettier@9.1.0: + resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-svelte@2.35.1: + resolution: {integrity: sha512-IF8TpLnROSGy98Z3NrsKXWDSCbNY2ReHDcrYTuXZMbfX7VmESISR78TWgO9zdg4Dht1X8coub5jKwHzP0ExRug==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0-0 + svelte: ^3.37.0 || ^4.0.0 + peerDependenciesMeta: + svelte: + optional: true + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.56.0: + resolution: {integrity: sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + esm-env@1.0.0: + resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==} + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.5.0: + resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + fill-range@7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.2.9: + resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==} + + foreground-child@3.1.1: + resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} + engines: {node: '>=14'} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.3.10: + resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Glob versions prior to v9 are no longer supported + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globalyzer@0.1.0: + resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.0: + resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==} + engines: {node: '>= 0.4'} + + hast-util-heading-rank@3.0.0: + resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-to-string@3.0.0: + resolution: {integrity: sha512-OGkAxX1Ua3cbcW6EJ5pT/tslVb90uViVkcJ4ZZIMW/R33DX/AkcJcRrPebPwJkHYwlDHXz4aIwvAAaAdtrACFA==} + + highlight.js@11.9.0: + resolution: {integrity: sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==} + engines: {node: '>=12.0.0'} + + ignore@5.3.1: + resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} + engines: {node: '>= 4'} + + import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + + import-meta-resolve@4.0.0: + resolution: {integrity: sha512-okYUR7ZQPH+efeuMJGlq4f8ubUgO50kByRPyt/Cy1Io4PSRsPjxME+YlVaCOx+NIToW7hCsZNFJyTPFFKepRSA==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-absolute-url@4.0.1: + resolution: {integrity: sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-buffer@2.0.5: + resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} + engines: {node: '>=4'} + + is-core-module@2.13.1: + resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-reference@3.0.2: + resolution: {integrity: sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} + engines: {node: '>=14'} + + jiti@1.21.0: + resolution: {integrity: sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + just-camel-case@4.0.2: + resolution: {integrity: sha512-df6QI/EIq+6uHe/wtaa9Qq7/pp4wr4pJC/r1+7XhVL6m5j03G6h9u9/rIZr8rDASX7CxwDPQnZjffCo2e6PRLw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + known-css-properties@0.29.0: + resolution: {integrity: sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + lilconfig@3.0.0: + resolution: {integrity: sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.castarray@4.4.0: + resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@10.2.0: + resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} + engines: {node: 14 || >=16.14} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lucide-svelte@0.323.0: + resolution: {integrity: sha512-3GEFk1vCwB8BtHTHZTocFJfX6AtTLQw9a74JSuihAGx+MzhxqeWk8W1TkM4WUlvE0x9UdONM2rJGRyx9IyjkJg==} + peerDependencies: + svelte: ^3 || ^4 || ^5.0.0-next.42 + + magic-string@0.30.7: + resolution: {integrity: sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==} + engines: {node: '>=12'} + + markdown-table@3.0.3: + resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==} + + marked@5.1.2: + resolution: {integrity: sha512-ahRPGXJpjMjwSOlBoTMZAK7ATXkli5qCPxZ21TG44rx1KEo44bii4ekgTDQPNRQ4Kh7JMb9Ub1PVk1NxRSsorg==} + engines: {node: '>= 16'} + hasBin: true + + mdast-util-definitions@5.1.2: + resolution: {integrity: sha512-8SVPMuHqlPME/z3gqVwWY4zVXn8lqKv/pAhC57FuJ40ImXyBpmO5ukh98zB2v7Blql2FiHjHv9LVztSIqjY+MA==} + + mdast-util-find-and-replace@3.0.1: + resolution: {integrity: sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==} + + mdast-util-from-markdown@2.0.0: + resolution: {integrity: sha512-n7MTOr/z+8NAX/wmhhDji8O3bRvPTV/U0oTCaZJkjhPSKTPhS3xufVhKGF8s1pJ7Ox4QgoIU7KHseh09S+9rTA==} + + mdast-util-gfm-autolink-literal@2.0.0: + resolution: {integrity: sha512-FyzMsduZZHSc3i0Px3PQcBT4WJY/X/RCtEJKuybiC6sjPqLv7h1yqAkmILZtuxMSsUyaLUWNp71+vQH2zqp5cg==} + + mdast-util-gfm-footnote@2.0.0: + resolution: {integrity: sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.0.0: + resolution: {integrity: sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-markdown@2.1.0: + resolution: {integrity: sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + + mdsvex-relative-images@1.0.3: + resolution: {integrity: sha512-3XvpnaguRAhC5gchpqCH+A5Yl28xG9WDPylVla0+k90c5LT+QqSM+hwHd1v5C7gB2cAT0AIhuMsY/g6aCw+WDg==} + + mdsvex@0.11.0: + resolution: {integrity: sha512-gJF1s0N2nCmdxcKn8HDn0LKrN8poStqAicp6bBcsKFd/zkUBGLP5e7vnxu+g0pjBbDFOscUyI1mtHz+YK2TCDw==} + peerDependencies: + svelte: '>=3 <5' + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-core-commonmark@2.0.0: + resolution: {integrity: sha512-jThOz/pVmAYUtkroV3D5c1osFXAMv9e0ypGDOIZuCeAe91/sD6BoE2Sjzt30yuXtwOYUmySOhMas/PVyh02itA==} + + micromark-extension-gfm-autolink-literal@2.0.0: + resolution: {integrity: sha512-rTHfnpt/Q7dEAK1Y5ii0W8bhfJlVJFnJMHIPisfPK3gpVNuOP0VnRl96+YJ3RYWV/P4gFeQoGKNlT3RhuvpqAg==} + + micromark-extension-gfm-footnote@2.0.0: + resolution: {integrity: sha512-6Rzu0CYRKDv3BfLAUnZsSlzx3ak6HAoI85KTiijuKIz5UxZxbUI+pD6oHgw+6UtQuiRwnGRhzMmPRv4smcz0fg==} + + micromark-extension-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-c3BR1ClMp5fxxmwP6AoOY2fXO9U8uFMKs4ADD66ahLTNcwzSCyRVU4k7LPV5Nxo/VJiR4TdzxRQY2v3qIUceCw==} + + micromark-extension-gfm-table@2.0.0: + resolution: {integrity: sha512-PoHlhypg1ItIucOaHmKE8fbin3vTLpDOUg8KAr8gRCF1MOZI9Nquq2i/44wFvviM4WuxJzc3demT8Y3dkfvYrw==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.0.1: + resolution: {integrity: sha512-cY5PzGcnULaN5O7T+cOzfMoHjBW7j+T9D2sucA5d/KbsBTPcYdebm9zUd9zzdgJGCwahV+/W78Z3nbulBYVbTw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.0: + resolution: {integrity: sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==} + + micromark-factory-label@2.0.0: + resolution: {integrity: sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==} + + micromark-factory-space@2.0.0: + resolution: {integrity: sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==} + + micromark-factory-title@2.0.0: + resolution: {integrity: sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==} + + micromark-factory-whitespace@2.0.0: + resolution: {integrity: sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==} + + micromark-util-character@2.1.0: + resolution: {integrity: sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==} + + micromark-util-chunked@2.0.0: + resolution: {integrity: sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==} + + micromark-util-classify-character@2.0.0: + resolution: {integrity: sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==} + + micromark-util-combine-extensions@2.0.0: + resolution: {integrity: sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==} + + micromark-util-decode-numeric-character-reference@2.0.1: + resolution: {integrity: sha512-bmkNc7z8Wn6kgjZmVHOX3SowGmVdhYS7yBpMnuMnPzDq/6xwVA604DuOXMZTO1lvq01g+Adfa0pE2UKGlxL1XQ==} + + micromark-util-decode-string@2.0.0: + resolution: {integrity: sha512-r4Sc6leeUTn3P6gk20aFMj2ntPwn6qpDZqWvYmAG6NgvFTIlj4WtrAudLi65qYoaGdXYViXYw2pkmn7QnIFasA==} + + micromark-util-encode@2.0.0: + resolution: {integrity: sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==} + + micromark-util-html-tag-name@2.0.0: + resolution: {integrity: sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==} + + micromark-util-normalize-identifier@2.0.0: + resolution: {integrity: sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==} + + micromark-util-resolve-all@2.0.0: + resolution: {integrity: sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==} + + micromark-util-sanitize-uri@2.0.0: + resolution: {integrity: sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==} + + micromark-util-subtokenize@2.0.0: + resolution: {integrity: sha512-vc93L1t+gpR3p8jxeVdaYlbV2jTYteDje19rNSS/H5dlhxUYll5Fy6vJ2cDwP8RnsXi818yGty1ayP55y3W6fg==} + + micromark-util-symbol@2.0.0: + resolution: {integrity: sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==} + + micromark-util-types@2.0.0: + resolution: {integrity: sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==} + + micromark@4.0.0: + resolution: {integrity: sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==} + + micromatch@4.0.5: + resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + engines: {node: '>=8.6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + + minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.0.4: + resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.0: + resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} + engines: {node: '>=10'} + + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.7: + resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.14: + resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.9.3: + resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.10.1: + resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==} + engines: {node: '>=16 || 14 >=14.17'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + periscopic@3.1.0: + resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} + + picocolors@1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.6: + resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} + engines: {node: '>= 6'} + + playwright-core@1.41.2: + resolution: {integrity: sha512-VaTvwCA4Y8kxEe+kfm2+uUUw5Lubf38RxF7FpBxLPmGe5sdNkSg5e3ChEigaGrX7qdqT3pt2m/98LiyvU2x6CA==} + engines: {node: '>=16'} + hasBin: true + + playwright@1.41.2: + resolution: {integrity: sha512-v0bOa6H2GJChDL8pAeLa/LZC4feoAMbSQm1/jF/ySsWWoaNItvrMP7GEkvEEFyCTUYKMxjQKaTSg5up7nR6/8A==} + engines: {node: '>=16'} + hasBin: true + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.0.1: + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@3.1.4: + resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} + engines: {node: '>= 10'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-load-config@4.0.2: + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-nested@6.0.1: + resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-safe-parser@6.0.0: + resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.3.3 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + + postcss-selector-parser@6.0.15: + resolution: {integrity: sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.35: + resolution: {integrity: sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-svelte@3.1.2: + resolution: {integrity: sha512-7xfMZtwgAWHMT0iZc8jN4o65zgbAQ3+O32V6W7pXrqNvKnHnkoyQCGCbKeUyXKZLbYE0YhFRnamfxfkEGxm8qA==} + peerDependencies: + prettier: ^3.0.0 + svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 + + prettier@3.2.5: + resolution: {integrity: sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==} + engines: {node: '>=14'} + hasBin: true + + prism-svelte@0.4.7: + resolution: {integrity: sha512-yABh19CYbM24V7aS7TuPYRNMqthxwbvx6FF/Rw920YbyBWO3tnyPIqRMgHuSVsLmuHkkBS1Akyof463FVdkeDQ==} + + prismjs@1.29.0: + resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==} + engines: {node: '>=6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + purgecss@6.0.0-alpha.0: + resolution: {integrity: sha512-UC7d7uIyZsky+srEsSXny9BkbTcVn3ZtBCNX3rW3DsqJKhvUXFRpufA4ktcHzWF0+JLZgmsqjUm/8R82x9bHpw==} + hasBin: true + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + reading-time@1.5.0: + resolution: {integrity: sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==} + + regexparam@3.0.0: + resolution: {integrity: sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==} + engines: {node: '>=8'} + + rehype-autolink-headings@7.1.0: + resolution: {integrity: sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==} + + rehype-slug@6.0.0: + resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==} + + remark-container@0.1.2: + resolution: {integrity: sha512-E+G7dSALm3aMqyi15N4DxnRFQmBbHwxVc+9GrbijqwbdHzagUDvi2A3oI27y/PwLkSDRjwMfoc1rCIZayZ2PFg==} + + remark-external-links@9.0.1: + resolution: {integrity: sha512-EYw+p8Zqy5oT5+W8iSKzInfRLY+zeKWHCf0ut+Q5SwnaSIDGXd2zzvp4SWqyAuVbinNmZ0zjMrDKaExWZnTYqQ==} + + remark-gfm@4.0.0: + resolution: {integrity: sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==} + + remark-github@12.0.0: + resolution: {integrity: sha512-ByefQKFN184LeiGRCabfl7zUJsdlMYWEhiLX1gpmQ11yFg6xSuOTW7LVCv0oc1x+YvUMJW23NU36sJX2RWGgvg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-reading-time@1.0.1: + resolution: {integrity: sha512-Z3yW1JSNgQcjpPavsKmWgY7wmqRQMXIKoh8r5RtvJdpDIWWf7O7MkhuFDZh+Ge/1Olv0tvD1pN4T7LEhwBQnUA==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup@4.9.6: + resolution: {integrity: sha512-05lzkCS2uASX0CiLFybYfVkwNbKZG5NFQ6Go0VWyogFTXXbR039UVsegViTntkk4OglHBdF54ccApXRRuXRbsg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + sander@0.5.1: + resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==} + + semver@7.6.0: + resolution: {integrity: sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.6.0: + resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sirv@2.0.4: + resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} + engines: {node: '>= 10'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + sorcery@0.11.0: + resolution: {integrity: sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw==} + hasBin: true + + source-map-js@1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svelte-check@3.6.3: + resolution: {integrity: sha512-Q2nGnoysxUnB9KjnjpQLZwdjK62DHyW6nuH/gm2qteFnDk0lCehe/6z8TsIvYeKjC6luKaWxiNGyOcWiLLPSwA==} + hasBin: true + peerDependencies: + svelte: ^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0 + + svelte-eslint-parser@0.33.1: + resolution: {integrity: sha512-vo7xPGTlKBGdLH8T5L64FipvTrqv3OQRx9d2z5X05KKZDlF4rQk8KViZO4flKERY+5BiVdOh7zZ7JGJWo5P0uA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + svelte: ^3.37.0 || ^4.0.0 + peerDependenciesMeta: + svelte: + optional: true + + svelte-fa@4.0.2: + resolution: {integrity: sha512-lza8Jfii6jcpMQB73mBStONxaLfZsUS+rKJ/hH6WxsHUd+g68+oHIL9yQTk4a0uY9HQk78T/CPvQnED0msqJfg==} + peerDependencies: + svelte: ^4.0.0 + + svelte-hmr@0.15.3: + resolution: {integrity: sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==} + engines: {node: ^12.20 || ^14.13.1 || >= 16} + peerDependencies: + svelte: ^3.19.0 || ^4.0.0 + + svelte-markdown@0.4.1: + resolution: {integrity: sha512-pOlLY6EruKJaWI9my/2bKX8PdTeP5CM0s4VMmwmC2prlOkjAf+AOmTM4wW/l19Y6WZ87YmP8+ZCJCCwBChWjYw==} + peerDependencies: + svelte: ^4.0.0 + + svelte-preprocess@5.1.3: + resolution: {integrity: sha512-xxAkmxGHT+J/GourS5mVJeOXZzne1FR5ljeOUAMXUkfEhkLEllRreXpbl3dIYJlcJRfL1LO1uIAPpBpBfiqGPw==} + engines: {node: '>= 16.0.0', pnpm: ^8.0.0} + peerDependencies: + '@babel/core': ^7.10.2 + coffeescript: ^2.5.1 + less: ^3.11.3 || ^4.0.0 + postcss: ^7 || ^8 + postcss-load-config: ^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + pug: ^3.0.0 + sass: ^1.26.8 + stylus: ^0.55.0 + sugarss: ^2.0.0 || ^3.0.0 || ^4.0.0 + svelte: ^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0 + typescript: '>=3.9.5 || ^4.0.0 || ^5.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + coffeescript: + optional: true + less: + optional: true + postcss: + optional: true + postcss-load-config: + optional: true + pug: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + typescript: + optional: true + + svelte@4.2.10: + resolution: {integrity: sha512-Ep06yCaCdgG1Mafb/Rx8sJ1QS3RW2I2BxGp2Ui9LBHSZ2/tO/aGLc5WqPjgiAP6KAnLJGaIr/zzwQlOo1b8MxA==} + engines: {node: '>=16'} + + tailwindcss@3.4.1: + resolution: {integrity: sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==} + engines: {node: '>=14.0.0'} + hasBin: true + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tiny-glob@0.2.9: + resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + to-vfile@8.0.0: + resolution: {integrity: sha512-IcmH1xB5576MJc9qcfEC/m/nQCFt3fzMHz45sSlgJyTWjRbKW1HAkJpuf3DgE57YzIlZcwcBZA5ENQbBo4aLkg==} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + ts-api-utils@1.2.1: + resolution: {integrity: sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.6.2: + resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + typescript@5.3.3: + resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + + unified@10.1.2: + resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==} + + unified@11.0.4: + resolution: {integrity: sha512-apMPnyLjAX+ty4OrNap7yumyVAMlKx5IWU2wlzzUdYJO9A8f1p9m/gywF/GM2ZDFcjQPrx59Mc90KwmxsoklxQ==} + + unist-util-is@5.2.1: + resolution: {integrity: sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==} + + unist-util-is@6.0.0: + resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} + + unist-util-stringify-position@2.0.3: + resolution: {integrity: sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==} + + unist-util-stringify-position@3.0.3: + resolution: {integrity: sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@4.1.1: + resolution: {integrity: sha512-1xAFJXAKpnnJl8G7K5KgU7FY55y3GcLIXqkzUj5QF/QVP7biUm0K0O2oqVkYsdjzJKifYeWn9+o6piAK2hGSHw==} + + unist-util-visit-parents@5.1.3: + resolution: {integrity: sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==} + + unist-util-visit-parents@6.0.1: + resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} + + unist-util-visit@3.1.0: + resolution: {integrity: sha512-Szoh+R/Ll68QWAyQyZZpQzZQm2UPbxibDvaY8Xc9SUtYgPsDzx5AWSk++UUt2hJuow8mvwR+rG+LQLw+KsuAKA==} + + unist-util-visit@4.1.2: + resolution: {integrity: sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==} + + unist-util-visit@5.0.0: + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + + universal-user-agent@7.0.2: + resolution: {integrity: sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==} + + update-browserslist-db@1.0.13: + resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vfile-message@2.0.4: + resolution: {integrity: sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==} + + vfile-message@3.1.4: + resolution: {integrity: sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==} + + vfile-message@4.0.2: + resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} + + vfile@5.3.7: + resolution: {integrity: sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==} + + vfile@6.0.1: + resolution: {integrity: sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw==} + + vite-plugin-tailwind-purgecss@0.2.0: + resolution: {integrity: sha512-6Q+SaalUd0t3BOIIiCQPlbZQuYARVgjoC78X+fLbQJqIEy/9fC58aQgHMgi+CmYfVfZmJToA8YiLueSGEo2mng==} + peerDependencies: + vite: ^4.1.1 || ^5.0.0 + + vite@5.1.0: + resolution: {integrity: sha512-STmSFzhY4ljuhz14bg9LkMTk3d98IO6DIArnTY6MeBwiD1Za2StcQtz7fzOUnRCqrHSD5+OS2reg4HOz1eoLnw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitefu@0.2.5: + resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + vite: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + worktop@0.8.0-next.18: + resolution: {integrity: sha512-+TvsA6VAVoMC3XDKR5MoC/qlLqDixEfOBysDEKnPIPou/NvoPWCAuXHXMsswwlvmEuvX56lQjvELLyLuzTKvRw==} + engines: {node: '>=12'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + + yaml@2.3.4: + resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==} + engines: {node: '>= 14'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@aashutoshrathi/word-wrap@1.2.6': {} + + '@alloc/quick-lru@5.2.0': {} + + '@ampproject/remapping@2.2.1': + dependencies: + '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/trace-mapping': 0.3.22 + + '@cloudflare/workers-types@4.20240208.0': {} + + '@esbuild/aix-ppc64@0.19.12': + optional: true + + '@esbuild/android-arm64@0.19.12': + optional: true + + '@esbuild/android-arm@0.19.12': + optional: true + + '@esbuild/android-x64@0.19.12': + optional: true + + '@esbuild/darwin-arm64@0.19.12': + optional: true + + '@esbuild/darwin-x64@0.19.12': + optional: true + + '@esbuild/freebsd-arm64@0.19.12': + optional: true + + '@esbuild/freebsd-x64@0.19.12': + optional: true + + '@esbuild/linux-arm64@0.19.12': + optional: true + + '@esbuild/linux-arm@0.19.12': + optional: true + + '@esbuild/linux-ia32@0.19.12': + optional: true + + '@esbuild/linux-loong64@0.19.12': + optional: true + + '@esbuild/linux-mips64el@0.19.12': + optional: true + + '@esbuild/linux-ppc64@0.19.12': + optional: true + + '@esbuild/linux-riscv64@0.19.12': + optional: true + + '@esbuild/linux-s390x@0.19.12': + optional: true + + '@esbuild/linux-x64@0.19.12': + optional: true + + '@esbuild/netbsd-x64@0.19.12': + optional: true + + '@esbuild/openbsd-x64@0.19.12': + optional: true + + '@esbuild/sunos-x64@0.19.12': + optional: true + + '@esbuild/win32-arm64@0.19.12': + optional: true + + '@esbuild/win32-ia32@0.19.12': + optional: true + + '@esbuild/win32-x64@0.19.12': + optional: true + + '@eslint-community/eslint-utils@4.4.0(eslint@8.56.0)': + dependencies: + eslint: 8.56.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.10.0': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.3.4 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.1 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.56.0': {} + + '@floating-ui/core@1.6.0': + dependencies: + '@floating-ui/utils': 0.2.1 + + '@floating-ui/dom@1.6.1': + dependencies: + '@floating-ui/core': 1.6.0 + '@floating-ui/utils': 0.2.1 + + '@floating-ui/utils@0.2.1': {} + + '@fortawesome/fontawesome-common-types@6.5.1': {} + + '@fortawesome/free-brands-svg-icons@6.5.1': + dependencies: + '@fortawesome/fontawesome-common-types': 6.5.1 + + '@humanwhocodes/config-array@0.11.14': + dependencies: + '@humanwhocodes/object-schema': 2.0.2 + debug: 4.3.4 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.2': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.3': + dependencies: + '@jridgewell/set-array': 1.1.2 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.22 + + '@jridgewell/resolve-uri@3.1.1': {} + + '@jridgewell/set-array@1.1.2': {} + + '@jridgewell/sourcemap-codec@1.4.15': {} + + '@jridgewell/trace-mapping@0.3.22': + dependencies: + '@jridgewell/resolve-uri': 3.1.1 + '@jridgewell/sourcemap-codec': 1.4.15 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.1 + + '@octokit/auth-token@5.1.1': {} + + '@octokit/core@6.1.2': + dependencies: + '@octokit/auth-token': 5.1.1 + '@octokit/graphql': 8.1.1 + '@octokit/request': 9.1.3 + '@octokit/request-error': 6.1.5 + '@octokit/types': 13.6.1 + before-after-hook: 3.0.2 + universal-user-agent: 7.0.2 + + '@octokit/endpoint@10.1.1': + dependencies: + '@octokit/types': 13.6.1 + universal-user-agent: 7.0.2 + + '@octokit/graphql@8.1.1': + dependencies: + '@octokit/request': 9.1.3 + '@octokit/types': 13.6.1 + universal-user-agent: 7.0.2 + + '@octokit/openapi-types@22.2.0': {} + + '@octokit/plugin-paginate-rest@11.3.5(@octokit/core@6.1.2)': + dependencies: + '@octokit/core': 6.1.2 + '@octokit/types': 13.6.1 + + '@octokit/plugin-request-log@5.3.1(@octokit/core@6.1.2)': + dependencies: + '@octokit/core': 6.1.2 + + '@octokit/plugin-rest-endpoint-methods@13.2.6(@octokit/core@6.1.2)': + dependencies: + '@octokit/core': 6.1.2 + '@octokit/types': 13.6.1 + + '@octokit/request-error@6.1.5': + dependencies: + '@octokit/types': 13.6.1 + + '@octokit/request@9.1.3': + dependencies: + '@octokit/endpoint': 10.1.1 + '@octokit/request-error': 6.1.5 + '@octokit/types': 13.6.1 + universal-user-agent: 7.0.2 + + '@octokit/rest@21.0.2': + dependencies: + '@octokit/core': 6.1.2 + '@octokit/plugin-paginate-rest': 11.3.5(@octokit/core@6.1.2) + '@octokit/plugin-request-log': 5.3.1(@octokit/core@6.1.2) + '@octokit/plugin-rest-endpoint-methods': 13.2.6(@octokit/core@6.1.2) + + '@octokit/types@13.6.1': + dependencies: + '@octokit/openapi-types': 22.2.0 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@playwright/test@1.41.2': + dependencies: + playwright: 1.41.2 + + '@polka/url@1.0.0-next.24': {} + + '@rollup/rollup-android-arm-eabi@4.9.6': + optional: true + + '@rollup/rollup-android-arm64@4.9.6': + optional: true + + '@rollup/rollup-darwin-arm64@4.9.6': + optional: true + + '@rollup/rollup-darwin-x64@4.9.6': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.9.6': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.9.6': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.9.6': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.9.6': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.9.6': + optional: true + + '@rollup/rollup-linux-x64-musl@4.9.6': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.9.6': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.9.6': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.9.6': + optional: true + + '@skeletonlabs/skeleton@2.8.0(svelte@4.2.10)': + dependencies: + esm-env: 1.0.0 + svelte: 4.2.10 + + '@skeletonlabs/tw-plugin@0.3.1(tailwindcss@3.4.1)': + dependencies: + tailwindcss: 3.4.1 + + '@sveltejs/adapter-cloudflare@4.1.0(@sveltejs/kit@2.5.0(@sveltejs/vite-plugin-svelte@3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)))(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)))': + dependencies: + '@cloudflare/workers-types': 4.20240208.0 + '@sveltejs/kit': 2.5.0(@sveltejs/vite-plugin-svelte@3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)))(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)) + esbuild: 0.19.12 + worktop: 0.8.0-next.18 + + '@sveltejs/kit@2.5.0(@sveltejs/vite-plugin-svelte@3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)))(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16))': + dependencies: + '@sveltejs/vite-plugin-svelte': 3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)) + '@types/cookie': 0.6.0 + cookie: 0.6.0 + devalue: 4.3.2 + esm-env: 1.0.0 + import-meta-resolve: 4.0.0 + kleur: 4.1.5 + magic-string: 0.30.7 + mrmime: 2.0.0 + sade: 1.8.1 + set-cookie-parser: 2.6.0 + sirv: 2.0.4 + svelte: 4.2.10 + tiny-glob: 0.2.9 + vite: 5.1.0(@types/node@20.11.16) + + '@sveltejs/vite-plugin-svelte-inspector@2.0.0(@sveltejs/vite-plugin-svelte@3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)))(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16))': + dependencies: + '@sveltejs/vite-plugin-svelte': 3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)) + debug: 4.3.4 + svelte: 4.2.10 + vite: 5.1.0(@types/node@20.11.16) + transitivePeerDependencies: + - supports-color + + '@sveltejs/vite-plugin-svelte@3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 2.0.0(@sveltejs/vite-plugin-svelte@3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)))(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)) + debug: 4.3.4 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.7 + svelte: 4.2.10 + svelte-hmr: 0.15.3(svelte@4.2.10) + vite: 5.1.0(@types/node@20.11.16) + vitefu: 0.2.5(vite@5.1.0(@types/node@20.11.16)) + transitivePeerDependencies: + - supports-color + + '@tailwindcss/typography@0.5.10(tailwindcss@3.4.1)': + dependencies: + lodash.castarray: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.merge: 4.6.2 + postcss-selector-parser: 6.0.10 + tailwindcss: 3.4.1 + + '@types/cookie@0.6.0': {} + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 0.7.34 + + '@types/eslint@8.56.0': + dependencies: + '@types/estree': 1.0.5 + '@types/json-schema': 7.0.15 + + '@types/estree@1.0.5': {} + + '@types/hast@2.3.10': + dependencies: + '@types/unist': 2.0.10 + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.2 + + '@types/json-schema@7.0.15': {} + + '@types/marked@5.0.2': {} + + '@types/mdast@3.0.15': + dependencies: + '@types/unist': 2.0.10 + + '@types/mdast@4.0.3': + dependencies: + '@types/unist': 3.0.2 + + '@types/ms@0.7.34': {} + + '@types/node@20.11.16': + dependencies: + undici-types: 5.26.5 + + '@types/pug@2.0.10': {} + + '@types/semver@7.5.6': {} + + '@types/unist@2.0.10': {} + + '@types/unist@3.0.2': {} + + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3)': + dependencies: + '@eslint-community/regexpp': 4.10.0 + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/type-utils': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.3.4 + eslint: 8.56.0 + graphemer: 1.4.0 + ignore: 5.3.1 + natural-compare: 1.4.0 + semver: 7.6.0 + ts-api-utils: 1.2.1(typescript@5.3.3) + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3)': + dependencies: + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.3.4 + eslint: 8.56.0 + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + + '@typescript-eslint/type-utils@6.21.0(eslint@8.56.0)(typescript@5.3.3)': + dependencies: + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + debug: 4.3.4 + eslint: 8.56.0 + ts-api-utils: 1.2.1(typescript@5.3.3) + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@6.21.0': {} + + '@typescript-eslint/typescript-estree@6.21.0(typescript@5.3.3)': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.3.4 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.3 + semver: 7.6.0 + ts-api-utils: 1.2.1(typescript@5.3.3) + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@6.21.0(eslint@8.56.0)(typescript@5.3.3)': + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.56.0) + '@types/json-schema': 7.0.15 + '@types/semver': 7.5.6 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) + eslint: 8.56.0 + semver: 7.6.0 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + eslint-visitor-keys: 3.4.3 + + '@ungap/structured-clone@1.2.0': {} + + acorn-jsx@5.3.2(acorn@8.11.3): + dependencies: + acorn: 8.11.3 + + acorn@8.11.3: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-regex@6.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.1: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@5.0.2: {} + + argparse@2.0.1: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + array-union@2.1.0: {} + + autoprefixer@10.4.17(postcss@8.4.35): + dependencies: + browserslist: 4.22.3 + caniuse-lite: 1.0.30001668 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.0.0 + postcss: 8.4.35 + postcss-value-parser: 4.2.0 + + axobject-query@4.0.0: + dependencies: + dequal: 2.0.3 + + bail@2.0.2: {} + + balanced-match@1.0.2: {} + + before-after-hook@3.0.2: {} + + binary-extensions@2.2.0: {} + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.2: + dependencies: + fill-range: 7.0.1 + + browserslist@4.22.3: + dependencies: + caniuse-lite: 1.0.30001668 + electron-to-chromium: 1.4.661 + node-releases: 2.0.14 + update-browserslist-db: 1.0.13(browserslist@4.22.3) + + buffer-crc32@0.2.13: {} + + callsites@3.1.0: {} + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001668: {} + + ccount@2.0.1: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + character-entities@2.0.2: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + code-red@1.0.4: + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + '@types/estree': 1.0.5 + acorn: 8.11.3 + estree-walker: 3.0.3 + periscopic: 3.1.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@10.0.1: {} + + commander@4.1.1: {} + + concat-map@0.0.1: {} + + cookie@0.6.0: {} + + cross-spawn@7.0.3: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.0.2 + + cssesc@3.0.0: {} + + date-fns@3.3.1: {} + + debug@4.3.4: + dependencies: + ms: 2.1.2 + + decode-named-character-reference@1.0.2: + dependencies: + character-entities: 2.0.2 + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + dequal@2.0.3: {} + + detect-indent@6.1.0: {} + + devalue@4.3.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + didyoumean@1.2.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dlv@1.1.3: {} + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + eastasianwidth@0.2.0: {} + + electron-to-chromium@1.4.661: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + es6-promise@3.3.1: {} + + esbuild@0.19.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.19.12 + '@esbuild/android-arm': 0.19.12 + '@esbuild/android-arm64': 0.19.12 + '@esbuild/android-x64': 0.19.12 + '@esbuild/darwin-arm64': 0.19.12 + '@esbuild/darwin-x64': 0.19.12 + '@esbuild/freebsd-arm64': 0.19.12 + '@esbuild/freebsd-x64': 0.19.12 + '@esbuild/linux-arm': 0.19.12 + '@esbuild/linux-arm64': 0.19.12 + '@esbuild/linux-ia32': 0.19.12 + '@esbuild/linux-loong64': 0.19.12 + '@esbuild/linux-mips64el': 0.19.12 + '@esbuild/linux-ppc64': 0.19.12 + '@esbuild/linux-riscv64': 0.19.12 + '@esbuild/linux-s390x': 0.19.12 + '@esbuild/linux-x64': 0.19.12 + '@esbuild/netbsd-x64': 0.19.12 + '@esbuild/openbsd-x64': 0.19.12 + '@esbuild/sunos-x64': 0.19.12 + '@esbuild/win32-arm64': 0.19.12 + '@esbuild/win32-ia32': 0.19.12 + '@esbuild/win32-x64': 0.19.12 + + escalade@3.1.2: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-compat-utils@0.1.2(eslint@8.56.0): + dependencies: + eslint: 8.56.0 + + eslint-config-prettier@9.1.0(eslint@8.56.0): + dependencies: + eslint: 8.56.0 + + eslint-plugin-svelte@2.35.1(eslint@8.56.0)(svelte@4.2.10): + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.56.0) + '@jridgewell/sourcemap-codec': 1.4.15 + debug: 4.3.4 + eslint: 8.56.0 + eslint-compat-utils: 0.1.2(eslint@8.56.0) + esutils: 2.0.3 + known-css-properties: 0.29.0 + postcss: 8.4.35 + postcss-load-config: 3.1.4(postcss@8.4.35) + postcss-safe-parser: 6.0.0(postcss@8.4.35) + postcss-selector-parser: 6.0.15 + semver: 7.6.0 + svelte-eslint-parser: 0.33.1(svelte@4.2.10) + optionalDependencies: + svelte: 4.2.10 + transitivePeerDependencies: + - supports-color + - ts-node + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.56.0: + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.56.0) + '@eslint-community/regexpp': 4.10.0 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.56.0 + '@humanwhocodes/config-array': 0.11.14 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.2.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.4 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.5.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.1 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.3 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + esm-env@1.0.0: {} + + espree@9.6.1: + dependencies: + acorn: 8.11.3 + acorn-jsx: 5.3.2(acorn@8.11.3) + eslint-visitor-keys: 3.4.3 + + esquery@1.5.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.5 + + esutils@2.0.3: {} + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.2: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.5 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.17.1: + dependencies: + reusify: 1.0.4 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + fill-range@7.0.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.2.9 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.2.9: {} + + foreground-child@3.1.1: + dependencies: + cross-spawn: 7.0.3 + signal-exit: 4.1.0 + + fraction.js@4.3.7: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + github-slugger@2.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.3.10: + dependencies: + foreground-child: 3.1.1 + jackspeak: 2.3.6 + minimatch: 9.0.3 + minipass: 7.0.4 + path-scurry: 1.10.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.6 + once: 1.4.0 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globalyzer@0.1.0: {} + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.3.1 + merge2: 1.4.1 + slash: 3.0.0 + + globrex@0.1.2: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + has-flag@4.0.0: {} + + hasown@2.0.0: + dependencies: + function-bind: 1.1.2 + + hast-util-heading-rank@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-to-string@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + highlight.js@11.9.0: {} + + ignore@5.3.1: {} + + import-fresh@3.3.0: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-meta-resolve@4.0.0: {} + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + is-absolute-url@4.0.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.2.0 + + is-buffer@2.0.5: {} + + is-core-module@2.13.1: + dependencies: + hasown: 2.0.0 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-plain-obj@4.1.0: {} + + is-reference@3.0.2: + dependencies: + '@types/estree': 1.0.5 + + isexe@2.0.0: {} + + jackspeak@2.3.6: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@1.21.0: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + just-camel-case@4.0.2: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@4.1.5: {} + + known-css-properties@0.29.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@2.1.0: {} + + lilconfig@3.0.0: {} + + lines-and-columns@1.2.4: {} + + locate-character@3.0.0: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.castarray@4.4.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.merge@4.6.2: {} + + longest-streak@3.1.0: {} + + lru-cache@10.2.0: {} + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + lucide-svelte@0.323.0(svelte@4.2.10): + dependencies: + svelte: 4.2.10 + + magic-string@0.30.7: + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + + markdown-table@3.0.3: {} + + marked@5.1.2: {} + + mdast-util-definitions@5.1.2: + dependencies: + '@types/mdast': 3.0.15 + '@types/unist': 2.0.10 + unist-util-visit: 4.1.2 + + mdast-util-find-and-replace@3.0.1: + dependencies: + '@types/mdast': 4.0.3 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + + mdast-util-from-markdown@2.0.0: + dependencies: + '@types/mdast': 4.0.3 + '@types/unist': 3.0.2 + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.0 + micromark-util-decode-numeric-character-reference: 2.0.1 + micromark-util-decode-string: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.0: + dependencies: + '@types/mdast': 4.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.1 + micromark-util-character: 2.1.0 + + mdast-util-gfm-footnote@2.0.0: + dependencies: + '@types/mdast': 4.0.3 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.0 + mdast-util-to-markdown: 2.1.0 + micromark-util-normalize-identifier: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.3 + mdast-util-from-markdown: 2.0.0 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.3 + devlop: 1.1.0 + markdown-table: 3.0.3 + mdast-util-from-markdown: 2.0.0 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.3 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.0 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.0 + mdast-util-gfm-autolink-literal: 2.0.0 + mdast-util-gfm-footnote: 2.0.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.3 + unist-util-is: 6.0.0 + + mdast-util-to-markdown@2.1.0: + dependencies: + '@types/mdast': 4.0.3 + '@types/unist': 3.0.2 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-decode-string: 2.0.0 + unist-util-visit: 5.0.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.3 + + mdn-data@2.0.30: {} + + mdsvex-relative-images@1.0.3: + dependencies: + just-camel-case: 4.0.2 + unist-util-visit: 3.1.0 + + mdsvex@0.11.0(svelte@4.2.10): + dependencies: + '@types/unist': 2.0.10 + prism-svelte: 0.4.7 + prismjs: 1.29.0 + svelte: 4.2.10 + vfile-message: 2.0.4 + + merge2@1.4.1: {} + + micromark-core-commonmark@2.0.0: + dependencies: + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + micromark-factory-destination: 2.0.0 + micromark-factory-label: 2.0.0 + micromark-factory-space: 2.0.0 + micromark-factory-title: 2.0.0 + micromark-factory-whitespace: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-classify-character: 2.0.0 + micromark-util-html-tag-name: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-subtokenize: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-autolink-literal@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-footnote@2.0.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-strikethrough@2.0.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-classify-character: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-table@2.0.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.0 + + micromark-extension-gfm-task-list-item@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.0.0 + micromark-extension-gfm-footnote: 2.0.0 + micromark-extension-gfm-strikethrough: 2.0.0 + micromark-extension-gfm-table: 2.0.0 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.0.1 + micromark-util-combine-extensions: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-destination@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-label@2.0.0: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-space@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-types: 2.0.0 + + micromark-factory-title@2.0.0: + dependencies: + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-whitespace@2.0.0: + dependencies: + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-character@2.1.0: + dependencies: + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-chunked@2.0.0: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-classify-character@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-combine-extensions@2.0.0: + dependencies: + micromark-util-chunked: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-decode-numeric-character-reference@2.0.1: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-decode-string@2.0.0: + dependencies: + decode-named-character-reference: 1.0.2 + micromark-util-character: 2.1.0 + micromark-util-decode-numeric-character-reference: 2.0.1 + micromark-util-symbol: 2.0.0 + + micromark-util-encode@2.0.0: {} + + micromark-util-html-tag-name@2.0.0: {} + + micromark-util-normalize-identifier@2.0.0: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-resolve-all@2.0.0: + dependencies: + micromark-util-types: 2.0.0 + + micromark-util-sanitize-uri@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-encode: 2.0.0 + micromark-util-symbol: 2.0.0 + + micromark-util-subtokenize@2.0.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-symbol@2.0.0: {} + + micromark-util-types@2.0.0: {} + + micromark@4.0.0: + dependencies: + '@types/debug': 4.1.12 + debug: 4.3.4 + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-combine-extensions: 2.0.0 + micromark-util-decode-numeric-character-reference: 2.0.1 + micromark-util-encode: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-subtokenize: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.5: + dependencies: + braces: 3.0.2 + picomatch: 2.3.1 + + min-indent@1.0.1: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + + minimatch@5.1.6: + dependencies: + brace-expansion: 2.0.1 + + minimatch@9.0.3: + dependencies: + brace-expansion: 2.0.1 + + minimist@1.2.8: {} + + minipass@7.0.4: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + mri@1.2.0: {} + + mrmime@2.0.0: {} + + ms@2.1.2: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.7: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.14: {} + + normalize-path@3.0.0: {} + + normalize-range@0.1.2: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.3: + dependencies: + '@aashutoshrathi/word-wrap': 1.2.6 + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.10.1: + dependencies: + lru-cache: 10.2.0 + minipass: 7.0.4 + + path-type@4.0.0: {} + + periscopic@3.1.0: + dependencies: + '@types/estree': 1.0.5 + estree-walker: 3.0.3 + is-reference: 3.0.2 + + picocolors@1.0.0: {} + + picomatch@2.3.1: {} + + pify@2.3.0: {} + + pirates@4.0.6: {} + + playwright-core@1.41.2: {} + + playwright@1.41.2: + dependencies: + playwright-core: 1.41.2 + optionalDependencies: + fsevents: 2.3.2 + + postcss-import@15.1.0(postcss@8.4.35): + dependencies: + postcss: 8.4.35 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.8 + + postcss-js@4.0.1(postcss@8.4.35): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.4.35 + + postcss-load-config@3.1.4(postcss@8.4.35): + dependencies: + lilconfig: 2.1.0 + yaml: 1.10.2 + optionalDependencies: + postcss: 8.4.35 + + postcss-load-config@4.0.2(postcss@8.4.35): + dependencies: + lilconfig: 3.0.0 + yaml: 2.3.4 + optionalDependencies: + postcss: 8.4.35 + + postcss-nested@6.0.1(postcss@8.4.35): + dependencies: + postcss: 8.4.35 + postcss-selector-parser: 6.0.15 + + postcss-safe-parser@6.0.0(postcss@8.4.35): + dependencies: + postcss: 8.4.35 + + postcss-scss@4.0.9(postcss@8.4.35): + dependencies: + postcss: 8.4.35 + + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@6.0.15: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.4.35: + dependencies: + nanoid: 3.3.7 + picocolors: 1.0.0 + source-map-js: 1.0.2 + + prelude-ls@1.2.1: {} + + prettier-plugin-svelte@3.1.2(prettier@3.2.5)(svelte@4.2.10): + dependencies: + prettier: 3.2.5 + svelte: 4.2.10 + + prettier@3.2.5: {} + + prism-svelte@0.4.7: {} + + prismjs@1.29.0: {} + + punycode@2.3.1: {} + + purgecss@6.0.0-alpha.0: + dependencies: + commander: 10.0.1 + glob: 8.1.0 + postcss: 8.4.35 + postcss-selector-parser: 6.0.15 + + queue-microtask@1.2.3: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + reading-time@1.5.0: {} + + regexparam@3.0.0: {} + + rehype-autolink-headings@7.1.0: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.2.0 + hast-util-heading-rank: 3.0.0 + hast-util-is-element: 3.0.0 + unified: 11.0.4 + unist-util-visit: 5.0.0 + + rehype-slug@6.0.0: + dependencies: + '@types/hast': 3.0.4 + github-slugger: 2.0.0 + hast-util-heading-rank: 3.0.0 + hast-util-to-string: 3.0.0 + unist-util-visit: 5.0.0 + + remark-container@0.1.2: {} + + remark-external-links@9.0.1: + dependencies: + '@types/hast': 2.3.10 + '@types/mdast': 3.0.15 + extend: 3.0.2 + is-absolute-url: 4.0.1 + mdast-util-definitions: 5.1.2 + space-separated-tokens: 2.0.2 + unified: 10.1.2 + unist-util-visit: 4.1.2 + + remark-gfm@4.0.0: + dependencies: + '@types/mdast': 4.0.3 + mdast-util-gfm: 3.0.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.4 + transitivePeerDependencies: + - supports-color + + remark-github@12.0.0: + dependencies: + '@types/mdast': 4.0.3 + mdast-util-find-and-replace: 3.0.1 + mdast-util-to-string: 4.0.0 + to-vfile: 8.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.1 + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.3 + mdast-util-from-markdown: 2.0.0 + micromark-util-types: 2.0.0 + unified: 11.0.4 + transitivePeerDependencies: + - supports-color + + remark-reading-time@1.0.1: + dependencies: + reading-time: 1.5.0 + unist-util-visit: 3.1.0 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.3 + mdast-util-to-markdown: 2.1.0 + unified: 11.0.4 + + resolve-from@4.0.0: {} + + resolve@1.22.8: + dependencies: + is-core-module: 2.13.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.0.4: {} + + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rollup@4.9.6: + dependencies: + '@types/estree': 1.0.5 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.9.6 + '@rollup/rollup-android-arm64': 4.9.6 + '@rollup/rollup-darwin-arm64': 4.9.6 + '@rollup/rollup-darwin-x64': 4.9.6 + '@rollup/rollup-linux-arm-gnueabihf': 4.9.6 + '@rollup/rollup-linux-arm64-gnu': 4.9.6 + '@rollup/rollup-linux-arm64-musl': 4.9.6 + '@rollup/rollup-linux-riscv64-gnu': 4.9.6 + '@rollup/rollup-linux-x64-gnu': 4.9.6 + '@rollup/rollup-linux-x64-musl': 4.9.6 + '@rollup/rollup-win32-arm64-msvc': 4.9.6 + '@rollup/rollup-win32-ia32-msvc': 4.9.6 + '@rollup/rollup-win32-x64-msvc': 4.9.6 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + sander@0.5.1: + dependencies: + es6-promise: 3.3.1 + graceful-fs: 4.2.11 + mkdirp: 0.5.6 + rimraf: 2.7.1 + + semver@7.6.0: + dependencies: + lru-cache: 6.0.0 + + set-cookie-parser@2.6.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + sirv@2.0.4: + dependencies: + '@polka/url': 1.0.0-next.24 + mrmime: 2.0.0 + totalist: 3.0.1 + + slash@3.0.0: {} + + sorcery@0.11.0: + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + buffer-crc32: 0.2.13 + minimist: 1.2.8 + sander: 0.5.1 + + source-map-js@1.0.2: {} + + space-separated-tokens@2.0.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.0.1 + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + sucrase@3.35.0: + dependencies: + '@jridgewell/gen-mapping': 0.3.3 + commander: 4.1.1 + glob: 10.3.10 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.6 + ts-interface-checker: 0.1.13 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svelte-check@3.6.3(postcss-load-config@4.0.2(postcss@8.4.35))(postcss@8.4.35)(svelte@4.2.10): + dependencies: + '@jridgewell/trace-mapping': 0.3.22 + chokidar: 3.6.0 + fast-glob: 3.3.2 + import-fresh: 3.3.0 + picocolors: 1.0.0 + sade: 1.8.1 + svelte: 4.2.10 + svelte-preprocess: 5.1.3(postcss-load-config@4.0.2(postcss@8.4.35))(postcss@8.4.35)(svelte@4.2.10)(typescript@5.3.3) + typescript: 5.3.3 + transitivePeerDependencies: + - '@babel/core' + - coffeescript + - less + - postcss + - postcss-load-config + - pug + - sass + - stylus + - sugarss + + svelte-eslint-parser@0.33.1(svelte@4.2.10): + dependencies: + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + postcss: 8.4.35 + postcss-scss: 4.0.9(postcss@8.4.35) + optionalDependencies: + svelte: 4.2.10 + + svelte-fa@4.0.2(svelte@4.2.10): + dependencies: + svelte: 4.2.10 + + svelte-hmr@0.15.3(svelte@4.2.10): + dependencies: + svelte: 4.2.10 + + svelte-markdown@0.4.1(svelte@4.2.10): + dependencies: + '@types/marked': 5.0.2 + marked: 5.1.2 + svelte: 4.2.10 + + svelte-preprocess@5.1.3(postcss-load-config@4.0.2(postcss@8.4.35))(postcss@8.4.35)(svelte@4.2.10)(typescript@5.3.3): + dependencies: + '@types/pug': 2.0.10 + detect-indent: 6.1.0 + magic-string: 0.30.7 + sorcery: 0.11.0 + strip-indent: 3.0.0 + svelte: 4.2.10 + optionalDependencies: + postcss: 8.4.35 + postcss-load-config: 4.0.2(postcss@8.4.35) + typescript: 5.3.3 + + svelte@4.2.10: + dependencies: + '@ampproject/remapping': 2.2.1 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.22 + '@types/estree': 1.0.5 + acorn: 8.11.3 + aria-query: 5.3.0 + axobject-query: 4.0.0 + code-red: 1.0.4 + css-tree: 2.3.1 + estree-walker: 3.0.3 + is-reference: 3.0.2 + locate-character: 3.0.0 + magic-string: 0.30.7 + periscopic: 3.1.0 + + tailwindcss@3.4.1: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.2 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.0 + lilconfig: 2.1.0 + micromatch: 4.0.5 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.0.0 + postcss: 8.4.35 + postcss-import: 15.1.0(postcss@8.4.35) + postcss-js: 4.0.1(postcss@8.4.35) + postcss-load-config: 4.0.2(postcss@8.4.35) + postcss-nested: 6.0.1(postcss@8.4.35) + postcss-selector-parser: 6.0.15 + resolve: 1.22.8 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + + text-table@0.2.0: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tiny-glob@0.2.9: + dependencies: + globalyzer: 0.1.0 + globrex: 0.1.2 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + to-vfile@8.0.0: + dependencies: + vfile: 6.0.1 + + totalist@3.0.1: {} + + trough@2.2.0: {} + + ts-api-utils@1.2.1(typescript@5.3.3): + dependencies: + typescript: 5.3.3 + + ts-interface-checker@0.1.13: {} + + tslib@2.6.2: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + typescript@5.3.3: {} + + undici-types@5.26.5: {} + + unified@10.1.2: + dependencies: + '@types/unist': 2.0.10 + bail: 2.0.2 + extend: 3.0.2 + is-buffer: 2.0.5 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 5.3.7 + + unified@11.0.4: + dependencies: + '@types/unist': 3.0.2 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.1 + + unist-util-is@5.2.1: + dependencies: + '@types/unist': 2.0.10 + + unist-util-is@6.0.0: + dependencies: + '@types/unist': 3.0.2 + + unist-util-stringify-position@2.0.3: + dependencies: + '@types/unist': 2.0.10 + + unist-util-stringify-position@3.0.3: + dependencies: + '@types/unist': 2.0.10 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.2 + + unist-util-visit-parents@4.1.1: + dependencies: + '@types/unist': 2.0.10 + unist-util-is: 5.2.1 + + unist-util-visit-parents@5.1.3: + dependencies: + '@types/unist': 2.0.10 + unist-util-is: 5.2.1 + + unist-util-visit-parents@6.0.1: + dependencies: + '@types/unist': 3.0.2 + unist-util-is: 6.0.0 + + unist-util-visit@3.1.0: + dependencies: + '@types/unist': 2.0.10 + unist-util-is: 5.2.1 + unist-util-visit-parents: 4.1.1 + + unist-util-visit@4.1.2: + dependencies: + '@types/unist': 2.0.10 + unist-util-is: 5.2.1 + unist-util-visit-parents: 5.1.3 + + unist-util-visit@5.0.0: + dependencies: + '@types/unist': 3.0.2 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + + universal-user-agent@7.0.2: {} + + update-browserslist-db@1.0.13(browserslist@4.22.3): + dependencies: + browserslist: 4.22.3 + escalade: 3.1.2 + picocolors: 1.0.0 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vfile-message@2.0.4: + dependencies: + '@types/unist': 2.0.10 + unist-util-stringify-position: 2.0.3 + + vfile-message@3.1.4: + dependencies: + '@types/unist': 2.0.10 + unist-util-stringify-position: 3.0.3 + + vfile-message@4.0.2: + dependencies: + '@types/unist': 3.0.2 + unist-util-stringify-position: 4.0.0 + + vfile@5.3.7: + dependencies: + '@types/unist': 2.0.10 + is-buffer: 2.0.5 + unist-util-stringify-position: 3.0.3 + vfile-message: 3.1.4 + + vfile@6.0.1: + dependencies: + '@types/unist': 3.0.2 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.2 + + vite-plugin-tailwind-purgecss@0.2.0(vite@5.1.0(@types/node@20.11.16)): + dependencies: + estree-walker: 3.0.3 + purgecss: 6.0.0-alpha.0 + vite: 5.1.0(@types/node@20.11.16) + + vite@5.1.0(@types/node@20.11.16): + dependencies: + esbuild: 0.19.12 + postcss: 8.4.35 + rollup: 4.9.6 + optionalDependencies: + '@types/node': 20.11.16 + fsevents: 2.3.3 + + vitefu@0.2.5(vite@5.1.0(@types/node@20.11.16)): + optionalDependencies: + vite: 5.1.0(@types/node@20.11.16) + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + worktop@0.8.0-next.18: + dependencies: + mrmime: 2.0.0 + regexparam: 3.0.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + wrappy@1.0.2: {} + + yallist@4.0.0: {} + + yaml@1.10.2: {} + + yaml@2.3.4: {} + + yocto-queue@0.1.0: {} + + zwitch@2.0.4: {} diff --git a/website/src/routes/downloads/older/+page.svelte b/website/src/routes/downloads/older/+page.svelte index 9f535230..346afbce 100644 --- a/website/src/routes/downloads/older/+page.svelte +++ b/website/src/routes/downloads/older/+page.svelte @@ -102,7 +102,7 @@
{#each assets as asset} - +