diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..a55310ce --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,177 @@ +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/.env.example b/.env.example index 920fe826..22abd24b 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,3 @@ -SUPABASE_URL= -SUPABASE_API_KEY= - # The format: # SPOTIFY_SECRETS=clintId1:clientSecret1,clientId2:clientSecret2 SPOTIFY_SECRETS= @@ -8,4 +5,7 @@ SPOTIFY_SECRETS= # 0 or 1 # 0 = disable # 1 = enable -ENABLE_UPDATE_CHECK= \ No newline at end of file +ENABLE_UPDATE_CHECK= + +LASTFM_API_KEY= +LASTFM_API_SECRET= diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index ba129cfd..f1f9ceed 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,4 @@ { - "flutterSdkVersion": "3.10.0", + "flutterSdkVersion": "3.16.0", "flavors": {} } \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e0031d17..64ee89d2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -71,3 +71,10 @@ body: description: Anything else you'd like to include? validations: required: false + - 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! + options: + - label: I'm ready to work on this issue! + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/new_feature.yml b/.github/ISSUE_TEMPLATE/new_feature.yml index 9742f91f..7f02ea38 100644 --- a/.github/ISSUE_TEMPLATE/new_feature.yml +++ b/.github/ISSUE_TEMPLATE/new_feature.yml @@ -35,4 +35,11 @@ body: label: Additional information description: Anything else you'd like to include? validations: - required: false \ No newline at end of file + required: false + - 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! + options: + - label: I'm ready to work on this issue! + required: false \ No newline at end of file diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml new file mode 100644 index 00000000..e4fb55c5 --- /dev/null +++ b/.github/workflows/pr-lint.yml @@ -0,0 +1,32 @@ +name: Lint + +on: + pull_request: + +env: + FLUTTER_VERSION: '3.16.0' + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + + - name: Configure repo + run: | + flutter pub get + echo '${{ secrets.DOTENV_NIGHTLY }}' > .env + dart run build_runner build --delete-conflicting-outputs + + - name: Lint Dart files + run: | + dart analyze --no-fatal-warnings + + - name: Lint translations & config files + run: | + npm install -g @prantlf/jsonlint + jsonlint -q -D --enforce-double-quotes ./lib/l10n/*.arb + jsonlint -q -D --enforce-double-quotes -T .vscode/*.json \ No newline at end of file diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 67dbbf3d..12a2f99b 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.0.0 + default: 3.1.0 required: true dry_run: description: Dry run @@ -22,12 +22,12 @@ jobs: runs-on: ubuntu-22.04 if: contains(inputs.jobs, 'flathub') steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: repository: KRTirtho/com.github.KRTirtho.Spotube token: ${{ secrets.FLATHUB_TOKEN }} - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: path: spotube @@ -50,7 +50,7 @@ jobs: runs-on: ubuntu-22.04 if: contains(inputs.jobs, 'aur') steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: dsaltares/fetch-gh-release-asset@master with: diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 2136ba94..3ef0ff61 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -4,7 +4,7 @@ on: inputs: version: description: Version to release (x.x.x) - default: 3.0.0 + default: 3.4.1 required: true channel: type: choice @@ -26,14 +26,14 @@ on: default: true env: - FLUTTER_VERSION: '3.10.0' + FLUTTER_VERSION: '3.16.3' jobs: windows: runs-on: windows-latest steps: - - uses: actions/checkout@v3 - - uses: subosito/flutter-action@v2.10.0 + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2.12.0 with: cache: true flutter-version: ${{ env.FLUTTER_VERSION }} @@ -87,23 +87,27 @@ jobs: make choco mv dist/spotube.*.nupkg dist/Spotube-windows-x86_64.nupkg + + - name: Upload Artifact + uses: actions/upload-artifact@v3 + with: + if-no-files-found: error + name: Spotube-Release-Binaries + path: | + dist/Spotube-windows-x86_64.nupkg + dist/Spotube-windows-x86_64-setup.exe + - name: Debug With SSH When fails if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} uses: mxschmitt/action-tmate@v3 with: limit-access-to-actor: true - - name: Upload Artifact - uses: actions/upload-artifact@v3 - with: - name: Spotube-Release-Binaries - path: dist/ - linux: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: subosito/flutter-action@v2.10.0 + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2.12.0 with: cache: true flutter-version: ${{ env.FLUTTER_VERSION }} @@ -159,23 +163,31 @@ jobs: 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 - name: Create tar.xz (stable) if: ${{ inputs.channel == 'stable' }} - run: make tar VERSION=${{ env.BUILD_VERSION }} + 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 + 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 - mv dist/**/spotube-*-linux.AppImage dist/Spotube-linux-x86_64.AppImage + + + - uses: actions/upload-artifact@v3 + 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 - name: Debug With SSH When fails if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} @@ -183,16 +195,12 @@ jobs: with: limit-access-to-actor: true - - uses: actions/upload-artifact@v3 - with: - name: Spotube-Release-Binaries - path: dist/ android: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: subosito/flutter-action@v2.10.0 + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2.12.0 with: cache: true flutter-version: ${{ env.FLUTTER_VERSION }} @@ -235,10 +243,8 @@ jobs: - name: Build Apk run: | - flutter build apk - flutter build appbundle - mv build/app/outputs/apk/release/app-release.apk build/Spotube-android-all-arch.apk - mv build/app/outputs/bundle/release/app-release.aab build/Spotube-playstore-all-arch.aab + 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: | @@ -247,8 +253,17 @@ jobs: 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 - mv build/app/outputs/bundle/release/app-release.aab build/Spotube-playstore-all-arch.aab + flutter build appbundle --flavor ${{ inputs.channel }} + mv build/app/outputs/bundle/${{ inputs.channel }}Release/app-${{ inputs.channel }}-release.aab build/Spotube-playstore-all-arch.aab + + + - uses: actions/upload-artifact@v3 + with: + if-no-files-found: error + name: Spotube-Release-Binaries + path: | + build/Spotube-android-all-arch.apk + build/Spotube-playstore-all-arch.aab - name: Debug With SSH When fails if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} @@ -256,17 +271,76 @@ jobs: with: limit-access-to-actor: true - - uses: actions/upload-artifact@v3 - with: - name: Spotube-Release-Binaries - path: | - build/Spotube-android-all-arch.apk - build/Spotube-playstore-all-arch.aab - macos: + runs-on: macos-12 steps: - - uses: actions/checkout@v3 + - 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 @@ -296,40 +370,36 @@ jobs: - name: Generate Secrets run: | flutter pub get - flutter pub remove media_kit_native_event_loop dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns - - name: Build Macos App + - name: Build iOS iPA run: | - flutter config --enable-macos-desktop - flutter build macos - du -sh build/macos/Build/Products/Release/spotube.app + flutter build ios --release --no-codesign --flavor ${{ inputs.channel }} + ln -sf ./build/ios/iphoneos Payload + zip -r9 Spotube-iOS.ipa Payload/${{ inputs.channel }}.app - - name: Package Macos App - run: | - npm install -g appdmg - mkdir -p build/${{ env.BUILD_VERSION }} - appdmg appdmg.json build/Spotube-macos-universal.dmg + - 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 - - - uses: actions/upload-artifact@v3 - with: - name: Spotube-Release-Binaries - path: | - build/Spotube-macos-universal.dmg - + upload: runs-on: ubuntu-latest + needs: - windows - linux - android - macos + - iOS steps: - uses: actions/download-artifact@v3 with: @@ -348,6 +418,7 @@ jobs: - uses: actions/upload-artifact@v3 with: + if-no-files-found: error name: Spotube-Release-Binaries path: | RELEASE.md5sum diff --git a/.vscode/launch.json b/.vscode/launch.json index 3c8209ff..7a1e8b9b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,17 @@ "name": "spotube", "type": "dart", "request": "launch", - "program": "lib/main.dart" + "program": "lib/main.dart", + }, + { + "name": "spotube (mobile)", + "type": "dart", + "request": "launch", + "program": "lib/main.dart", + "args": [ + "--flavor", + "dev" + ] }, { "name": "spotube (profile)", diff --git a/.vscode/settings.json b/.vscode/settings.json index c4917255..0e6a4294 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,13 +1,20 @@ { - "cmake.configureOnOpen": false, - "cSpell.words": [ - "acousticness", - "danceability", - "instrumentalness", - "Mpris", - "riverpod", - "speechiness", - "Spotube", - "winget" - ] -} + "cmake.configureOnOpen": false, + "cSpell.words": [ + "acousticness", + "danceability", + "instrumentalness", + "Mpris", + "riverpod", + "Scrobblenaut", + "speechiness", + "Spotube", + "winget" + ], + "editor.formatOnSave": true, + "explorer.fileNesting.enabled": true, + "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", + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a5add141..8f48b39e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,189 @@ 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.4.1](https://personal.github.com/krtirtho/spotube/compare/v3.4.0...v3.4.1) (2024-01-27) + + +### Features + +* add create playlist button in add playlist dialog ([2168a64](https://personal.github.com/krtirtho/spotube/commit/2168a640af3104a43139c303d78e2c2326a1bda7)) +* add spotify friends activity ([#1130](https://personal.github.com/krtirtho/spotube/issues/1130)) ([7983932](https://personal.github.com/krtirtho/spotube/commit/79839329b0970acccb0c566a31eee508adbc8557)) +* **deep-link:** add track opening page ([988a975](https://personal.github.com/krtirtho/spotube/commit/988a975bf1a675df0cfc7b17776bcec74c67f1f2)) +* haptic feedback on long press and reordering actions ([6242200](https://personal.github.com/krtirtho/spotube/commit/624220090572eb643dce37ca8ffd85d2b3f5c9df)) +* improve youtube/piped matching by suffixing "- Topic" ([8184555](https://personal.github.com/krtirtho/spotube/commit/8184555ee89fd30aaf886af9fc1d52c142fdebb0)) +* **translations:** add Nepali (नेपाली) translations ([#1111](https://personal.github.com/krtirtho/spotube/issues/1111)) ([c3ebf56](https://personal.github.com/krtirtho/spotube/commit/c3ebf56ac149b0af8815a5533fe6c386df743440)), closes [#1074](https://personal.github.com/krtirtho/spotube/issues/1074) [#1100](https://personal.github.com/krtirtho/spotube/issues/1100) + + +### Bug Fixes + +* alternative searched sources doesn't play [#1059](https://personal.github.com/krtirtho/spotube/issues/1059) ([a8e9b82](https://personal.github.com/krtirtho/spotube/commit/a8e9b824f33add8f6a83f0d147e889eb6beeb442)) +* alternative source doesn't persist on next restart [#840](https://personal.github.com/krtirtho/spotube/issues/840) ([62fde50](https://personal.github.com/krtirtho/spotube/commit/62fde50442f04f93255b5b1b1dcca23d116a13ec)) +* **android:** download failing for permission issues [#1015](https://personal.github.com/krtirtho/spotube/issues/1015) ([5509cae](https://personal.github.com/krtirtho/spotube/commit/5509cae91c8b1f5cb9fac179060f477397a4a27f)) +* artist page error [#1018](https://personal.github.com/krtirtho/spotube/issues/1018) ([8cd650b](https://personal.github.com/krtirtho/spotube/commit/8cd650b07e5f4c4c2f296bf4374e5ee67fb3eb50)) +* audio resumes after a phone call even if it was paused before [#926](https://personal.github.com/krtirtho/spotube/issues/926) ([fd1899f](https://personal.github.com/krtirtho/spotube/commit/fd1899f162395752142d7aa7320d1c39b0995070)) +* better error message for failing to find lyrics [#1085](https://personal.github.com/krtirtho/spotube/issues/1085) ([e58e18d](https://personal.github.com/krtirtho/spotube/commit/e58e18de33d7bc6fb0e4ddd7ccf6ea14472642b1)) +* Black window flash when starting the app ([#1003](https://personal.github.com/krtirtho/spotube/issues/1003)) ([02e44fc](https://personal.github.com/krtirtho/spotube/commit/02e44fc6b849a873adad382f5d46ed8caf32359f)) +* **linux:** crash after login ([0dfd401](https://personal.github.com/krtirtho/spotube/commit/0dfd40153714b7a4b83ac30f0c56830bc0c05ffd)) +* **macos:** backbutton and window button overlap and unused empty space on home ([b9417ca](https://personal.github.com/krtirtho/spotube/commit/b9417ca3575992673357230dab49e0124dd576b1)) +* **macos:** download folder unchangeable ([9d74cf5](https://personal.github.com/krtirtho/spotube/commit/9d74cf5fc250a6a143321d49b8e045519b4c2872)) +* **macos:** Respect Minimize to tray option ([#1001](https://personal.github.com/krtirtho/spotube/issues/1001)) ([69559ba](https://personal.github.com/krtirtho/spotube/commit/69559ba24285636e42b2f2231f956c31388c5cf3)) +* **macos:** system tray shows name and sidebar weird gap [#1083](https://personal.github.com/krtirtho/spotube/issues/1083) ([27057ea](https://personal.github.com/krtirtho/spotube/commit/27057ea0c8d83c9701057c18b473f1af4e4e82be)) +* releases section is empty when user doesn't follow any artists [#1104](https://personal.github.com/krtirtho/spotube/issues/1104) ([682e88e](https://personal.github.com/krtirtho/spotube/commit/682e88e0c55bc0f4708bc0b4681b129e5c61c999)) +* search page vertical scrollbar moves on horizontal scroll [#1017](https://personal.github.com/krtirtho/spotube/issues/1017) ([c203ac6](https://personal.github.com/krtirtho/spotube/commit/c203ac69ee74ba8722dae3da4b47761cd8d59c34)) +* songs doesn't play when sources with preferred audio codec is empty ([#976](https://personal.github.com/krtirtho/spotube/issues/976)) ([ba4e11a](https://personal.github.com/krtirtho/spotube/commit/ba4e11a40ab18308437a05333a46eace6f8eeb5a)) +* track index not showing after 200 ([a752cf4](https://personal.github.com/krtirtho/spotube/commit/a752cf4c978d1b05851aabb6c84c7862de551320)) +* track pad horizontal scrolling not working ([59e0e6b](https://personal.github.com/krtirtho/spotube/commit/59e0e6bb659b70831f6e0ae064100381c57f149c)) + +## [3.4.0](https://github.com/KRTirtho/spotube/compare/v3.3.0...v3.4.0) (2023-12-30) + + +### Features + +* Add Go to Album option in track option [#917](https://github.com/KRTirtho/spotube/issues/917) ([b0beeca](https://github.com/KRTirtho/spotube/commit/b0beeca0cbaf810fae27832cff98cfda95715050)) +* **translations:** add Italian language translations ([#818](https://github.com/KRTirtho/spotube/issues/818)) ([e4eb0e2](https://github.com/KRTirtho/spotube/commit/e4eb0e2596ade2bb5195e183f03af42742fc8486)), closes [#676](https://github.com/KRTirtho/spotube/issues/676) [#676](https://github.com/KRTirtho/spotube/issues/676) +* compact genre view in home page ([82ed5e9](https://github.com/KRTirtho/spotube/commit/82ed5e90576b57ef32e61a65015e04862ab15461)) +* Deep link support ([#950](https://github.com/KRTirtho/spotube/issues/950)) ([4050f55](https://github.com/KRTirtho/spotube/commit/4050f556400aaec5515231578512cf1a6b990110)) +* improve loading animations ([b92583d](https://github.com/KRTirtho/spotube/commit/b92583d0df7b8dee0d121cd2bb666b14c77d8c86)) +* toggle for discord rpc ([24a2294](https://github.com/KRTirtho/spotube/commit/24a2294512bb0c4aff77bc8dcad9b4de3e8b45c6)) +* **translations:** add Dutch Language ([#969](https://github.com/KRTirtho/spotube/issues/969)) ([3ad7ba6](https://github.com/KRTirtho/spotube/commit/3ad7ba66b56e93e69d2181d47029b7549ed225fc)) + + +### Bug Fixes + +* add safe area in home ([9ee6067](https://github.com/KRTirtho/spotube/commit/9ee60677f6d50df7468e12dc6653ecedefa2494f)) +* amoled mode and color scheme can't be changed ([840e014](https://github.com/KRTirtho/spotube/commit/840e014f2b18f193d040baef0e0cd595088a4a84)) +* doesn't minimize to tray when system title bar close button is used [#866](https://github.com/KRTirtho/spotube/issues/866) ([bb8f250](https://github.com/KRTirtho/spotube/commit/bb8f250f5f351c1a353791b77b25b9de7586191f)) +* genre border issues ([2fb16e6](https://github.com/KRTirtho/spotube/commit/2fb16e64e9cdfca54d633cdf287b0544ecdda3b6)) +* Incorrect "Artist" label/heading on Search Results Page [#920](https://github.com/KRTirtho/spotube/issues/920) ([f86d544](https://github.com/KRTirtho/spotube/commit/f86d5449168068e338f769d7f504d2146b86dc79)) +* metadata not getting added for YouTube tracks [#916](https://github.com/KRTirtho/spotube/issues/916) and Wrong duration of downloaded tracks [#912](https://github.com/KRTirtho/spotube/issues/912) ([a7b9398](https://github.com/KRTirtho/spotube/commit/a7b9398708ede865dc2c25fb791c8e98eeff7a38)) +* Playlist refresh not working [#915](https://github.com/KRTirtho/spotube/issues/915) ([5f1df5a](https://github.com/KRTirtho/spotube/commit/5f1df5a87d8fb7980b52cf57b7b6bedea57a1269)) +* track view header title overflow and player view drag glitch ([b04d884](https://github.com/KRTirtho/spotube/commit/b04d8849e7169824ec5b980236b5d61b2629f56e)) +* wrong artist name sent while scrobbling [#958](https://github.com/KRTirtho/spotube/issues/958) ([dcbe729](https://github.com/KRTirtho/spotube/commit/dcbe7294b742d43fbff4e89ab4c4825e94421dd9)) + +## [3.3.0](https://github.com/KRTirtho/spotube/compare/v3.2.0...v3.3.0) (2023-11-27) + + +### Features + +* Add JioSaavn as audio source ([#881](https://github.com/KRTirtho/spotube/issues/881)) ([14069cd](https://github.com/KRTirtho/spotube/commit/14069cd4fe08597c8d9aa0810270fb4c386c1d55)) +* **android:** better quick scroll/drag to scroll implementation ([2e2c44f](https://github.com/KRTirtho/spotube/commit/2e2c44f0afef69bf9bc485db97d45127a0847c8e)) +* **artist:** modularize page and add wikipedia section ([2a69886](https://github.com/KRTirtho/spotube/commit/2a698865567883271471ace9a44123bbfd8fcd2f)) +* discord RPC integration [#98](https://github.com/KRTirtho/spotube/issues/98) ([88b8785](https://github.com/KRTirtho/spotube/commit/88b8785cb86a19900f3a867b044c1ccb2fe400bb)) +* **mini_player:** show/hide lyrics [#851](https://github.com/KRTirtho/spotube/issues/851) ([dcbb156](https://github.com/KRTirtho/spotube/commit/dcbb1568337969841acc0abe0e7185ee5e4c3590)) +* paginated playlist and album page ([28a5d6b](https://github.com/KRTirtho/spotube/commit/28a5d6bb3820ab0bd4007664f73d685f6e1d2c90)) +* **translations:** add Turkish translations ([0c22469](https://github.com/KRTirtho/spotube/commit/0c22469503f32dbbf1a5d31419c1b76c699fa966)) + + +### Bug Fixes + +* "Add () to Playlist" option not showing in favorited playlists [#904](https://github.com/KRTirtho/spotube/issues/904) ([96021e1](https://github.com/KRTirtho/spotube/commit/96021e1a49d22bd25fd052c122f49f439c2bea43)) +* 0:00 media duration in queue after application restart [#782](https://github.com/KRTirtho/spotube/issues/782) ([83c0b49](https://github.com/KRTirtho/spotube/commit/83c0b49da962d9f3d40de9525f90f0b320e8f7b8)) +* Add to Playlist Dialog memory leak [#817](https://github.com/KRTirtho/spotube/issues/817) ([fed36ec](https://github.com/KRTirtho/spotube/commit/fed36ecdd81e8a0f8358693eff0a6233dea32e5d)) +* **album_card:** show loading state during adding track to queue/play ([5633367](https://github.com/KRTirtho/spotube/commit/5633367397812148f6d712d06e97a4f84033f968)) +* alternative track source safearea overflow [#876](https://github.com/KRTirtho/spotube/issues/876) ([7b72a90](https://github.com/KRTirtho/spotube/commit/7b72a90bc65b541cbe2e24ef2234524b522ad71d)) +* android invalid download location Download not starting or not explaining error [#720](https://github.com/KRTirtho/spotube/issues/720) ([d056dbf](https://github.com/KRTirtho/spotube/commit/d056dbf9eeef7033dbc012d0c05800063e820042)) +* changed settings are not persisting after force stop [#821](https://github.com/KRTirtho/spotube/issues/821) ([e29a38d](https://github.com/KRTirtho/spotube/commit/e29a38dfa43ddf7a38046d1d40424f01dbe62261)) +* check for unsynced lyrics and error handling for timed lyrics query ([1d77556](https://github.com/KRTirtho/spotube/commit/1d77556157d158600f29cf2ea5f26c567607dec7)) +* **genres:** lag while scrolling ([dc980b0](https://github.com/KRTirtho/spotube/commit/dc980b024edad3132e72cbb2f0087297a4b76469)) +* infinite list disappearing for a moment everytime new page is fetched ([1334a62](https://github.com/KRTirtho/spotube/commit/1334a62aaea31f97031b3ebf455e94c583f37314)) +* last track of queue keeps repeating [#718](https://github.com/KRTirtho/spotube/issues/718) ([58e5698](https://github.com/KRTirtho/spotube/commit/58e569864dddd74c3064624998dfc184046e97eb)) +* Navigating to settings, redirects to home page [#812](https://github.com/KRTirtho/spotube/issues/812) ([da04f06](https://github.com/KRTirtho/spotube/commit/da04f068f9b7effff8d50cb5714d93ea80c22b7f)) +* new releases section flickering on scroll glitch ([ee94b7c](https://github.com/KRTirtho/spotube/commit/ee94b7cbb24e0f0bc22a6d49c830d4055aa02895)) +* **playbutton_card:** annoying animation ([574406d](https://github.com/KRTirtho/spotube/commit/574406dd5fc410914b27e7fce374323696845012)) +* scrobbling not working for first track or single track ([0a6b54d](https://github.com/KRTirtho/spotube/commit/0a6b54da367345b73fe6e954f1d9368d9f9ead71)) +* settings page scrollbar position ([ee82290](https://github.com/KRTirtho/spotube/commit/ee8229020b3b03fc074b316db4b322af13b807bd)) +* shuffle doesn't move active track to top ([4956bf3](https://github.com/KRTirtho/spotube/commit/4956bf367baae39c88b5de7c6c136513a14f8ad2)) +* spotube doesn't exit properly, hangs in infinite loop [#768](https://github.com/KRTirtho/spotube/issues/768) ([353ca79](https://github.com/KRTirtho/spotube/commit/353ca79be334077c3ac27b4f64e8b4b15eca7175)) +* trim login field padding ([286ef83](https://github.com/KRTirtho/spotube/commit/286ef83e8ec516db70019398d9e3e724437a4172)) +* use CustomScrollView for personalized page ([7d05c40](https://github.com/KRTirtho/spotube/commit/7d05c40dc0d04208b059f2483c1e4de199c8b51d)) +* user_playlists layout, track tile index, ([487c2ed](https://github.com/KRTirtho/spotube/commit/487c2ed6bdc4af33006ba52532eb4eaaa261dceb)) +* **windows:** media control not working [#641](https://github.com/KRTirtho/spotube/issues/641) ([7818574](https://github.com/KRTirtho/spotube/commit/7818574356d0fb8ff567e1f6a83fd0b6f2ee7c8a)) + +## [3.2.0](https://github.com/KRTirtho/spotube/compare/v3.1.2...v3.2.0) (2023-10-16) + + +### Features + +* ability to select/copy lyrics [#802](https://github.com/KRTirtho/spotube/issues/802) ([0eb9ee8](https://github.com/KRTirtho/spotube/commit/0eb9ee8648bee43a8009e6752674b1be646c0916)) +* add Amoled theme [#724](https://github.com/KRTirtho/spotube/issues/724) ([5c5dbf6](https://github.com/KRTirtho/spotube/commit/5c5dbf69ecea95c92d3c3900ad690a500d75b4e2)) +* add audio normalization [#164](https://github.com/KRTirtho/spotube/issues/164) ([da10ab2](https://github.com/KRTirtho/spotube/commit/da10ab2e291d4ba4d3082b9a6ae535639fb8f1b7)) +* add restore default settings button ([94c3866](https://github.com/KRTirtho/spotube/commit/94c386638f2e5a42d21c8f157835443333ee6d5c)) +* configurable audio normalization switch ([c325911](https://github.com/KRTirtho/spotube/commit/c325911c0d87758a203a52df02179c1513bad3fd)) +* customizable stream/download file formats ([#757](https://github.com/KRTirtho/spotube/issues/757)) ([e54762b](https://github.com/KRTirtho/spotube/commit/e54762be6add6524ab614d103fc3557a101c75f4)) +* improve and unify the logging framework ([#738](https://github.com/KRTirtho/spotube/issues/738)) ([c7432bb](https://github.com/KRTirtho/spotube/commit/c7432bbd986d576a93957f0a22bdbca5c1e87f20)) +* LastFM scrobbling support ([#761](https://github.com/KRTirtho/spotube/issues/761)) ([f5bd907](https://github.com/KRTirtho/spotube/commit/f5bd90731d9abc19d684c8bcb231eff399e73023)) +* loading indicator for genre and personalized pages ([ffe8d9c](https://github.com/KRTirtho/spotube/commit/ffe8d9ca6da25cb3e6fd2c781d5ed3a7b919510e)) +* manual offline detection ([854ab89](https://github.com/KRTirtho/spotube/commit/854ab8910dffb2837c011d3439173a1f0ebe9c6c)) +* show error dialog on failed to login ([101c325](https://github.com/KRTirtho/spotube/commit/101c32523d3be8c05527261f6f63f939d388ad79)) +* sliding up player support ([083319f](https://github.com/KRTirtho/spotube/commit/083319fd2445ab179e3dcda0a6aeaca6f13dda29)) +* swipe to open player view ([#765](https://github.com/KRTirtho/spotube/issues/765)) ([9aee056](https://github.com/KRTirtho/spotube/commit/9aee0568bf42eed9fea8d517e960a010abf0ebf2)) +* thicken the scrollbars & make 'em interactive for mobile ([#764](https://github.com/KRTirtho/spotube/issues/764)) ([84a4bcd](https://github.com/KRTirtho/spotube/commit/84a4bcd948ab459489aaf6f39d6954776c3401d7)) +* **translations:** add Arabic Translations ([#740](https://github.com/KRTirtho/spotube/issues/740)) ([38493f9](https://github.com/KRTirtho/spotube/commit/38493f9dd75303890857a626c0b276ee1ab75bb2)) +* **translations:** add Farsi Translations ([#760](https://github.com/KRTirtho/spotube/issues/760)) ([fe42cfe](https://github.com/KRTirtho/spotube/commit/fe42cfe8430035d9b67dd158fb7b835ee4071497)) + + +### Bug Fixes + +* add libmpv1 for ubuntu-based systems ([#739](https://github.com/KRTirtho/spotube/issues/739)) ([5115e04](https://github.com/KRTirtho/spotube/commit/5115e041e78c20fce798a80f1d844bfc60746958)) +* add xdg-user-dirs as deps ([f3e331e](https://github.com/KRTirtho/spotube/commit/f3e331ecf733995da24c9b907efc5ed4bd02ffdd)) +* **android :** file_selector getDirectoryPath returns unusable content urls [#720](https://github.com/KRTirtho/spotube/issues/720) ([b3cf639](https://github.com/KRTirtho/spotube/commit/b3cf639ee2f970f4df9b394b260c3ad8a5732a9c)) +* **android:** audio doesn't resume on interruption end ([15d466a](https://github.com/KRTirtho/spotube/commit/15d466a04538ec70c3a0c132f2baaaf8690f8d4e)) +* **android:** system navigator back doesn't close player ([20d7092](https://github.com/KRTirtho/spotube/commit/20d70927c909347e84ffa8e456f8fab88d49d179)) +* get rid of overflow errors & status bar dark color ([5bb8231](https://github.com/KRTirtho/spotube/commit/5bb8231782287faf75c778fadb3a03ac774d14f0)) +* keyboard shortcuts changing route but not update sidebar ([2d93441](https://github.com/KRTirtho/spotube/commit/2d934411887bd104d8265236df5bf595c5ad2278)) +* last track repeats ([ed6ca00](https://github.com/KRTirtho/spotube/commit/ed6ca006ce237ed8d509cde9ed47cd6ea3396b63)) +* minor glitches ([e5d0aaf](https://github.com/KRTirtho/spotube/commit/e5d0aaf80d22b2291b6f7e7c5e18dd99ae1a7a82)) +* not fetching all followed artists ([#759](https://github.com/KRTirtho/spotube/issues/759)) ([c09a572](https://github.com/KRTirtho/spotube/commit/c09a5729251d8df820442d55477455f78c19c52e)) +* use audio_service_mpris plugin ([e29cc25](https://github.com/KRTirtho/spotube/commit/e29cc2578cab36729e235b117c1b5489c3452902)) +* valid non-ASCII characters get removed from downloaded file name [#745](https://github.com/KRTirtho/spotube/issues/745) ([a7e102f](https://github.com/KRTirtho/spotube/commit/a7e102ffc726d00df369560ec9a7f742f9d387bb)) + +## [3.1.2](https://github.com/KRTirtho/spotube/compare/v3.1.1...v3.1.2) (2023-09-15) + + +### Features + +* **player_queue:** filtering track support ([d4f99ec](https://github.com/KRTirtho/spotube/commit/d4f99ec89927ea78f070707509ff3222ec402942)) +* right click to open track option ([1540999](https://github.com/KRTirtho/spotube/commit/1540999f50d7ba78d9706d73127483b98d800d86)) +* search loading animation ([b9d5c70](https://github.com/KRTirtho/spotube/commit/b9d5c70301dd33ec26332e5e9a456ce5bfe73da0)) +* show loading indicator on play track ([d12ea48](https://github.com/KRTirtho/spotube/commit/d12ea48b97596205d6309012d561ce83e5cbc9c1)) + + +### Bug Fixes + +* add missing dependency in debian package ([#704](https://github.com/KRTirtho/spotube/issues/704)) ([c987ea7](https://github.com/KRTirtho/spotube/commit/c987ea78414f094dead2c2b35ddf8ae83a70f2fe)) +* hour not showing for tracks longer than 60 minutes ([#648](https://github.com/KRTirtho/spotube/issues/648)) ([de335f4](https://github.com/KRTirtho/spotube/commit/de335f48342e45a077d6c3202706ef48dfb0a326)) +* liked tracks card play not working ([d3e1cef](https://github.com/KRTirtho/spotube/commit/d3e1cef8a21ef7d64e74ca4e99b4b57b653b60a7)) +* limit cover image upload to allowed 256kb size ([1c50612](https://github.com/KRTirtho/spotube/commit/1c50612559a78dce9c108f7e7b816d1b84540fe4)) +* playlist grey page ([#707](https://github.com/KRTirtho/spotube/issues/707)) ([0df8d9c](https://github.com/KRTirtho/spotube/commit/0df8d9cacee718fbb4cf3ec7b950b489630f3145)) +* rewind breaks track progress bar ([#695](https://github.com/KRTirtho/spotube/issues/695)) ([e321743](https://github.com/KRTirtho/spotube/commit/e3217436c9985b86c68dab93ea65ee414b32fb49)) +* Windows memory leak due refetchOnStale user-liked-tracks ([#705](https://github.com/KRTirtho/spotube/issues/705)) ([142dc49](https://github.com/KRTirtho/spotube/commit/142dc498f8f9d26e6b370c9c52f790a20832fc38)) + +## [3.1.1](https://github.com/KRTirtho/spotube/compare/v3.1.0...v3.1.1) (2023-08-28) + + +### Features + +* ability to toggle system title bar & custom title bar ([#185](https://github.com/KRTirtho/spotube/issues/185)) ([8d46029](https://github.com/KRTirtho/spotube/commit/8d4602962be20ea4bafc20db10eae1160f83ac52)) +* jump to specific time on lyric click ([#590](https://github.com/KRTirtho/spotube/issues/590)) ([a14fb9e](https://github.com/KRTirtho/spotube/commit/a14fb9ec389822e5ffa0c537e162b87cbba34e6c)) +* paginated user albums ([d239d64](https://github.com/KRTirtho/spotube/commit/d239d641ff8f1b3edd64243994fd4a58cf71a5d3)) +* **translations:** add Russian translation ([#661](https://github.com/KRTirtho/spotube/issues/661)) ([e9a0911](https://github.com/KRTirtho/spotube/commit/e9a0911bfcea2374ee282aee738c12ad9ed93b02)), closes [#625](https://github.com/KRTirtho/spotube/issues/625) +* **translations:** added Portuguese (Brazil) translation ([#634](https://github.com/KRTirtho/spotube/issues/634)) ([76f30a0](https://github.com/KRTirtho/spotube/commit/76f30a0f20f2b09680d27525cde3d1c9617fad5a)) + + +### Bug Fixes + +* always fetching SponsorBlock if no segments found & download failing ([6ced0a0](https://github.com/KRTirtho/spotube/commit/6ced0a0fad06f9f431636ca0fe5dae83eafe33ce)) +* debian bookworm invalid dependencies ([633415d](https://github.com/KRTirtho/spotube/commit/633415dd3e702a38c5a7e7d7b3b1c2713d9c9cc9)) +* disable android auto for playstore version :"( ([0f0d240](https://github.com/KRTirtho/spotube/commit/0f0d240c04d77db6f7c127d59ba8b331d5534469)) +* infinite route push glitch ([e90eceb](https://github.com/KRTirtho/spotube/commit/e90eceb285a84028df690c25a687ff9b5168bba8)) +* jump to track going to wrong track ([190df17](https://github.com/KRTirtho/spotube/commit/190df17adcf4c01cb2bcebfdec47908828b33816)) +* last track of queue never plays & repeat playlist never works ([c3c09f5](https://github.com/KRTirtho/spotube/commit/c3c09f5b76c9547a306d15cd3768dacc1622876d)) +* lyrics page text contrast ([179d536](https://github.com/KRTirtho/spotube/commit/179d536ccc10a5e63f11a63680a6e61c2d1314c8)) +* replace connectivity_plus with internet_connection_checker ([f23e871](https://github.com/KRTirtho/spotube/commit/f23e8719eec7f5bed677ea866cb4bfab7aee5373)) +* sanitize song title for file name ([#644](https://github.com/KRTirtho/spotube/issues/644)) ([1a7ea0c](https://github.com/KRTirtho/spotube/commit/1a7ea0ce6aae1a7cbe195f6b2fae7d99082bb828)) +* sorting by date crashes app ([#551](https://github.com/KRTirtho/spotube/issues/551)) ([48e90a4](https://github.com/KRTirtho/spotube/commit/48e90a42294a6287cad65f840a7cc305988d34ff)) +* window size remains same after exiting mini player ([#618](https://github.com/KRTirtho/spotube/issues/618)) ([fb36003](https://github.com/KRTirtho/spotube/commit/fb360035ade09c270b46a0c3b99ab1594ece07c0)) + ## [3.1.0](https://github.com/KRTirtho/spotube/compare/v3.0.1...v3.1.0) (2023-08-18) diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index b4b52084..13996cea 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -25,6 +25,7 @@ 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) ## Code of Conduct @@ -118,20 +119,20 @@ Enhancement suggestions are tracked as [GitHub issues](https://github.com/KRTirt Do the following: -- Download the latest Flutter SDK (>=3.10.0) & enable desktop support +- Download the latest Flutter SDK (>=3.16.0) & enable desktop support - 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 network-manager + $ 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 ``` - Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04) - Arch/Manjaro ```bash - yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify networkmanager + yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify ``` - Fedora ```bash - dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel NetworkManager + dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel ``` - Clone the Repo - Create a `.env` in root of the project following the `.env.example` template @@ -144,4 +145,22 @@ Do the following: flutter run -d )> ``` -Do debugging/testing/build etc then submit to us with PR against the development branch (master) & we'll review your code +Do debugging/testing/build etc then submit to us with PR against the development branch (dev) & we'll review your code + + +### Submit Translations + +Make sure you're familiar with [Flutter localization](https://docs.flutter.dev/ui/accessibility-and-localization/internationalization). Then, you can start translating the app by following these steps: + +- Do all the steps in [Your First Code Contribution](#your-first-code-contribution) +- Make sure application starts in debug mode +- Now, in `lib/l10n/app_<2-letter code of your language>.arb` (create if not exists) add necessary translations + > (You can follow the `lib/l10n/app_en.arb` for reference) +- If you're adding missing translations, you can check the `/untranslated_messages.json` file to see which messages are missing in your native locale +- If you added entirely new translations: + - Add `const Locale('<2-letter language code>', '<2-letter ISO country code>')` in `lib/l10n/l10n.dart`'s `static final all = [...]` variable + - Uncomment the Map entry of your locale from `lib/collections/language_codes.dart`'s `static final Map isoLangs = {` variable +- Now restart (hot restart if running already) the app in debug mode & go to "Settings" > "Language" & see if your locale shows up +- If it does, select it & see if the app is translated properly +- Now git commit the changes & push +- Finally, submit a PR against the development branch (dev) & we'll review your code diff --git a/Makefile b/Makefile index 985d4486..c2678d2e 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,8 @@ INNO_VERSION=6.2.0 TEMP_DIR=/tmp/spotube-tar USR_SHARE=deb-struct/usr/share -BUNDLE_DIR=build/linux/x64/release/bundle +BUNDLE_DIR=build/linux/${ARCH}/release/bundle MIRRORLIST=${PWD}/build/mirrorlist -deb: - mkdir -p ${USR_SHARE}/spotube\ - && mkdir -p $(USR_SHARE)/applications $(USR_SHARE)/icons/spotube $(USR_SHARE)/spotube $(USR_SHARE)/appdata\ - && cp -r $(BUNDLE_DIR)/* $(USR_SHARE)/spotube\ - && cp linux/spotube.desktop $(USR_SHARE)/applications/\ - && cp linux/com.github.KRTirtho.Spotube.appdata.xml $(USR_SHARE)/appdata/spotube.appdata.xml\ - && cp assets/spotube-logo.png $(USR_SHARE)/icons/spotube\ - && sed -i 's|com.github.KRTirtho.Spotube|spotube|' $(USR_SHARE)/appdata/spotube.appdata.xml\ - && dpkg-deb -b deb-struct/ build/Spotube-linux-x86_64.deb tar: mkdir -p $(TEMP_DIR)\ @@ -19,13 +10,9 @@ tar: && cp linux/spotube.desktop $(TEMP_DIR)\ && cp assets/spotube-logo.png $(TEMP_DIR)\ && cp linux/com.github.KRTirtho.Spotube.appdata.xml $(TEMP_DIR)\ - && tar -cJf build/spotube-linux-${VERSION}-x86_64.tar.xz -C $(TEMP_DIR) .\ + && tar -cJf build/spotube-linux-${VERSION}-${PKG_ARCH}.tar.xz -C $(TEMP_DIR) .\ && rm -rf $(TEMP_DIR) -appimage: - appimage-builder --recipe AppImageBuilder.yml\ - && mv Spotube-*-x86_64.AppImage build - aursrcinfo: docker run -e EXPORT_SRC=1 -v ${PWD}/aur-struct:/pkg -v ${MIRRORLIST}:/etc/pacman.d/mirrorlist:ro whynothugo/makepkg diff --git a/README.md b/README.md index a6d8433b..18eb55aa 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,32 @@
Spotube Logo - An open source, cross-platform Spotify client that doesn't require Premium nor uses Electron!
- That uses Spotify's data/discovery API and YouTube (or Piped.video) as audio source +An open source, cross-platform Spotify client compatible across multiple platforms
+utilizing Spotify's data API and YouTube, Piped.video or JioSaavn as an audio source,
+eliminating the need for Spotify Premium - Visit the website - Discord Server +Btw it's not just another Electron app 😉 - Support me on Patron - Buy me a Coffee +Visit the website +Discord Server - Donate to our Open Collective +Support me on Patron +Buy me a Coffee - --- +Donate to our Open Collective - ![Spotube Desktop](assets/spotube-screenshot.png) +--- - ![Spotube Mobile](assets/mobile-screenshots/combined.png) +![Spotube Desktop](assets/spotube-screenshot.png) + +![Spotube Mobile](assets/mobile-screenshots/combined.png)
## 🌃 Features - 🚫 No ads, thanks to the use of public & free Spotify and YT Music APIs¹ -- ⬇️ Downloadable tracks +- ⬇️ Freely downloadable tracks - 🖥️ 📱 Cross-platform support - 🪶 Small size & less data usage - 🕵️ Anonymous/guest login @@ -33,17 +36,17 @@ - 📖 Open source/libre software - 🔉 Playback control is done locally, not on the server -**¹** It is still **recommended** to support the creators by watching/liking/subscribing to the artists' YouTube channels or liking their tracks on Spotify (or purchasing a Spotify Premium subscription too). +**¹** It is still **recommended** to support creators by engaging with their YouTube channels/Spotify tracks (or preferably by buying their merch/concert tickets/physical media). ### ❌ Unsupported features -- 🗣️ **Spotify Shows & Podcasts:** Shows and Podcasts can **never be supported** because the audio tracks are _only_ available on Spotify and accessing them would require Spotify Premium. +- 🗣️ **Spotify Shows & Podcasts:** Shows and Podcasts will **never be supported** because the audio tracks are _only_ available on Spotify and accessing them would require Spotify Premium. - 🎧 **Spotify Listen Along:** [Coming soon!](https://github.com/KRTirtho/spotube/issues/8) ## 📜 ⬇️ Installation guide -New releases usually appear after 3-4 months.
-This handy table lists all methods you can use to install Spotube: +New versions usually release every 3-4 months.
+This handy table lists all the methods you can use to install Spotube: @@ -105,7 +108,7 @@ This handy table lists all methods you can use to install Spotube: Debian/Ubuntu Download -

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

+

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

@@ -158,7 +161,7 @@ This handy table lists all methods you can use to install Spotube: Grab the latest nightly builds of Spotube [from the GitHub Releases](https://github.com/KRTirtho/spotube/releases/tag/nightly). -## 🕳️ Building from source +## 🕳️ Building from source GitHub Workflow Status @@ -167,8 +170,9 @@ You can compile Spotube's source code by [following these instructions](CONTRIBU ## 👥 The Spotube team - [Kingkor Roy Tirtho](https://github.com/KRTirtho) - The Founder, Maintainer and Lead Developer -- [Owen Connor](https://github.com/owencz1998) - The Cool Discord Moderator - [RaptaG](https://github.com/RaptaG) - The GitHub Moderator and Community Manager +- [Owen Connor](https://github.com/owencz1998) - The Cool Discord Moderator +- [Meenbeese](https://github.com/meenbeese) - The Android Developer - [Piotr Rogowski](https://github.com/karniv00l) - The MacOS Developer - [Rusty Apple](https://github.com/RustyApple) - The Mysterious Unknown Guy @@ -180,26 +184,32 @@ If you are concerned, you can [read the reason of choosing this license](https:/
-

[Click to show] 🙏 Library/Plugin/Framework Credits

+

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

+### Services 1. [Flutter](https://flutter.dev) - Flutter transforms the app development process. Build, test, and deploy beautiful mobile, web, desktop, and embedded apps from a single codebase 1. [Spotify API](https://developer.spotify.com/documentation/web-api) - The Spotify Web API is a RESTful API that provides access to Spotify data 1. [Piped](https://piped-docs.kavin.rocks/) - Piped is a privacy friendly alternative YouTube frontend, which is efficient and scalable by design. 1. [YouTube](https://youtube.com/) - YouTube is an American online video-sharing platform headquartered in San Bruno, California. Three former PayPal employees—Chad Hurley, Steve Chen, and Jawed Karim—created the service in February 2005 +1. [JioSaavn](https://www.jiosaavn.com) - JioSaavn is an Indian online music streaming service and a digital distributor of Bollywood, English and other regional Indian music across the world. Since it was founded in 2007 as Saavn, the company has acquired rights to over 5 crore (50 million) music tracks in 15 languages 1. [Linux](https://www.linux.org) - Linux is a family of open-source Unix-like operating systems based on the Linux kernel, an operating system kernel first released on September 17, 1991, by Linus Torvalds. Linux is typically packaged in a Linux distribution 1. [AUR](https://aur.archlinux.org) - AUR stands for Arch User Repository. It is a community-driven repository for Arch-based Linux distributions users 1. [Flatpak](https://flatpak.org) - Flatpak is a utility for software deployment and package management for Linux 1. [SponsorBlock](https://sponsor.ajay.app) - SponsorBlock is an open-source crowdsourced browser extension and open API for skipping sponsor segments in YouTube videos. 1. [Inno Setup](https://jrsoftware.org/isinfo.php) - Inno Setup is a free installer for Windows programs by Jordan Russell and Martijn Laan 1. [F-Droid](https://f-droid.org) - F-Droid is an installable catalogue of FOSS (Free and Open Source Software) applications for the Android platform. The client makes it easy to browse, install, and keep track of updates on your device +1. [LastFM](https://last.fm) - Last.fm is a music streaming and discovery platform that helps users discover and share new music. It tracks users' music listening habits across many devices and platforms. + +### Dependencies 1. [args](https://pub.dev/packages/args) - Library for defining parsers for parsing raw command-line arguments into a set of options and values using GNU and POSIX style options. 1. [async](https://pub.dev/packages/async) - Utility functions and classes related to the 'dart:async' library. -1. [audio_service](https://github.com/ryanheise/audio_service/tree/master/audio_service) - Flutter plugin to play audio in the background while the screen is off. +1. [audio_service](https://pub.dev/packages/audio_service) - Flutter plugin to play audio in the background while the screen is off. 1. [audio_session](https://github.com/ryanheise/audio_session) - Sets the iOS audio session category and Android audio attributes for your app, and manages your app's audio focus, mixing and ducking behaviour. 1. [auto_size_text](https://github.com/leisim/auto_size_text) - Flutter widget that automatically resizes text to fit perfectly within its bounds. 1. [buttons_tabbar](https://afonsoraposo.com) - A Flutter package that implements a TabBar where each label is a toggle button. 1. [cached_network_image](https://github.com/Baseflow/flutter_cached_network_image) - Flutter library to load and cache network images. Can also be used with placeholder and error widgets. +1. [catcher_2](https://github.com/ThexXTURBOXx/catcher_2) - Plugin for error catching which provides multiple handlers for dealing with errors when they are not caught by the developer. 1. [collection](https://pub.dev/packages/collection) - Collections and utilities functions and classes related to collections. 1. [cupertino_icons](https://pub.dev/packages/cupertino_icons) - Default icons asset for Cupertino widgets based on Apple styled icons 1. [curved_navigation_bar](https://github.com/rafalbednarczuk/curved_navigation_bar) - Stunning Animating Curved Shape Navigation Bar. Adjustable color, background color, animation curve, animation duration. @@ -210,10 +220,10 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [disable_battery_optimization](https://github.com/pvsvamsi/Disable-Battery-Optimizations) - Flutter plugin to check and disable battery optimizations. Also shows custom steps to disable the optimizations in devices like mi, xiaomi, samsung, oppo, huawei, oneplus etc 1. [duration](https://github.com/desktop-dart/duration) - Utilities to make working with 'Duration's easier. Formats duration in human readable form and also parses duration in human readable form to Dart's Duration. 1. [envied](https://github.com/petercinibulk/envied) - Explicitly reads environment variables into a dart file from a .env file for more security and faster start up times. -1. [file_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. [fl_query](https://fl-query.vercel.app) - Asynchronous data caching, refetching & invalidation library for Flutter -1. [fl_query_hooks](https://fl-query.vercel.app) - Elite flutter_hooks compatible library for fl_query, the Asynchronous data caching, refetching & invalidation library for Flutter -1. [fl_query_connectivity_plus_adapter](https://fl-query.vercel.app) - Connectivity Plus adapter for FlQuery Connectivity +1. [file_selector](https://pub.dev/packages/file_selector) - Flutter plugin for opening and saving files, or selecting directories, using native file selection UI. +1. [fl_query](https://fl-query.krtirtho.dev) - Asynchronous data caching, refetching & invalidation library for Flutter +1. [fl_query_hooks](https://fl-query.krtirtho.dev) - Elite flutter_hooks compatible library for fl_query, the Asynchronous data caching, refetching & invalidation library for Flutter +1. [fl_query_devtools](https://fl-query.krtirtho.dev) - Devtools support for Fl-Query 1. [fluentui_system_icons](https://github.com/microsoft/fluentui-system-icons/tree/main) - Fluent UI System Icons are a collection of familiar, friendly and modern icons from Microsoft. 1. [flutter_cache_manager](https://github.com/Baseflow/flutter_cache_manager/tree/develop/flutter_cache_manager) - Generic cache manager for flutter. Saves web files on the storages of the device and saves the cache info using sqflite. 1. [flutter_displaymode](https://github.com/ajinasokan/flutter_displaymode) - A Flutter plugin to set display mode (resolution, refresh rate) on Android platform. Allows to enable high refresh rate on supported devices. @@ -224,25 +234,22 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [flutter_riverpod](https://riverpod.dev) - A simple way to access state from anywhere in your application while robust and testable. 1. [flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage) - Flutter Secure Storage provides API to store data in secure storage. Keychain is used in iOS, KeyStore based solution is used in Android. 1. [flutter_svg](https://pub.dev/packages/flutter_svg) - An SVG rendering and widget library for Flutter, which allows painting and displaying Scalable Vector Graphics 1.1 files. +1. [form_validator](https://github.com/TheMisir/form-validator) - Simplest form validation library for flutter's form field widgets 1. [fuzzywuzzy](https://github.com/sphericalkat/dart-fuzzywuzzy) - An implementation of the popular fuzzywuzzy package in Dart, to suit all your fuzzy string matching/searching needs! -1. [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. [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. [html](https://pub.dev/packages/html) - APIs for parsing and manipulating HTML content outside the browser. 1. [http](https://pub.dev/packages/http) - A composable, multi-platform, Future-based API for HTTP requests. +1. [image_picker](https://pub.dev/packages/image_picker) - Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. 1. [intl](https://pub.dev/packages/intl) - Contains code to deal with internationalized/localized messages, date and number formatting and parsing, bi-directional text, and other internationalization issues. 1. [introduction_screen](https://github.com/pyozer/introduction_screen) - Introduction/Onboarding package for flutter app with some customizations possibilities 1. [json_annotation](https://pub.dev/packages/json_annotation) - Classes and helper functions that support JSON code generation via the `json_serializable` package. 1. [logger](https://pub.dev/packages/logger) - Small, easy to use and extensible logger which prints beautiful logs. 1. [media_kit](https://github.com/media-kit/media-kit) - A cross-platform video player & audio player for Flutter & Dart. Performant, stable, feature-proof & modular. -1. [media_kit_native_event_loop](https://github.com/media-kit/media-kit) - Platform specific threaded event handling for media_kit. Enables support for higher number of concurrent instances. -1. [media_kit_libs_android_audio](https://github.com/media-kit/media-kit.git) - Android package providing audio (only) native libraries for package:media_kit. -1. [media_kit_libs_ios_audio](https://github.com/media-kit/media-kit.git) - iOS package providing audio native libraries for package:media_kit. -1. [media_kit_libs_macos_audio](https://github.com/media-kit/media-kit.git) - macOS package providing audio native libraries for package:media_kit. -1. [media_kit_libs_windows_audio](https://github.com/media-kit/media-kit.git) - Windows package providing audio (only) native libraries for package:media_kit. -1. [media_kit_libs_linux](https://github.com/media-kit/media-kit.git) - GNU/Linux dependency package for package:media_kit. Necessary for initialization. +1. [media_kit_libs_audio](https://github.com/media-kit/media-kit.git) - package:media_kit audio (only) playback native libraries for all platforms. 1. [metadata_god](https://github.com/KRTirtho/metadata_god) - Plugin for retrieving and writing audio tags/metadata from audio files 1. [mime](https://pub.dev/packages/mime) - Utilities for handling media (MIME) types, including determining a type from a file extension and file contents. 1. [package_info_plus](https://plus.fluttercommunity.dev/) - Flutter plugin for querying information about the application package, such as CFBundleVersion on iOS or versionCode on Android. @@ -253,20 +260,33 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 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. [scroll_to_index](https://github.com/quire-io/scroll-to-index) - Scroll to a specific child of any scrollable widget in Flutter -1. [shared_preferences](https://pub.dev/packages/shared_preferences) - Flutter plugin for reading and writing simple key-value pairs. Wraps NSUserDefaults on iOS and SharedPreferences on Android. 1. [sidebarx](https://github.com/Frezyx/sidebarx) - flutter multiplatform navigation sidebar / side navigationbar / drawer widget +1. [shared_preferences](https://pub.dev/packages/shared_preferences) - Flutter plugin for reading and writing simple key-value pairs. Wraps NSUserDefaults on iOS and SharedPreferences on Android. 1. [skeleton_text](https://github.com/101Loop/Skeleton-Text) - A package that provides an easy way to add skeleton text loading animation in Flutter project. This project is a part of 101Loop community. 1. [smtc_windows](https://github.com/KRTirtho/smtc_windows) - Windows `SystemMediaTransportControls` implementation for Flutter giving access to Windows OS Media Control applet. 1. [spotify](https://github.com/rinukkusu/spotify-dart) - An incomplete dart library for interfacing with the Spotify Web API. -1. [supabase](https://supabase.com) - A dart client for Supabase. This client makes it simple for developers to build secure and scalable products. +1. [stroke_text](https://github.com/MohamedAbd0/stroke_text) - A Simple Flutter plugin for applying stroke (border) style to a text widget 1. [system_theme](https://pub.dev/packages/system_theme) - A plugin to get the current system theme info. Supports Android, Web, Windows, Linux and macOS 1. [titlebar_buttons](https://github.com/gtk-flutter/titlebar_buttons) - A package which provides most of the titlebar buttons from windows, linux and macos. 1. [url_launcher](https://pub.dev/packages/url_launcher) - Flutter plugin for launching a URL. Supports web, phone, SMS, and email schemes. -1. [uuid](https://github.com/Daegalus/dart-uuid) - RFC4122 (v1, v4, v5) UUID Generator and Parser for all Dart platforms (Web, VM, Flutter) +1. [uuid](https://pub.dev/packages/uuid) - RFC4122 (v1, v4, v5, v6, v7, v8) UUID Generator and Parser for Dart 1. [version](https://github.com/dartninja/version) - Provides a simple class for parsing and comparing semantic versions as defined by http://semver.org/ 1. [visibility_detector](https://pub.dev/packages/visibility_detector) - A widget that detects the visibility of its child and notifies a callback. 1. [window_manager](https://github.com/leanflutter/window_manager) - This plugin allows Flutter desktop apps to resizing and repositioning the window. 1. [youtube_explode_dart](https://github.com/Hexer10/youtube_explode_dart) - A port in dart of the youtube explode library. Supports several API functions without the need of Youtube API Key. +1. [simple_icons](https://teavelopment.com/) - The Simple Icon pack available as Flutter Icons. Provides over 1500 Free SVG icons for popular brands. +1. [audio_service_mpris](https://github.com/bdrazhzhov/audio-service-mpris) - audio_service platform interface supporting Media Player Remote Interfacing Specification. +1. [file_picker](https://github.com/miguelpruivo/plugins_flutter_file_picker) - A package that allows you to use a native file explorer to pick single or multiple absolute file paths, with extension filtering support. +1. [jiosaavn](https://github.com/KRTirtho/jiosaavn) - Unofficial API client for jiosaavn.com +1. [very_good_infinite_list](https://github.com/VeryGoodOpenSource/very_good_infinite_list) - A library for easily displaying paginated data, created by Very Good Ventures. Great for activity feeds, news feeds, and more. +1. [gap](https://github.com/letsar/gap) - Flutter widgets for easily adding gaps inside Flex widgets such as Columns and Rows or scrolling views. +1. [sliver_tools](https://github.com/Kavantix) - A set of useful sliver tools that are missing from the flutter framework +1. [html_unescape](https://github.com/filiph/html_unescape) - A small library for un-escaping HTML. Supports all Named Character References, Decimal Character References and Hexadecimal Character References. +1. [wikipedia_api](https://github.com/KRTirtho/wikipedia_api) - Wikipedia API for dart and flutter +1. [skeletonizer](https://github.com/Milad-Akarie/skeletonizer) - Converts already built widgets into skeleton loaders with no extra effort. +1. [app_links](https://github.com/llfbandit/app_links) - Android App Links, Deep Links, iOs Universal Links and Custom URL schemes handler for Flutter (desktop included). +1. [win32_registry](https://win32.pub) - A package that provides a friendly Dart API for accessing the Windows Registry. +1. [flutter_sharing_intent](https://github.com/bhagat-techind/flutter_sharing_intent.git) - A flutter plugin that allow flutter apps to receive photos, videos, text, urls or any other file types from another app. 1. [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. @@ -277,9 +297,11 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 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. [catcher](https://github.com/jhomlala/catcher) - Plugin for error catching which provides multiple handlers for dealing with errors when they are not caught by the developer. 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.
-

© Copyright Spotube 2023

+

© Copyright Spotube 2024

diff --git a/analysis_options.yaml b/analysis_options.yaml index 4f0718e4..5f2cbbe1 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -31,4 +31,6 @@ linter: analyzer: enable-experiment: - records - - patterns \ No newline at end of file + - patterns + errors: + invalid_annotation_target: ignore diff --git a/android/app/build.gradle b/android/app/build.gradle index d05a90a1..2f85cdeb 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -32,7 +32,7 @@ if (keystorePropertiesFile.exists()) { } android { - compileSdkVersion 33 + compileSdkVersion 34 ndkVersion "21.4.7075529" @@ -72,6 +72,28 @@ android { signingConfig signingConfigs.release } } + + flavorDimensions "default" + + productFlavors { + nightly { + dimension "default" + resValue "string", "app_name_en", "Spotube Nightly" + applicationIdSuffix ".nightly" + versionNameSuffix "-nightly" + } + dev { + dimension "default" + resValue "string", "app_name_en", "Spotube Dev" + applicationIdSuffix ".dev" + versionNameSuffix "-dev" + } + stable { + dimension "default" + resValue "string", "app_name_en", "Spotube" + } + } + } flutter { @@ -92,4 +114,4 @@ dependencies { // other deps so just ignore implementation 'com.android.support:multidex:2.0.1' -} +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7891310d..5ab7a0b5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -18,7 +18,7 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/nightly/res/drawable-hdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-hdpi-v31/android12branding.png new file mode 100644 index 00000000..9e62c813 Binary files /dev/null and b/android/app/src/nightly/res/drawable-hdpi-v31/android12branding.png differ diff --git a/android/app/src/nightly/res/drawable-hdpi/android12splash.png b/android/app/src/nightly/res/drawable-hdpi/android12splash.png new file mode 100644 index 00000000..98fd87f4 Binary files /dev/null and b/android/app/src/nightly/res/drawable-hdpi/android12splash.png differ diff --git a/android/app/src/nightly/res/drawable-hdpi/branding.png b/android/app/src/nightly/res/drawable-hdpi/branding.png new file mode 100644 index 00000000..9e62c813 Binary files /dev/null and b/android/app/src/nightly/res/drawable-hdpi/branding.png differ diff --git a/android/app/src/nightly/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/nightly/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..20a57ba2 Binary files /dev/null and b/android/app/src/nightly/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/nightly/res/drawable-hdpi/splash.png b/android/app/src/nightly/res/drawable-hdpi/splash.png new file mode 100644 index 00000000..07c7024a Binary files /dev/null and b/android/app/src/nightly/res/drawable-hdpi/splash.png differ diff --git a/android/app/src/nightly/res/drawable-mdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-mdpi-v31/android12branding.png new file mode 100644 index 00000000..80579983 Binary files /dev/null and b/android/app/src/nightly/res/drawable-mdpi-v31/android12branding.png differ diff --git a/android/app/src/nightly/res/drawable-mdpi/android12splash.png b/android/app/src/nightly/res/drawable-mdpi/android12splash.png new file mode 100644 index 00000000..a86a2222 Binary files /dev/null and b/android/app/src/nightly/res/drawable-mdpi/android12splash.png differ diff --git a/android/app/src/nightly/res/drawable-mdpi/branding.png b/android/app/src/nightly/res/drawable-mdpi/branding.png new file mode 100644 index 00000000..80579983 Binary files /dev/null and b/android/app/src/nightly/res/drawable-mdpi/branding.png differ diff --git a/android/app/src/nightly/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/nightly/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..43f5e0fe Binary files /dev/null and b/android/app/src/nightly/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/nightly/res/drawable-mdpi/splash.png b/android/app/src/nightly/res/drawable-mdpi/splash.png new file mode 100644 index 00000000..86d3fe74 Binary files /dev/null and b/android/app/src/nightly/res/drawable-mdpi/splash.png differ diff --git a/android/app/src/nightly/res/drawable-night-hdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-night-hdpi-v31/android12branding.png new file mode 100644 index 00000000..9e62c813 Binary files /dev/null and b/android/app/src/nightly/res/drawable-night-hdpi-v31/android12branding.png differ diff --git a/android/app/src/nightly/res/drawable-night-hdpi/android12splash.png b/android/app/src/nightly/res/drawable-night-hdpi/android12splash.png new file mode 100644 index 00000000..98fd87f4 Binary files /dev/null and b/android/app/src/nightly/res/drawable-night-hdpi/android12splash.png differ diff --git a/android/app/src/nightly/res/drawable-night-mdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-night-mdpi-v31/android12branding.png new file mode 100644 index 00000000..80579983 Binary files /dev/null and b/android/app/src/nightly/res/drawable-night-mdpi-v31/android12branding.png differ diff --git a/android/app/src/nightly/res/drawable-night-mdpi/android12splash.png b/android/app/src/nightly/res/drawable-night-mdpi/android12splash.png new file mode 100644 index 00000000..a86a2222 Binary files /dev/null and b/android/app/src/nightly/res/drawable-night-mdpi/android12splash.png differ diff --git a/android/app/src/nightly/res/drawable-night-xhdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-night-xhdpi-v31/android12branding.png new file mode 100644 index 00000000..0bcf138d Binary files /dev/null and b/android/app/src/nightly/res/drawable-night-xhdpi-v31/android12branding.png differ diff --git a/android/app/src/nightly/res/drawable-night-xhdpi/android12splash.png b/android/app/src/nightly/res/drawable-night-xhdpi/android12splash.png new file mode 100644 index 00000000..ad3f39d0 Binary files /dev/null and b/android/app/src/nightly/res/drawable-night-xhdpi/android12splash.png differ diff --git a/android/app/src/nightly/res/drawable-night-xxhdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-night-xxhdpi-v31/android12branding.png new file mode 100644 index 00000000..c7d01776 Binary files /dev/null and b/android/app/src/nightly/res/drawable-night-xxhdpi-v31/android12branding.png differ diff --git a/android/app/src/nightly/res/drawable-night-xxhdpi/android12splash.png b/android/app/src/nightly/res/drawable-night-xxhdpi/android12splash.png new file mode 100644 index 00000000..133fb647 Binary files /dev/null and b/android/app/src/nightly/res/drawable-night-xxhdpi/android12splash.png differ diff --git a/android/app/src/nightly/res/drawable-night-xxxhdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-night-xxxhdpi-v31/android12branding.png new file mode 100644 index 00000000..5477b799 Binary files /dev/null and b/android/app/src/nightly/res/drawable-night-xxxhdpi-v31/android12branding.png differ diff --git a/android/app/src/nightly/res/drawable-night-xxxhdpi/android12splash.png b/android/app/src/nightly/res/drawable-night-xxxhdpi/android12splash.png new file mode 100644 index 00000000..fa5a8c92 Binary files /dev/null and b/android/app/src/nightly/res/drawable-night-xxxhdpi/android12splash.png differ diff --git a/android/app/src/nightly/res/drawable-v21/background.png b/android/app/src/nightly/res/drawable-v21/background.png new file mode 100644 index 00000000..203fc77a Binary files /dev/null and b/android/app/src/nightly/res/drawable-v21/background.png differ diff --git a/android/app/src/nightly/res/drawable-v21/launch_background.xml b/android/app/src/nightly/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..52e8749e --- /dev/null +++ b/android/app/src/nightly/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/android/app/src/nightly/res/drawable-xhdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-xhdpi-v31/android12branding.png new file mode 100644 index 00000000..0bcf138d Binary files /dev/null and b/android/app/src/nightly/res/drawable-xhdpi-v31/android12branding.png differ diff --git a/android/app/src/nightly/res/drawable-xhdpi/android12splash.png b/android/app/src/nightly/res/drawable-xhdpi/android12splash.png new file mode 100644 index 00000000..ad3f39d0 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xhdpi/android12splash.png differ diff --git a/android/app/src/nightly/res/drawable-xhdpi/branding.png b/android/app/src/nightly/res/drawable-xhdpi/branding.png new file mode 100644 index 00000000..0bcf138d Binary files /dev/null and b/android/app/src/nightly/res/drawable-xhdpi/branding.png differ diff --git a/android/app/src/nightly/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/nightly/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..4cf86d25 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/nightly/res/drawable-xhdpi/splash.png b/android/app/src/nightly/res/drawable-xhdpi/splash.png new file mode 100644 index 00000000..dbb0ea02 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xhdpi/splash.png differ diff --git a/android/app/src/nightly/res/drawable-xxhdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-xxhdpi-v31/android12branding.png new file mode 100644 index 00000000..c7d01776 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxhdpi-v31/android12branding.png differ diff --git a/android/app/src/nightly/res/drawable-xxhdpi/android12splash.png b/android/app/src/nightly/res/drawable-xxhdpi/android12splash.png new file mode 100644 index 00000000..133fb647 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxhdpi/android12splash.png differ diff --git a/android/app/src/nightly/res/drawable-xxhdpi/branding.png b/android/app/src/nightly/res/drawable-xxhdpi/branding.png new file mode 100644 index 00000000..c7d01776 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxhdpi/branding.png differ diff --git a/android/app/src/nightly/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/nightly/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..95fa3443 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/nightly/res/drawable-xxhdpi/splash.png b/android/app/src/nightly/res/drawable-xxhdpi/splash.png new file mode 100644 index 00000000..12eb5531 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxhdpi/splash.png differ diff --git a/android/app/src/nightly/res/drawable-xxxhdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-xxxhdpi-v31/android12branding.png new file mode 100644 index 00000000..5477b799 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxxhdpi-v31/android12branding.png differ diff --git a/android/app/src/nightly/res/drawable-xxxhdpi/android12splash.png b/android/app/src/nightly/res/drawable-xxxhdpi/android12splash.png new file mode 100644 index 00000000..fa5a8c92 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxxhdpi/android12splash.png differ diff --git a/android/app/src/nightly/res/drawable-xxxhdpi/branding.png b/android/app/src/nightly/res/drawable-xxxhdpi/branding.png new file mode 100644 index 00000000..5477b799 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxxhdpi/branding.png differ diff --git a/android/app/src/nightly/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/nightly/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..3de8a2ee Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/nightly/res/drawable-xxxhdpi/splash.png b/android/app/src/nightly/res/drawable-xxxhdpi/splash.png new file mode 100644 index 00000000..68e806f4 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxxhdpi/splash.png differ diff --git a/android/app/src/nightly/res/drawable/background.png b/android/app/src/nightly/res/drawable/background.png new file mode 100644 index 00000000..203fc77a Binary files /dev/null and b/android/app/src/nightly/res/drawable/background.png differ diff --git a/android/app/src/nightly/res/drawable/launch_background.xml b/android/app/src/nightly/res/drawable/launch_background.xml new file mode 100644 index 00000000..52e8749e --- /dev/null +++ b/android/app/src/nightly/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/android/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..5f349f7f --- /dev/null +++ b/android/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/nightly/res/mipmap-hdpi/ic_launcher.png b/android/app/src/nightly/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..a826bb73 Binary files /dev/null and b/android/app/src/nightly/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/nightly/res/mipmap-mdpi/ic_launcher.png b/android/app/src/nightly/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..3743861d Binary files /dev/null and b/android/app/src/nightly/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/nightly/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/nightly/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..1be1daa7 Binary files /dev/null and b/android/app/src/nightly/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/nightly/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/nightly/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..3a8a7832 Binary files /dev/null and b/android/app/src/nightly/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..781c9c1a Binary files /dev/null and b/android/app/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/nightly/res/values-night-v31/styles.xml b/android/app/src/nightly/res/values-night-v31/styles.xml new file mode 100644 index 00000000..96980835 --- /dev/null +++ b/android/app/src/nightly/res/values-night-v31/styles.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/android/app/src/nightly/res/values-night/styles.xml b/android/app/src/nightly/res/values-night/styles.xml new file mode 100644 index 00000000..dbc9ea9f --- /dev/null +++ b/android/app/src/nightly/res/values-night/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/nightly/res/values-v31/styles.xml b/android/app/src/nightly/res/values-v31/styles.xml new file mode 100644 index 00000000..981a07a9 --- /dev/null +++ b/android/app/src/nightly/res/values-v31/styles.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/android/app/src/nightly/res/values/colors.xml b/android/app/src/nightly/res/values/colors.xml new file mode 100644 index 00000000..88247a21 --- /dev/null +++ b/android/app/src/nightly/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #242832 + \ No newline at end of file diff --git a/android/app/src/nightly/res/values/styles.xml b/android/app/src/nightly/res/values/styles.xml new file mode 100644 index 00000000..0d1fa8fc --- /dev/null +++ b/android/app/src/nightly/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/assets/jiosaavn.png b/assets/jiosaavn.png new file mode 100644 index 00000000..4d2d46e4 Binary files /dev/null and b/assets/jiosaavn.png differ diff --git a/assets/liked-tracks.jpg b/assets/liked-tracks.jpg new file mode 100644 index 00000000..62dad65e Binary files /dev/null and b/assets/liked-tracks.jpg differ diff --git a/assets/spotube-hero-banner.png b/assets/spotube-hero-banner.png new file mode 100644 index 00000000..c5309b92 Binary files /dev/null and b/assets/spotube-hero-banner.png differ diff --git a/assets/spotube-nightly-logo-foreground.jpg b/assets/spotube-nightly-logo-foreground.jpg new file mode 100644 index 00000000..a0c849b6 Binary files /dev/null and b/assets/spotube-nightly-logo-foreground.jpg differ diff --git a/assets/spotube-nightly-logo.png b/assets/spotube-nightly-logo.png new file mode 100644 index 00000000..ea7a8b20 Binary files /dev/null and b/assets/spotube-nightly-logo.png differ diff --git a/assets/spotube-nightly-logo.svg b/assets/spotube-nightly-logo.svg new file mode 100644 index 00000000..7601108e --- /dev/null +++ b/assets/spotube-nightly-logo.svg @@ -0,0 +1,359 @@ + + diff --git a/assets/spotube-nightly-logo_android12.png b/assets/spotube-nightly-logo_android12.png new file mode 100644 index 00000000..1a5bf4f1 Binary files /dev/null and b/assets/spotube-nightly-logo_android12.png differ diff --git a/assets/spotube-tall-capsule.png b/assets/spotube-tall-capsule.png new file mode 100644 index 00000000..43fb8229 Binary files /dev/null and b/assets/spotube-tall-capsule.png differ diff --git a/assets/spotube-wide-capsule-large.png b/assets/spotube-wide-capsule-large.png new file mode 100644 index 00000000..09a93d83 Binary files /dev/null and b/assets/spotube-wide-capsule-large.png differ diff --git a/assets/spotube-wide-capsule-small.png b/assets/spotube-wide-capsule-small.png new file mode 100644 index 00000000..17566550 Binary files /dev/null and b/assets/spotube-wide-capsule-small.png differ diff --git a/aur-struct/.SRCINFO b/aur-struct/.SRCINFO index 13c82382..ae0b6d10 100644 --- a/aur-struct/.SRCINFO +++ b/aur-struct/.SRCINFO @@ -10,7 +10,7 @@ pkgbase = spotube-bin depends = libsecret depends = jsoncpp depends = libnotify - depends = networkmanager + depends = xdg-user-dirs source = https://github.com/KRTirtho/spotube/releases/download/v2.3.0/Spotube-linux-x86_64.tar.xz md5sums = 8cd6a7385c5c75d203dccd762f1d63ec diff --git a/aur-struct/PKGBUILD b/aur-struct/PKGBUILD index 9d2e093f..4663c3ab 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' 'networkmanager') +depends=('mpv' 'libappindicator-gtk3' 'libsecret' 'jsoncpp' 'libnotify' 'xdg-user-dirs') makedepends=() checkdepends=() optdepends=() diff --git a/bin/gen-credits.dart b/bin/gen-credits.dart index 6f3e1b51..f8975335 100644 --- a/bin/gen-credits.dart +++ b/bin/gen-credits.dart @@ -1,7 +1,7 @@ +import 'dart:developer'; import 'dart:io'; import 'package:collection/collection.dart'; -import 'package:path/path.dart'; import 'package:http/http.dart'; import 'package:html/parser.dart'; import 'package:pub_api_client/pub_api_client.dart'; @@ -33,15 +33,20 @@ void main() async { final gitDeps = gitDepsList.map( (d) { + final uri = Uri.parse( + d.value.url.toString().replaceAll('.git', ''), + ); return MapEntry( d.key, - join( - d.value.url.toString().replaceAll('.git', ''), - 'raw', - d.value.ref ?? 'main', - d.value.path ?? '', - 'pubspec.yaml', - ), + uri.replace( + pathSegments: [ + ...uri.pathSegments, + 'raw', + d.value.ref ?? 'main', + d.value.path ?? '', + 'pubspec.yaml', + ], + ).toString(), ); }, ).toList(); @@ -55,7 +60,10 @@ void main() async { } catch (e) { final document = parse(res.body); final pre = document.querySelector('pre'); - if (pre == null) rethrow; + if (pre == null) { + log(d.toString()); + rethrow; + } return Pubspec.parse(pre.text); } } @@ -68,6 +76,7 @@ void main() async { ), ); + // ignore: avoid_print print( packageInfo .map( @@ -76,6 +85,7 @@ void main() async { ) .join('\n'), ); + // ignore: avoid_print print( gitPubspecs.map( (package) { diff --git a/bin/untranslated_messages.dart b/bin/untranslated_messages.dart index 428314aa..e19f9a07 100644 --- a/bin/untranslated_messages.dart +++ b/bin/untranslated_messages.dart @@ -35,6 +35,12 @@ void main(List args) { ); } + 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/flutter_launcher_icons-nightly.yaml b/flutter_launcher_icons-nightly.yaml new file mode 100644 index 00000000..c6892d4b --- /dev/null +++ b/flutter_launcher_icons-nightly.yaml @@ -0,0 +1,6 @@ +flutter_launcher_icons: + android: true + ios: true + image_path: "assets/spotube-nightly-logo.png" + adaptive_icon_foreground: "assets/spotube-nightly-logo-foreground.jpg" + adaptive_icon_background: "#242832" diff --git a/flutter_native_splash-nightly.yaml b/flutter_native_splash-nightly.yaml new file mode 100644 index 00000000..37da37d9 --- /dev/null +++ b/flutter_native_splash-nightly.yaml @@ -0,0 +1,9 @@ +flutter_native_splash: + background_image: assets/bengali-patterns-bg.jpg + image: assets/spotube-nightly-logo.png + branding: assets/branding.png + android_12: + image: assets/spotube-nightly-logo_android12.png + branding: assets/branding.png + color: "#000000" + icon_background_color: "#000000" diff --git a/ios/Podfile b/ios/Podfile index cfd01b62..5b0d5a2c 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -34,6 +34,27 @@ target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) end +target 'dev' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +target 'stable' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +target 'nightly' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 68fcdacc..35f3dc18 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,13 +1,12 @@ PODS: + - app_links (0.0.1): + - Flutter - audio_service (0.0.1): - Flutter - audio_session (0.0.1): - Flutter - - audioplayers_darwin (0.0.1): + - device_info_plus (0.0.1): - Flutter - - connectivity_plus (0.0.1): - - Flutter - - ReachabilitySwift - DKImagePickerController/Core (4.3.4): - DKImagePickerController/ImageDataManager - DKImagePickerController/Resource @@ -42,6 +41,8 @@ PODS: - file_picker (0.0.1): - DKImagePickerController/PhotoGallery - Flutter + - file_selector_ios (0.0.1): + - Flutter - Flutter (1.0.0) - flutter_inappwebview (0.0.1): - Flutter @@ -50,44 +51,77 @@ PODS: - flutter_inappwebview/Core (0.0.1): - Flutter - OrderedSet (~> 5.0) + - flutter_keyboard_visibility (0.0.1): + - Flutter + - flutter_mailer (0.0.1): + - Flutter + - 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) - - metadata_god (0.0.1): + - image_picker_ios (0.0.1): - Flutter + - integration_test (0.0.1): + - Flutter + - media_kit_libs_ios_audio (1.0.4): + - Flutter + - media_kit_native_event_loop (1.0.0): + - Flutter + - metadata_god (0.0.1) - OrderedSet (5.0.0) - package_info_plus (0.4.5): - Flutter - - path_provider_ios (0.0.1): + - path_provider_foundation (0.0.1): - Flutter - - permission_handler_apple (9.0.4): + - FlutterMacOS + - permission_handler_apple (9.1.1): - Flutter - - ReachabilitySwift (5.0.0) - - SDWebImage (5.13.4): - - SDWebImage/Core (= 5.13.4) - - SDWebImage/Core (5.13.4) - - shared_preferences_ios (0.0.1): + - SDWebImage (5.18.8): + - SDWebImage/Core (= 5.18.8) + - SDWebImage/Core (5.18.8) + - shared_preferences_foundation (0.0.1): - Flutter - - sqflite (0.0.2): + - FlutterMacOS + - sqflite (0.0.3): - Flutter - FMDB (>= 2.7.5) - - SwiftyGif (5.4.3) + - SwiftyGif (5.4.4) + - Toast (4.0.0) - url_launcher_ios (0.0.1): - Flutter 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`) - - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/ios`) - - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) + - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - 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_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`) + - media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`) - metadata_god (from `.symlinks/plugins/metadata_god/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - - shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite (from `.symlinks/plugins/sqflite/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) @@ -97,63 +131,96 @@ SPEC REPOS: - DKPhotoGallery - FMDB - OrderedSet - - ReachabilitySwift - SDWebImage - SwiftyGif + - Toast EXTERNAL SOURCES: + app_links: + :path: ".symlinks/plugins/app_links/ios" audio_service: :path: ".symlinks/plugins/audio_service/ios" audio_session: :path: ".symlinks/plugins/audio_session/ios" - audioplayers_darwin: - :path: ".symlinks/plugins/audioplayers_darwin/ios" - connectivity_plus: - :path: ".symlinks/plugins/connectivity_plus/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" file_picker: :path: ".symlinks/plugins/file_picker/ios" + file_selector_ios: + :path: ".symlinks/plugins/file_selector_ios/ios" 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_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: + :path: ".symlinks/plugins/integration_test/ios" + media_kit_libs_ios_audio: + :path: ".symlinks/plugins/media_kit_libs_ios_audio/ios" + media_kit_native_event_loop: + :path: ".symlinks/plugins/media_kit_native_event_loop/ios" metadata_god: :path: ".symlinks/plugins/metadata_god/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_ios: - :path: ".symlinks/plugins/path_provider_ios/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" - shared_preferences_ios: - :path: ".symlinks/plugins/shared_preferences_ios/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sqflite: :path: ".symlinks/plugins/sqflite/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: + app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875 audio_service: f509d65da41b9521a61f1c404dd58651f265a567 audio_session: 4f3e461722055d21515cf3261b64c973c062f345 - audioplayers_darwin: 387322cb364026a1782298c982693b1b6aa9fa1b - connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e + device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - file_picker: 817ab1d8cd2da9d2da412a417162deee3500fc95 + file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de + file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 - flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721 + flutter_inappwebview: acd4fc0f012cefd09015000c241137d82f01ba62 + flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 + flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83 + flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef + flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be + flutter_sharing_intent: e35380d0e1501d7111dbb7e46d5ac6339da6da98 + fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a - metadata_god: cebcc48708aca3e9d1ef60c74b23404ff3730d5e + image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 + integration_test: 13825b8a9334a850581300559b8839134b124670 + media_kit_libs_ios_audio: 8f39d96a9c630685dfb844c289bd1d114c486fb3 + media_kit_native_event_loop: 99111eded5acbdc9c2738021ea6550dd36ca8837 + metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7 OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c - package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e - path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 - permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce - ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 - SDWebImage: e5cc87bf736e60f49592f307bdf9e157189298a3 - shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad - sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 - SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 - url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de + package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7 + path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 + SDWebImage: a81bbb3ba4ea5f810f4069c68727cb118467a04a + shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 + sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a + SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f + Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 + url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 -PODFILE CHECKSUM: e9ba2289804955e1370e293b204c6e8651354f4a +PODFILE CHECKSUM: e36c7ad9836dfd8d22934c7680185432a658e28f -COCOAPODS: 1.11.3 +COCOAPODS: 1.14.3 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d9cf2138..dbcdd7a6 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,17 +3,39 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ + 051977801F58E8DBB6712352 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F7E9EBDD27997A73A4D38EE1 /* Pods_Runner.framework */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 17438EB903776D8D0E926C9B /* Pods_nightly.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BAC36FC304DBD4E8A8C00694 /* Pods_nightly.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 46249B26D47C5DB81A4F972E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F7E9EBDD27997A73A4D38EE1 /* Pods_Runner.framework */; }; + 4E86E0C42011EDB42C34AF9A /* Pods_stable.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5F91A319C771EEC978B238A /* Pods_stable.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B536BD902B405DB1009B3CE4 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + B536BD912B405DB1009B3CE4 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + B536BD952B405DB1009B3CE4 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B536BD962B405DB1009B3CE4 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + B536BD972B405DB1009B3CE4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + B536BD982B405DB1009B3CE4 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + B536BDAE2B405FDE009B3CE4 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + B536BDAF2B405FDE009B3CE4 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + B536BDB22B405FDE009B3CE4 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B536BDB32B405FDE009B3CE4 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + B536BDB42B405FDE009B3CE4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + B536BDB52B405FDE009B3CE4 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + B536BDD02B4060B3009B3CE4 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + B536BDD12B4060B3009B3CE4 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + B536BDD42B4060B3009B3CE4 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B536BDD52B4060B3009B3CE4 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + B536BDD62B4060B3009B3CE4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + B536BDD72B4060B3009B3CE4 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + C36A05AD330BBFAED75A62D5 /* Pods_dev.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4238A4985255EC9F93067739 /* Pods_dev.framework */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -27,17 +49,78 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + B536BD992B405DB1009B3CE4 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + B536BDB62B405FDE009B3CE4 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + B536BDD82B4060B3009B3CE4 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 04C104D3779B4D1635D939BF /* Pods-Runner.profile-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile-nightly.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile-nightly.xcconfig"; sourceTree = ""; }; + 0F8FB58820FF492BD3CF9315 /* Pods-nightly.debug-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.debug-stable.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.debug-stable.xcconfig"; sourceTree = ""; }; + 126B91CED32FAD3C40A67A23 /* Pods-dev.debug-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.debug-stable.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.debug-stable.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 171073CFF94F5751BC2B78DD /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 1C9810F8B3FD927ED8C94791 /* Pods-dev.profile-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.profile-nightly.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.profile-nightly.xcconfig"; sourceTree = ""; }; + 21C0B1DEE0F0BFD3F3651F79 /* Pods-stable.debug-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.debug-nightly.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.debug-nightly.xcconfig"; sourceTree = ""; }; + 261A31AC0DBA2D93BD1910D9 /* Pods-nightly.profile-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.profile-nightly.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.profile-nightly.xcconfig"; sourceTree = ""; }; + 285DE2278D380EE2A6647CA9 /* Pods-nightly.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.debug.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.debug.xcconfig"; sourceTree = ""; }; + 29304D1832AA30DE0C33E05C /* Pods-dev.profile-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.profile-stable.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.profile-stable.xcconfig"; sourceTree = ""; }; + 2DA87118BE2AF25875B7C376 /* Pods-stable.release-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.release-stable.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.release-stable.xcconfig"; sourceTree = ""; }; + 2F9AD76AF35FFC693C051CE1 /* Pods-dev.release-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.release-nightly.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.release-nightly.xcconfig"; sourceTree = ""; }; + 39E15EE1745C9266FDB59558 /* Pods-stable.debug-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.debug-dev.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.debug-dev.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 3E262038FF3BDA3B8A7BDAC3 /* Pods-Runner.release-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release-dev.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release-dev.xcconfig"; sourceTree = ""; }; + 3F754C793C1BC0E8B8FFB5B7 /* Pods-stable.profile-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.profile-stable.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.profile-stable.xcconfig"; sourceTree = ""; }; + 4238A4985255EC9F93067739 /* Pods_dev.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_dev.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 46E04A5AA989356A32CD8E66 /* Pods-dev.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.profile.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.profile.xcconfig"; sourceTree = ""; }; + 48E7E801EAE1B520AA5F35DD /* Pods-dev.profile-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.profile-dev.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.profile-dev.xcconfig"; sourceTree = ""; }; + 4BDAF8FFADB62CA017755094 /* Pods-stable.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.profile.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.profile.xcconfig"; sourceTree = ""; }; + 5014E8BD9F7181E528538444 /* Pods-stable.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.release.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.release.xcconfig"; sourceTree = ""; }; + 53AD516AAEB9A1331C99CBAE /* Pods-stable.profile-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.profile-dev.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.profile-dev.xcconfig"; sourceTree = ""; }; + 5A8B64E98ADDA28FB63AA32C /* Pods-Runner.release-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release-nightly.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release-nightly.xcconfig"; sourceTree = ""; }; + 636F4A85470D9E3B4CC8AFB8 /* Pods-nightly.profile-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.profile-dev.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.profile-dev.xcconfig"; sourceTree = ""; }; 66F649AFA6E49EA44F469DA3 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 68BE49B58C0EBB578948D773 /* Pods-nightly.release-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.release-dev.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.release-dev.xcconfig"; sourceTree = ""; }; + 6AE8151F4499707FA23C8223 /* Pods-dev.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.debug.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.debug.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 77EFEBB27B276DD5F6B01B4B /* Pods-Runner.debug-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug-nightly.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug-nightly.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 864AC9150518DFBA85A46A15 /* Pods-stable.release-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.release-dev.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.release-dev.xcconfig"; sourceTree = ""; }; + 869E7B97AE866F2BCA2E5A6A /* Pods-Runner.release-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release-stable.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release-stable.xcconfig"; sourceTree = ""; }; + 89CD409D60E1362C529707A4 /* Pods-nightly.release-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.release-nightly.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.release-nightly.xcconfig"; sourceTree = ""; }; + 8AD587044EF2C6A6FA3059DC /* Pods-stable.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.debug.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.debug.xcconfig"; sourceTree = ""; }; + 8B9DFB8E20C11066C3AB696A /* Pods-dev.debug-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.debug-nightly.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.debug-nightly.xcconfig"; sourceTree = ""; }; + 8CF39CF9464623571B63D15B /* Pods-nightly.release-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.release-stable.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.release-stable.xcconfig"; sourceTree = ""; }; + 9232DBE472C8CEA1101843D9 /* Pods-nightly.profile-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.profile-stable.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.profile-stable.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -45,7 +128,30 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9878519B106548FD75CA15C0 /* Pods-nightly.debug-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.debug-dev.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.debug-dev.xcconfig"; sourceTree = ""; }; + A59B7A01EEC476AF3141B518 /* Pods-Runner.debug-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug-dev.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug-dev.xcconfig"; sourceTree = ""; }; + B38E6C7315D66215AFD8B218 /* Pods-stable.profile-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.profile-nightly.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.profile-nightly.xcconfig"; sourceTree = ""; }; + B536BDA02B405DB1009B3CE4 /* stable.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = stable.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B536BDA12B405DB1009B3CE4 /* stable-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "stable-Info.plist"; path = "/Users/xiaobowen/Documents/GitHub/spotube/ios/stable-Info.plist"; sourceTree = ""; }; + B536BDBF2B405FDE009B3CE4 /* dev.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = dev.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B536BDC02B405FDE009B3CE4 /* dev-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "dev-Info.plist"; path = "/Users/xiaobowen/Documents/GitHub/spotube/ios/dev-Info.plist"; sourceTree = ""; }; + B536BDE42B4060B3009B3CE4 /* nightly.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = nightly.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B536BDE52B4060B3009B3CE4 /* nightly-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "nightly-Info.plist"; path = "/Users/xiaobowen/Documents/GitHub/spotube/ios/nightly-Info.plist"; sourceTree = ""; }; + B5F91A319C771EEC978B238A /* Pods_stable.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_stable.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B95530D9046F7F9BA07D2ADD /* Pods-Runner.profile-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile-stable.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile-stable.xcconfig"; sourceTree = ""; }; + BAC36FC304DBD4E8A8C00694 /* Pods_nightly.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_nightly.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BDE1B62C8A5219CAA5D19583 /* Pods-nightly.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.release.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.release.xcconfig"; sourceTree = ""; }; + C3F494F4E243EAE21CEC5765 /* Pods-Runner.profile-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile-dev.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile-dev.xcconfig"; sourceTree = ""; }; + C63F01302EF00EAECE6BEA7C /* Pods-dev.release-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.release-dev.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.release-dev.xcconfig"; sourceTree = ""; }; + CA0F4EAB0789E68A7C771A07 /* Pods-nightly.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.profile.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.profile.xcconfig"; sourceTree = ""; }; CE8646F5A4BCC46B0416DC84 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + D32BAE0F55672DD7669755B8 /* Pods-Runner.debug-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug-stable.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug-stable.xcconfig"; sourceTree = ""; }; + D9A69004587D01A7C68666CF /* Pods-dev.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.release.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.release.xcconfig"; sourceTree = ""; }; + E0EAB4380EE7C7EA7A350B6F /* Pods-stable.release-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.release-nightly.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.release-nightly.xcconfig"; sourceTree = ""; }; + E81F11471FD7D807286E33D6 /* Pods-dev.debug-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.debug-dev.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.debug-dev.xcconfig"; sourceTree = ""; }; + EB7783C1029CEC13F4B05D36 /* Pods-nightly.debug-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.debug-nightly.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.debug-nightly.xcconfig"; sourceTree = ""; }; + EBBED0A8DE0D0E230CD03613 /* Pods-dev.release-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.release-stable.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.release-stable.xcconfig"; sourceTree = ""; }; + F6F397A82E788E50B186ADC7 /* Pods-stable.debug-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.debug-stable.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.debug-stable.xcconfig"; sourceTree = ""; }; F7E9EBDD27997A73A4D38EE1 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -55,6 +161,31 @@ buildActionMask = 2147483647; files = ( 46249B26D47C5DB81A4F972E /* Pods_Runner.framework in Frameworks */, + 051977801F58E8DBB6712352 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B536BD922B405DB1009B3CE4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4E86E0C42011EDB42C34AF9A /* Pods_stable.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B536BDB02B405FDE009B3CE4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C36A05AD330BBFAED75A62D5 /* Pods_dev.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B536BDD22B4060B3009B3CE4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 17438EB903776D8D0E926C9B /* Pods_nightly.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -65,6 +196,9 @@ isa = PBXGroup; children = ( F7E9EBDD27997A73A4D38EE1 /* Pods_Runner.framework */, + 4238A4985255EC9F93067739 /* Pods_dev.framework */, + BAC36FC304DBD4E8A8C00694 /* Pods_nightly.framework */, + B5F91A319C771EEC978B238A /* Pods_stable.framework */, ); name = Frameworks; sourceTree = ""; @@ -75,8 +209,52 @@ 66F649AFA6E49EA44F469DA3 /* Pods-Runner.debug.xcconfig */, CE8646F5A4BCC46B0416DC84 /* Pods-Runner.release.xcconfig */, 171073CFF94F5751BC2B78DD /* Pods-Runner.profile.xcconfig */, + D32BAE0F55672DD7669755B8 /* Pods-Runner.debug-stable.xcconfig */, + 869E7B97AE866F2BCA2E5A6A /* Pods-Runner.release-stable.xcconfig */, + B95530D9046F7F9BA07D2ADD /* Pods-Runner.profile-stable.xcconfig */, + A59B7A01EEC476AF3141B518 /* Pods-Runner.debug-dev.xcconfig */, + 3E262038FF3BDA3B8A7BDAC3 /* Pods-Runner.release-dev.xcconfig */, + C3F494F4E243EAE21CEC5765 /* Pods-Runner.profile-dev.xcconfig */, + 77EFEBB27B276DD5F6B01B4B /* Pods-Runner.debug-nightly.xcconfig */, + 5A8B64E98ADDA28FB63AA32C /* Pods-Runner.release-nightly.xcconfig */, + 04C104D3779B4D1635D939BF /* Pods-Runner.profile-nightly.xcconfig */, + 6AE8151F4499707FA23C8223 /* Pods-dev.debug.xcconfig */, + 8B9DFB8E20C11066C3AB696A /* Pods-dev.debug-nightly.xcconfig */, + E81F11471FD7D807286E33D6 /* Pods-dev.debug-dev.xcconfig */, + 126B91CED32FAD3C40A67A23 /* Pods-dev.debug-stable.xcconfig */, + D9A69004587D01A7C68666CF /* Pods-dev.release.xcconfig */, + 2F9AD76AF35FFC693C051CE1 /* Pods-dev.release-nightly.xcconfig */, + C63F01302EF00EAECE6BEA7C /* Pods-dev.release-dev.xcconfig */, + EBBED0A8DE0D0E230CD03613 /* Pods-dev.release-stable.xcconfig */, + 46E04A5AA989356A32CD8E66 /* Pods-dev.profile.xcconfig */, + 1C9810F8B3FD927ED8C94791 /* Pods-dev.profile-nightly.xcconfig */, + 48E7E801EAE1B520AA5F35DD /* Pods-dev.profile-dev.xcconfig */, + 29304D1832AA30DE0C33E05C /* Pods-dev.profile-stable.xcconfig */, + 285DE2278D380EE2A6647CA9 /* Pods-nightly.debug.xcconfig */, + EB7783C1029CEC13F4B05D36 /* Pods-nightly.debug-nightly.xcconfig */, + 9878519B106548FD75CA15C0 /* Pods-nightly.debug-dev.xcconfig */, + 0F8FB58820FF492BD3CF9315 /* Pods-nightly.debug-stable.xcconfig */, + BDE1B62C8A5219CAA5D19583 /* Pods-nightly.release.xcconfig */, + 89CD409D60E1362C529707A4 /* Pods-nightly.release-nightly.xcconfig */, + 68BE49B58C0EBB578948D773 /* Pods-nightly.release-dev.xcconfig */, + 8CF39CF9464623571B63D15B /* Pods-nightly.release-stable.xcconfig */, + CA0F4EAB0789E68A7C771A07 /* Pods-nightly.profile.xcconfig */, + 261A31AC0DBA2D93BD1910D9 /* Pods-nightly.profile-nightly.xcconfig */, + 636F4A85470D9E3B4CC8AFB8 /* Pods-nightly.profile-dev.xcconfig */, + 9232DBE472C8CEA1101843D9 /* Pods-nightly.profile-stable.xcconfig */, + 8AD587044EF2C6A6FA3059DC /* Pods-stable.debug.xcconfig */, + 21C0B1DEE0F0BFD3F3651F79 /* Pods-stable.debug-nightly.xcconfig */, + 39E15EE1745C9266FDB59558 /* Pods-stable.debug-dev.xcconfig */, + F6F397A82E788E50B186ADC7 /* Pods-stable.debug-stable.xcconfig */, + 5014E8BD9F7181E528538444 /* Pods-stable.release.xcconfig */, + E0EAB4380EE7C7EA7A350B6F /* Pods-stable.release-nightly.xcconfig */, + 864AC9150518DFBA85A46A15 /* Pods-stable.release-dev.xcconfig */, + 2DA87118BE2AF25875B7C376 /* Pods-stable.release-stable.xcconfig */, + 4BDAF8FFADB62CA017755094 /* Pods-stable.profile.xcconfig */, + B38E6C7315D66215AFD8B218 /* Pods-stable.profile-nightly.xcconfig */, + 53AD516AAEB9A1331C99CBAE /* Pods-stable.profile-dev.xcconfig */, + 3F754C793C1BC0E8B8FFB5B7 /* Pods-stable.profile-stable.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -99,6 +277,9 @@ 97C146EF1CF9000F007C117D /* Products */, 67CBFE209DF24C94A9837AD5 /* Pods */, 0E0B839C4E103F896209E822 /* Frameworks */, + B536BDA12B405DB1009B3CE4 /* stable-Info.plist */, + B536BDC02B405FDE009B3CE4 /* dev-Info.plist */, + B536BDE52B4060B3009B3CE4 /* nightly-Info.plist */, ); sourceTree = ""; }; @@ -106,6 +287,9 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, + B536BDA02B405DB1009B3CE4 /* stable.app */, + B536BDBF2B405FDE009B3CE4 /* dev.app */, + B536BDE42B4060B3009B3CE4 /* nightly.app */, ); name = Products; sourceTree = ""; @@ -150,13 +334,79 @@ productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + B536BD8C2B405DB1009B3CE4 /* stable */ = { + isa = PBXNativeTarget; + buildConfigurationList = B536BD9C2B405DB1009B3CE4 /* Build configuration list for PBXNativeTarget "stable" */; + buildPhases = ( + F0C8BA10A27CA77E18F842E7 /* [CP] Check Pods Manifest.lock */, + B536BD8E2B405DB1009B3CE4 /* Run Script */, + B536BD8F2B405DB1009B3CE4 /* Sources */, + B536BD922B405DB1009B3CE4 /* Frameworks */, + B536BD942B405DB1009B3CE4 /* Resources */, + B536BD992B405DB1009B3CE4 /* Embed Frameworks */, + B536BD9A2B405DB1009B3CE4 /* Thin Binary */, + A6D446F111DE4C4A202BE7F7 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = stable; + productName = Runner; + productReference = B536BDA02B405DB1009B3CE4 /* stable.app */; + productType = "com.apple.product-type.application"; + }; + B536BDAB2B405FDE009B3CE4 /* dev */ = { + isa = PBXNativeTarget; + buildConfigurationList = B536BDB82B405FDE009B3CE4 /* Build configuration list for PBXNativeTarget "dev" */; + buildPhases = ( + 6228176255365EAC646F2745 /* [CP] Check Pods Manifest.lock */, + B536BDAC2B405FDE009B3CE4 /* Run Script */, + B536BDAD2B405FDE009B3CE4 /* Sources */, + B536BDB02B405FDE009B3CE4 /* Frameworks */, + B536BDB12B405FDE009B3CE4 /* Resources */, + B536BDB62B405FDE009B3CE4 /* Embed Frameworks */, + B536BDB72B405FDE009B3CE4 /* Thin Binary */, + 244D41CE80E4BC0FFD63F8C6 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = dev; + productName = Runner; + productReference = B536BDBF2B405FDE009B3CE4 /* dev.app */; + productType = "com.apple.product-type.application"; + }; + B536BDCD2B4060B3009B3CE4 /* nightly */ = { + isa = PBXNativeTarget; + buildConfigurationList = B536BDDA2B4060B3009B3CE4 /* Build configuration list for PBXNativeTarget "nightly" */; + buildPhases = ( + 5CD4405E93760FBD048E36E2 /* [CP] Check Pods Manifest.lock */, + B536BDCE2B4060B3009B3CE4 /* Run Script */, + B536BDCF2B4060B3009B3CE4 /* Sources */, + B536BDD22B4060B3009B3CE4 /* Frameworks */, + B536BDD32B4060B3009B3CE4 /* Resources */, + B536BDD82B4060B3009B3CE4 /* Embed Frameworks */, + B536BDD92B4060B3009B3CE4 /* Thin Binary */, + D566C841A84D807A607F6DE5 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = nightly; + productName = Runner; + productReference = B536BDE42B4060B3009B3CE4 /* nightly.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1430; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -179,6 +429,9 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, + B536BD8C2B405DB1009B3CE4 /* stable */, + B536BDAB2B405FDE009B3CE4 /* dev */, + B536BDCD2B4060B3009B3CE4 /* nightly */, ); }; /* End PBXProject section */ @@ -195,9 +448,59 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B536BD942B405DB1009B3CE4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B536BD952B405DB1009B3CE4 /* LaunchScreen.storyboard in Resources */, + B536BD962B405DB1009B3CE4 /* AppFrameworkInfo.plist in Resources */, + B536BD972B405DB1009B3CE4 /* Assets.xcassets in Resources */, + B536BD982B405DB1009B3CE4 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B536BDB12B405FDE009B3CE4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B536BDB22B405FDE009B3CE4 /* LaunchScreen.storyboard in Resources */, + B536BDB32B405FDE009B3CE4 /* AppFrameworkInfo.plist in Resources */, + B536BDB42B405FDE009B3CE4 /* Assets.xcassets in Resources */, + B536BDB52B405FDE009B3CE4 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B536BDD32B4060B3009B3CE4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B536BDD42B4060B3009B3CE4 /* LaunchScreen.storyboard in Resources */, + B536BDD52B4060B3009B3CE4 /* AppFrameworkInfo.plist in Resources */, + B536BDD62B4060B3009B3CE4 /* Assets.xcassets in Resources */, + B536BDD72B4060B3009B3CE4 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 244D41CE80E4BC0FFD63F8C6 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 2AF6C7D149EE8481703D5255 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -222,10 +525,12 @@ }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( @@ -234,6 +539,50 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 5CD4405E93760FBD048E36E2 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-nightly-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 6228176255365EAC646F2745 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-dev-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 6E9FEF583EA597C8B76255B2 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -253,6 +602,7 @@ }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -265,6 +615,155 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + A6D446F111DE4C4A202BE7F7 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + B536BD8E2B405DB1009B3CE4 /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + B536BD9A2B405DB1009B3CE4 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + B536BDAC2B405FDE009B3CE4 /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + B536BDB72B405FDE009B3CE4 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + B536BDCE2B4060B3009B3CE4 /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + B536BDD92B4060B3009B3CE4 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + D566C841A84D807A607F6DE5 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + F0C8BA10A27CA77E18F842E7 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-stable-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -277,6 +776,33 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B536BD8F2B405DB1009B3CE4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B536BD902B405DB1009B3CE4 /* AppDelegate.swift in Sources */, + B536BD912B405DB1009B3CE4 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B536BDAD2B405FDE009B3CE4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B536BDAE2B405FDE009B3CE4 /* AppDelegate.swift in Sources */, + B536BDAF2B405FDE009B3CE4 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B536BDCF2B4060B3009B3CE4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B536BDD02B4060B3009B3CE4 /* AppDelegate.swift in Sources */, + B536BDD12B4060B3009B3CE4 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ @@ -356,6 +882,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -484,6 +1011,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -506,6 +1034,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -520,6 +1049,1482 @@ }; name = Release; }; + B536BD9D2B405DB1009B3CE4 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "stable-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + B536BD9E2B405DB1009B3CE4 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "stable-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + B536BD9F2B405DB1009B3CE4 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "stable-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + B536BDA22B405E06009B3CE4 /* Debug-stable */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Debug-stable"; + }; + B536BDA32B405E06009B3CE4 /* Debug-stable */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Debug-stable"; + }; + B536BDA42B405E06009B3CE4 /* Debug-stable */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "stable-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Debug-stable"; + }; + B536BDA52B405E19009B3CE4 /* Release-stable */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = "Release-stable"; + }; + B536BDA62B405E19009B3CE4 /* Release-stable */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Release-stable"; + }; + B536BDA72B405E19009B3CE4 /* Release-stable */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "stable-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Release-stable"; + }; + B536BDA82B405E1F009B3CE4 /* Profile-stable */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = "Profile-stable"; + }; + B536BDA92B405E1F009B3CE4 /* Profile-stable */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Profile-stable"; + }; + B536BDAA2B405E1F009B3CE4 /* Profile-stable */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "stable-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Profile-stable"; + }; + B536BDB92B405FDE009B3CE4 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "dev-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + B536BDBA2B405FDE009B3CE4 /* Debug-stable */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "dev-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Debug-stable"; + }; + B536BDBB2B405FDE009B3CE4 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "dev-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + B536BDBC2B405FDE009B3CE4 /* Release-stable */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "dev-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Release-stable"; + }; + B536BDBD2B405FDE009B3CE4 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "dev-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + B536BDBE2B405FDE009B3CE4 /* Profile-stable */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "dev-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Profile-stable"; + }; + B536BDC12B406014009B3CE4 /* Debug-dev */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Debug-dev"; + }; + B536BDC22B406014009B3CE4 /* Debug-dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Debug-dev"; + }; + B536BDC32B406014009B3CE4 /* Debug-dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "stable-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Debug-dev"; + }; + B536BDC42B406014009B3CE4 /* Debug-dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "dev-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Debug-dev"; + }; + B536BDC52B40601C009B3CE4 /* Release-dev */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = "Release-dev"; + }; + B536BDC62B40601C009B3CE4 /* Release-dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Release-dev"; + }; + B536BDC72B40601C009B3CE4 /* Release-dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "stable-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Release-dev"; + }; + B536BDC82B40601C009B3CE4 /* Release-dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "dev-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Release-dev"; + }; + B536BDC92B406021009B3CE4 /* Profile-dev */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = "Profile-dev"; + }; + B536BDCA2B406021009B3CE4 /* Profile-dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Profile-dev"; + }; + B536BDCB2B406021009B3CE4 /* Profile-dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "stable-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Profile-dev"; + }; + B536BDCC2B406021009B3CE4 /* Profile-dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "dev-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Profile-dev"; + }; + B536BDDB2B4060B3009B3CE4 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "nightly-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + B536BDDC2B4060B3009B3CE4 /* Debug-dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "nightly-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Debug-dev"; + }; + B536BDDD2B4060B3009B3CE4 /* Debug-stable */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "nightly-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Debug-stable"; + }; + B536BDDE2B4060B3009B3CE4 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "nightly-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + B536BDDF2B4060B3009B3CE4 /* Release-dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "nightly-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Release-dev"; + }; + B536BDE02B4060B3009B3CE4 /* Release-stable */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "nightly-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Release-stable"; + }; + B536BDE12B4060B3009B3CE4 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "nightly-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + B536BDE22B4060B3009B3CE4 /* Profile-dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "nightly-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Profile-dev"; + }; + B536BDE32B4060B3009B3CE4 /* Profile-stable */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "nightly-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Profile-stable"; + }; + B536BDE62B4060FE009B3CE4 /* Debug-nightly */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Debug-nightly"; + }; + B536BDE72B4060FE009B3CE4 /* Debug-nightly */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Debug-nightly"; + }; + B536BDE82B4060FE009B3CE4 /* Debug-nightly */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "stable-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Debug-nightly"; + }; + B536BDE92B4060FE009B3CE4 /* Debug-nightly */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "dev-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Debug-nightly"; + }; + B536BDEA2B4060FE009B3CE4 /* Debug-nightly */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "nightly-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Debug-nightly"; + }; + B536BDEB2B406105009B3CE4 /* Release-nightly */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = "Release-nightly"; + }; + B536BDEC2B406105009B3CE4 /* Release-nightly */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Release-nightly"; + }; + B536BDED2B406105009B3CE4 /* Release-nightly */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "stable-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Release-nightly"; + }; + B536BDEE2B406105009B3CE4 /* Release-nightly */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "dev-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Release-nightly"; + }; + B536BDEF2B406105009B3CE4 /* Release-nightly */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "nightly-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Release-nightly"; + }; + B536BDF02B40610B009B3CE4 /* Profile-nightly */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = "Profile-nightly"; + }; + B536BDF12B40610B009B3CE4 /* Profile-nightly */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Profile-nightly"; + }; + B536BDF22B40610B009B3CE4 /* Profile-nightly */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "stable-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.stable; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Profile-nightly"; + }; + B536BDF32B40610B009B3CE4 /* Profile-nightly */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "dev-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.dev; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Profile-nightly"; + }; + B536BDF42B40610B009B3CE4 /* Profile-nightly */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "nightly-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.nightly; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Profile-nightly"; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -527,8 +2532,17 @@ isa = XCConfigurationList; buildConfigurations = ( 97C147031CF9000F007C117D /* Debug */, + B536BDE62B4060FE009B3CE4 /* Debug-nightly */, + B536BDC12B406014009B3CE4 /* Debug-dev */, + B536BDA22B405E06009B3CE4 /* Debug-stable */, 97C147041CF9000F007C117D /* Release */, + B536BDEB2B406105009B3CE4 /* Release-nightly */, + B536BDC52B40601C009B3CE4 /* Release-dev */, + B536BDA52B405E19009B3CE4 /* Release-stable */, 249021D3217E4FDB00AE95B9 /* Profile */, + B536BDF02B40610B009B3CE4 /* Profile-nightly */, + B536BDC92B406021009B3CE4 /* Profile-dev */, + B536BDA82B405E1F009B3CE4 /* Profile-stable */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -537,8 +2551,74 @@ isa = XCConfigurationList; buildConfigurations = ( 97C147061CF9000F007C117D /* Debug */, + B536BDE72B4060FE009B3CE4 /* Debug-nightly */, + B536BDC22B406014009B3CE4 /* Debug-dev */, + B536BDA32B405E06009B3CE4 /* Debug-stable */, 97C147071CF9000F007C117D /* Release */, + B536BDEC2B406105009B3CE4 /* Release-nightly */, + B536BDC62B40601C009B3CE4 /* Release-dev */, + B536BDA62B405E19009B3CE4 /* Release-stable */, 249021D4217E4FDB00AE95B9 /* Profile */, + B536BDF12B40610B009B3CE4 /* Profile-nightly */, + B536BDCA2B406021009B3CE4 /* Profile-dev */, + B536BDA92B405E1F009B3CE4 /* Profile-stable */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B536BD9C2B405DB1009B3CE4 /* Build configuration list for PBXNativeTarget "stable" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B536BD9D2B405DB1009B3CE4 /* Debug */, + B536BDE82B4060FE009B3CE4 /* Debug-nightly */, + B536BDC32B406014009B3CE4 /* Debug-dev */, + B536BDA42B405E06009B3CE4 /* Debug-stable */, + B536BD9E2B405DB1009B3CE4 /* Release */, + B536BDED2B406105009B3CE4 /* Release-nightly */, + B536BDC72B40601C009B3CE4 /* Release-dev */, + B536BDA72B405E19009B3CE4 /* Release-stable */, + B536BD9F2B405DB1009B3CE4 /* Profile */, + B536BDF22B40610B009B3CE4 /* Profile-nightly */, + B536BDCB2B406021009B3CE4 /* Profile-dev */, + B536BDAA2B405E1F009B3CE4 /* Profile-stable */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B536BDB82B405FDE009B3CE4 /* Build configuration list for PBXNativeTarget "dev" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B536BDB92B405FDE009B3CE4 /* Debug */, + B536BDE92B4060FE009B3CE4 /* Debug-nightly */, + B536BDC42B406014009B3CE4 /* Debug-dev */, + B536BDBA2B405FDE009B3CE4 /* Debug-stable */, + B536BDBB2B405FDE009B3CE4 /* Release */, + B536BDEE2B406105009B3CE4 /* Release-nightly */, + B536BDC82B40601C009B3CE4 /* Release-dev */, + B536BDBC2B405FDE009B3CE4 /* Release-stable */, + B536BDBD2B405FDE009B3CE4 /* Profile */, + B536BDF32B40610B009B3CE4 /* Profile-nightly */, + B536BDCC2B406021009B3CE4 /* Profile-dev */, + B536BDBE2B405FDE009B3CE4 /* Profile-stable */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B536BDDA2B4060B3009B3CE4 /* Build configuration list for PBXNativeTarget "nightly" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B536BDDB2B4060B3009B3CE4 /* Debug */, + B536BDEA2B4060FE009B3CE4 /* Debug-nightly */, + B536BDDC2B4060B3009B3CE4 /* Debug-dev */, + B536BDDD2B4060B3009B3CE4 /* Debug-stable */, + B536BDDE2B4060B3009B3CE4 /* Release */, + B536BDEF2B406105009B3CE4 /* Release-nightly */, + B536BDDF2B4060B3009B3CE4 /* Release-dev */, + B536BDE02B4060B3009B3CE4 /* Release-stable */, + B536BDE12B4060B3009B3CE4 /* Profile */, + B536BDF42B40610B009B3CE4 /* Profile-nightly */, + B536BDE22B4060B3009B3CE4 /* Profile-dev */, + B536BDE32B4060B3009B3CE4 /* Profile-stable */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c87d15a3..a6b826db 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/nightly.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/nightly.xcscheme new file mode 100644 index 00000000..7ec18a73 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/nightly.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/stable.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/stable.xcscheme new file mode 100644 index 00000000..ddc19e2e --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/stable.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-1024x1024@1x.png new file mode 100644 index 00000000..dbc4596b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@1x.png new file mode 100644 index 00000000..4836771d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@2x.png new file mode 100644 index 00000000..90954ce9 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@3x.png new file mode 100644 index 00000000..9c0ebd5f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@1x.png new file mode 100644 index 00000000..94cd79be Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@2x.png new file mode 100644 index 00000000..ff70cab7 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@3x.png new file mode 100644 index 00000000..6cdda1b6 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@1x.png new file mode 100644 index 00000000..90954ce9 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@2x.png new file mode 100644 index 00000000..5184f84f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@3x.png new file mode 100644 index 00000000..57e21a75 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-50x50@1x.png new file mode 100644 index 00000000..93e157b6 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-50x50@2x.png new file mode 100644 index 00000000..d175beb2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-57x57@1x.png new file mode 100644 index 00000000..6d634c87 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-57x57@2x.png new file mode 100644 index 00000000..22da4950 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-60x60@2x.png new file mode 100644 index 00000000..57e21a75 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-60x60@3x.png new file mode 100644 index 00000000..3cfd01c2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-72x72@1x.png new file mode 100644 index 00000000..a826bb73 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-72x72@2x.png new file mode 100644 index 00000000..3a8a7832 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-76x76@1x.png new file mode 100644 index 00000000..f233322b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-76x76@2x.png new file mode 100644 index 00000000..2f5b082a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-83.5x83.5@2x.png new file mode 100644 index 00000000..e4ecc19a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/AppIcon-nightly-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/Contents.json new file mode 100644 index 00000000..e8947587 --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"20x20","idiom":"iphone","filename":"AppIcon-nightly-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"AppIcon-nightly-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-nightly-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-nightly-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-nightly-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"AppIcon-nightly-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"AppIcon-nightly-40x40@3x.png","scale":"3x"},{"size":"50x50","idiom":"ipad","filename":"AppIcon-nightly-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"AppIcon-nightly-50x50@2x.png","scale":"2x"},{"size":"57x57","idiom":"iphone","filename":"AppIcon-nightly-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"AppIcon-nightly-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"AppIcon-nightly-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"AppIcon-nightly-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"AppIcon-nightly-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"AppIcon-nightly-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"AppIcon-nightly-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"AppIcon-nightly-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"AppIcon-nightly-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"AppIcon-nightly-40x40@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"AppIcon-nightly-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"AppIcon-nightly-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"AppIcon-nightly-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"AppIcon-nightly-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"AppIcon-nightly-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"AppIcon-nightly-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-1024x1024@1x.png new file mode 100644 index 00000000..aaf8f69b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png new file mode 100644 index 00000000..2ac9068e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png new file mode 100644 index 00000000..d0a01485 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png new file mode 100644 index 00000000..693f7baa Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png new file mode 100644 index 00000000..033019fc Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png new file mode 100644 index 00000000..809668c3 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png new file mode 100644 index 00000000..eaa1de13 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png new file mode 100644 index 00000000..d0a01485 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png new file mode 100644 index 00000000..ffb602b3 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png new file mode 100644 index 00000000..77d37d5d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-50x50@1x.png new file mode 100644 index 00000000..a26cd088 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-50x50@2x.png new file mode 100644 index 00000000..8d860f15 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-57x57@1x.png new file mode 100644 index 00000000..6a480baf Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-57x57@2x.png new file mode 100644 index 00000000..d8b55615 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png new file mode 100644 index 00000000..77d37d5d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png new file mode 100644 index 00000000..2b587235 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-72x72@1x.png new file mode 100644 index 00000000..efac11ba Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-72x72@2x.png new file mode 100644 index 00000000..a73fe33c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png new file mode 100644 index 00000000..e8ac9032 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png new file mode 100644 index 00000000..e1859a0d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png new file mode 100644 index 00000000..f863a923 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index d36b1fab..05843b52 100644 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,122 +1 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} +{"images":[{"size":"20x20","idiom":"iphone","filename":"AppIcon-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"AppIcon-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"AppIcon-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"AppIcon-40x40@3x.png","scale":"3x"},{"size":"50x50","idiom":"ipad","filename":"AppIcon-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"AppIcon-50x50@2x.png","scale":"2x"},{"size":"57x57","idiom":"iphone","filename":"AppIcon-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"AppIcon-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"AppIcon-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"AppIcon-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"AppIcon-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"AppIcon-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"AppIcon-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"AppIcon-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"AppIcon-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"AppIcon-40x40@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"AppIcon-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"AppIcon-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"AppIcon-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"AppIcon-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"AppIcon-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"AppIcon-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index dc9ada47..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 28c6bf03..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 2ccbfd96..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index f091b6b0..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 4cde1211..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index d0ef06e7..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index dcdc2306..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 2ccbfd96..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index c8f9ed8f..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index a6d6b860..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index a6d6b860..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index 75b2d164..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index c4df70d3..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index 6a84f41e..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index d0e1f585..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage.png b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage.png new file mode 100644 index 00000000..80579983 Binary files /dev/null and b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage.png differ diff --git a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@2x.png b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@2x.png new file mode 100644 index 00000000..0bcf138d Binary files /dev/null and b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@3x.png b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@3x.png new file mode 100644 index 00000000..c7d01776 Binary files /dev/null and b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/Contents.json b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/Contents.json new file mode 100644 index 00000000..12712275 --- /dev/null +++ b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "BrandingImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "BrandingImage@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "BrandingImage@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/Contents.json b/ios/Runner/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ios/Runner/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/Contents.json new file mode 100644 index 00000000..9f447e1b --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "background.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/background.png b/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/background.png new file mode 100644 index 00000000..203fc77a Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/background.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/Contents.json new file mode 100644 index 00000000..00cabce8 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "LaunchImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "LaunchImage@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "LaunchImage@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage.png new file mode 100644 index 00000000..86d3fe74 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@2x.png new file mode 100644 index 00000000..dbb0ea02 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@3x.png new file mode 100644 index 00000000..12eb5531 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Base.lproj/LaunchScreenNightly.storyboard b/ios/Runner/Base.lproj/LaunchScreenNightly.storyboard new file mode 100644 index 00000000..6869214f --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreenNightly.storyboard @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 59fc0f08..5ba9991f 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Sptube + Spotube CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -54,5 +54,13 @@ UIStatusBarHidden - + NSPhotoLibraryUsageDescription + This app require access to the photo library + NSCameraUsageDescription + This app require access to the device camera + NSMicrophoneUsageDescription + This app does not require access to the device microphone + UIApplicationSupportsIndirectInputEvents + + diff --git a/ios/build/.last_build_id b/ios/build/.last_build_id new file mode 100644 index 00000000..ee73fd53 --- /dev/null +++ b/ios/build/.last_build_id @@ -0,0 +1 @@ +6f5ed64a4065df2d43bfb5b18863018c \ No newline at end of file diff --git a/ios/dev-Info.plist b/ios/dev-Info.plist new file mode 100644 index 00000000..022d5419 --- /dev/null +++ b/ios/dev-Info.plist @@ -0,0 +1,66 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Spotube Dev + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + spotube + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsForMedia + + + NSCameraUsageDescription + This app require access to the device camera + NSMicrophoneUsageDescription + This app does not require access to the device microphone + NSPhotoLibraryUsageDescription + This app require access to the photo library + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIStatusBarHidden + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/nightly-Info.plist b/ios/nightly-Info.plist new file mode 100644 index 00000000..5ba9991f --- /dev/null +++ b/ios/nightly-Info.plist @@ -0,0 +1,66 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Spotube + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + spotube + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsForMedia + + + CADisableMinimumFrameDurationOnPhone + + UIStatusBarHidden + + NSPhotoLibraryUsageDescription + This app require access to the photo library + NSCameraUsageDescription + This app require access to the device camera + NSMicrophoneUsageDescription + This app does not require access to the device microphone + UIApplicationSupportsIndirectInputEvents + + + diff --git a/ios/stable-Info.plist b/ios/stable-Info.plist new file mode 100644 index 00000000..5ba9991f --- /dev/null +++ b/ios/stable-Info.plist @@ -0,0 +1,66 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Spotube + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + spotube + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsForMedia + + + CADisableMinimumFrameDurationOnPhone + + UIStatusBarHidden + + NSPhotoLibraryUsageDescription + This app require access to the photo library + NSCameraUsageDescription + This app require access to the device camera + NSMicrophoneUsageDescription + This app does not require access to the device microphone + UIApplicationSupportsIndirectInputEvents + + + diff --git a/lib/collections/assets.gen.dart b/lib/collections/assets.gen.dart index f720ac9f..2587800e 100644 --- a/lib/collections/assets.gen.dart +++ b/lib/collections/assets.gen.dart @@ -34,8 +34,13 @@ class Assets { AssetGenImage('assets/bengali-patterns-bg.jpg'); static const AssetGenImage branding = AssetGenImage('assets/branding.png'); static const AssetGenImage emptyBox = AssetGenImage('assets/empty_box.png'); + static const AssetGenImage jiosaavn = AssetGenImage('assets/jiosaavn.png'); + static const AssetGenImage likedTracks = + AssetGenImage('assets/liked-tracks.jpg'); static const AssetGenImage placeholder = AssetGenImage('assets/placeholder.png'); + static const AssetGenImage spotubeHeroBanner = + AssetGenImage('assets/spotube-hero-banner.png'); static const AssetGenImage spotubeLogoForeground = AssetGenImage('assets/spotube-logo-foreground.jpg'); static const String spotubeLogoIco = 'assets/spotube-logo.ico'; @@ -44,8 +49,21 @@ class Assets { static const String spotubeLogoSvg = 'assets/spotube-logo.svg'; static const AssetGenImage spotubeLogoAndroid12 = AssetGenImage('assets/spotube-logo_android12.png'); + static const AssetGenImage spotubeNightlyLogoForeground = + AssetGenImage('assets/spotube-nightly-logo-foreground.jpg'); + static const AssetGenImage spotubeNightlyLogoPng = + AssetGenImage('assets/spotube-nightly-logo.png'); + static const String spotubeNightlyLogoSvg = 'assets/spotube-nightly-logo.svg'; + static const AssetGenImage spotubeNightlyLogoAndroid12 = + AssetGenImage('assets/spotube-nightly-logo_android12.png'); static const AssetGenImage spotubeScreenshot = AssetGenImage('assets/spotube-screenshot.png'); + static const AssetGenImage spotubeTallCapsule = + AssetGenImage('assets/spotube-tall-capsule.png'); + static const AssetGenImage spotubeWideCapsuleLarge = + AssetGenImage('assets/spotube-wide-capsule-large.png'); + static const AssetGenImage spotubeWideCapsuleSmall = + AssetGenImage('assets/spotube-wide-capsule-small.png'); static const AssetGenImage spotubeBanner = AssetGenImage('assets/spotube_banner.png'); static const AssetGenImage success = AssetGenImage('assets/success.png'); @@ -59,13 +77,23 @@ class Assets { bengaliPatternsBg, branding, emptyBox, + jiosaavn, + likedTracks, placeholder, + spotubeHeroBanner, spotubeLogoForeground, spotubeLogoIco, spotubeLogoPng, spotubeLogoSvg, spotubeLogoAndroid12, + spotubeNightlyLogoForeground, + spotubeNightlyLogoPng, + spotubeNightlyLogoSvg, + spotubeNightlyLogoAndroid12, spotubeScreenshot, + spotubeTallCapsule, + spotubeWideCapsuleLarge, + spotubeWideCapsuleSmall, spotubeBanner, success, userPlaceholder @@ -130,7 +158,16 @@ class AssetGenImage { ); } - ImageProvider provider() => AssetImage(_assetName); + ImageProvider provider({ + AssetBundle? bundle, + String? package, + }) { + return AssetImage( + _assetName, + bundle: bundle, + package: package, + ); + } String get path => _assetName; diff --git a/lib/collections/env.dart b/lib/collections/env.dart index c279a0f9..50fe1e6a 100644 --- a/lib/collections/env.dart +++ b/lib/collections/env.dart @@ -1,17 +1,20 @@ import 'package:envied/envied.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; part 'env.g.dart'; @Envied(obfuscate: true, requireEnvFile: true, path: ".env") abstract class Env { - @EnviedField(varName: 'SUPABASE_URL') - static final supabaseUrl = _Env.supabaseUrl; - - @EnviedField(varName: 'SUPABASE_API_KEY') - static final supabaseAnonKey = _Env.supabaseAnonKey; - @EnviedField(varName: 'SPOTIFY_SECRETS') - static final spotifySecrets = _Env.spotifySecrets.split(',').map((e) { + static final String rawSpotifySecrets = _Env.rawSpotifySecrets; + + @EnviedField(varName: 'LASTFM_API_KEY') + static final String lastFmApiKey = _Env.lastFmApiKey; + + @EnviedField(varName: 'LASTFM_API_SECRET') + static final String lastFmApiSecret = _Env.lastFmApiSecret; + + static final spotifySecrets = rawSpotifySecrets.split(',').map((e) { final secrets = e.trim().split(":").map((e) => e.trim()); return { "clientId": secrets.first, @@ -20,7 +23,10 @@ abstract class Env { }).toList(); @EnviedField(varName: 'ENABLE_UPDATE_CHECK', defaultValue: "1") - static final _enableUpdateChecker = _Env._enableUpdateChecker; + static final String _enableUpdateChecker = _Env._enableUpdateChecker; - static bool get enableUpdateChecker => _enableUpdateChecker == "1"; + static bool get enableUpdateChecker => + DesktopTools.platform.isFlatpak || _enableUpdateChecker == "1"; + + static String discordAppId = "1176718791388975124"; } diff --git a/lib/collections/fake.dart b/lib/collections/fake.dart new file mode 100644 index 00000000..8f5f9e8b --- /dev/null +++ b/lib/collections/fake.dart @@ -0,0 +1,199 @@ +import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/spotify_friends.dart'; + +abstract class FakeData { + static final Image image = Image() + ..height = 1 + ..width = 1 + ..url = "url"; + + static final Followers followers = Followers() + ..href = "text" + ..total = 1; + + static final Artist artist = Artist() + ..id = "1" + ..name = "Wow artist Good!" + ..images = [image] + ..popularity = 1 + ..type = "type" + ..uri = "uri" + ..externalUrls = externalUrls + ..genres = ["genre"] + ..href = "text" + ..followers = followers; + + static final externalIds = ExternalIds() + ..isrc = "text" + ..ean = "text" + ..upc = "text"; + + static final externalUrls = ExternalUrls()..spotify = "text"; + + static final Album album = Album() + ..id = "1" + ..genres = ["genre"] + ..label = "label" + ..popularity = 1 + ..albumType = AlbumType.album + ..artists = [artist] + ..availableMarkets = [Market.BD] + ..externalUrls = externalUrls + ..href = "text" + ..images = [image] + ..name = "Another good album" + ..releaseDate = "2021-01-01" + ..releaseDatePrecision = DatePrecision.day + ..tracks = [track] + ..type = "type" + ..uri = "uri" + ..externalIds = externalIds + ..copyrights = [ + Copyright() + ..type = CopyrightType.C + ..text = "text", + ]; + + static final ArtistSimple artistSimple = ArtistSimple() + ..id = "1" + ..name = "What an artist" + ..type = "type" + ..uri = "uri" + ..externalUrls = externalUrls; + + static final AlbumSimple albumSimple = AlbumSimple() + ..id = "1" + ..albumType = AlbumType.album + ..artists = [artistSimple] + ..availableMarkets = [Market.BD] + ..externalUrls = externalUrls + ..href = "text" + ..images = [image] + ..name = "A good album" + ..releaseDate = "2021-01-01" + ..releaseDatePrecision = DatePrecision.day + ..type = "type" + ..uri = "uri"; + + static final Track track = Track() + ..id = "1" + ..artists = [artist, artist, artist] + ..album = albumSimple + ..availableMarkets = [Market.BD] + ..discNumber = 1 + ..durationMs = 50000 + ..explicit = false + ..externalUrls = externalUrls + ..href = "text" + ..name = "A Track Name" + ..popularity = 1 + ..previewUrl = "url" + ..trackNumber = 1 + ..type = "type" + ..uri = "uri" + ..isPlayable = true + ..explicit = false + ..linkedFrom = trackLink; + + static final TrackLink trackLink = TrackLink() + ..id = "1" + ..type = "type" + ..uri = "uri" + ..externalUrls = {"spotify": "text"} + ..href = "text"; + + static final Paging paging = Paging() + ..href = "text" + ..itemsNative = [track.toJson()] + ..limit = 1 + ..next = "text" + ..offset = 1 + ..previous = "text" + ..total = 1; + + static final User user = User() + ..id = "1" + ..displayName = "Your Name" + ..birthdate = "2021-01-01" + ..country = Market.BD + ..email = "test@email.com" + ..followers = followers + ..href = "text" + ..images = [image] + ..type = "type" + ..uri = "uri"; + + static final TracksLink tracksLink = TracksLink() + ..href = "text" + ..total = 1; + + static final Playlist playlist = Playlist() + ..id = "1" + ..collaborative = false + ..description = "A very good playlist description" + ..externalUrls = externalUrls + ..followers = followers + ..href = "text" + ..images = [image] + ..name = "A good playlist" + ..owner = user + ..public = true + ..snapshotId = "text" + ..tracks = paging + ..tracksLink = tracksLink + ..type = "type" + ..uri = "uri"; + + static final PlaylistSimple playlistSimple = PlaylistSimple() + ..id = "1" + ..collaborative = false + ..externalUrls = externalUrls + ..href = "text" + ..images = [image] + ..name = "A good playlist" + ..owner = user + ..public = true + ..snapshotId = "text" + ..tracksLink = tracksLink + ..type = "type" + ..description = "A very good playlist description" + ..uri = "uri"; + + static final Category category = Category() + ..href = "text" + ..icons = [image] + ..id = "1" + ..name = "category"; + + static final friends = SpotifyFriends( + friends: [ + for (var i = 0; i < 3; i++) + SpotifyFriendActivity( + user: const SpotifyFriend( + name: "name", + imageUrl: "imageUrl", + uri: "uri", + ), + track: SpotifyActivityTrack( + name: "name", + artist: const SpotifyActivityArtist( + name: "name", + uri: "uri", + ), + album: const SpotifyActivityAlbum( + name: "name", + uri: "uri", + ), + context: SpotifyActivityContext( + name: "name", + index: i, + uri: "uri", + ), + imageUrl: "imageUrl", + uri: "uri", + ), + ), + ], + ); +} diff --git a/lib/collections/gradients.dart b/lib/collections/gradients.dart new file mode 100644 index 00000000..e861dde7 --- /dev/null +++ b/lib/collections/gradients.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; + +const gradients = [ + LinearGradient(colors: [ + Color.fromRGBO(123, 102, 255, 1), + Color.fromRGBO(95, 189, 255, 1), + Color.fromRGBO(150, 239, 255, 1), + Color.fromRGBO(197, 255, 248, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(245, 204, 160, 1), + Color.fromRGBO(228, 143, 69, 1), + Color.fromRGBO(153, 77, 28, 1), + Color.fromRGBO(107, 36, 12, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(243, 243, 243, 1), + Color.fromRGBO(197, 232, 152, 1), + Color.fromRGBO(41, 173, 178, 1), + Color.fromRGBO(7, 102, 173, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(240, 89, 65, 1), + Color.fromRGBO(190, 49, 68, 1), + Color.fromRGBO(135, 35, 65, 1), + Color.fromRGBO(34, 9, 44, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(119, 107, 93, 1), + Color.fromRGBO(176, 166, 149, 1), + Color.fromRGBO(235, 227, 213, 1), + Color.fromRGBO(243, 238, 234, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(208, 162, 247, 1), + Color.fromRGBO(220, 191, 255, 1), + Color.fromRGBO(229, 212, 255, 1), + Color.fromRGBO(241, 234, 255, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(221, 242, 253, 1), + Color.fromRGBO(155, 190, 200, 1), + Color.fromRGBO(66, 125, 157, 1), + Color.fromRGBO(22, 72, 99, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(119, 67, 219, 1), + Color.fromRGBO(195, 172, 208, 1), + Color.fromRGBO(247, 239, 229, 1), + Color.fromRGBO(255, 251, 245, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(194, 217, 255, 1), + Color.fromRGBO(142, 143, 250, 1), + Color.fromRGBO(119, 82, 254, 1), + Color.fromRGBO(25, 4, 130, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(104, 126, 255, 1), + Color.fromRGBO(128, 179, 255, 1), + Color.fromRGBO(152, 228, 255, 1), + Color.fromRGBO(182, 255, 250, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(176, 87, 141, 1), + Color.fromRGBO(217, 136, 185, 1), + Color.fromRGBO(250, 203, 234, 1), + Color.fromRGBO(255, 228, 214, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(190, 255, 247, 1), + Color.fromRGBO(166, 246, 255, 1), + Color.fromRGBO(158, 221, 255, 1), + Color.fromRGBO(100, 153, 233, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(245, 252, 205, 1), + Color.fromRGBO(120, 214, 198, 1), + Color.fromRGBO(65, 145, 151, 1), + Color.fromRGBO(18, 72, 107, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(229, 207, 247, 1), + Color.fromRGBO(157, 118, 193, 1), + Color.fromRGBO(113, 58, 190, 1), + Color.fromRGBO(91, 8, 136, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(249, 222, 201, 1), + Color.fromRGBO(247, 140, 162, 1), + Color.fromRGBO(216, 0, 50, 1), + Color.fromRGBO(61, 12, 17, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(242, 247, 161, 1), + Color.fromRGBO(53, 162, 159, 1), + Color.fromRGBO(8, 131, 149, 1), + Color.fromRGBO(7, 25, 82, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(243, 159, 90, 1), + Color.fromRGBO(174, 68, 90, 1), + Color.fromRGBO(102, 37, 73, 1), + Color.fromRGBO(69, 25, 82, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(255, 200, 200, 1), + Color.fromRGBO(255, 155, 130, 1), + Color.fromRGBO(255, 63, 164, 1), + Color.fromRGBO(87, 55, 93, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(238, 238, 238, 1), + Color.fromRGBO(100, 204, 197, 1), + Color.fromRGBO(23, 107, 135, 1), + Color.fromRGBO(5, 59, 80, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(198, 61, 47, 1), + Color.fromRGBO(226, 94, 62, 1), + Color.fromRGBO(255, 155, 80, 1), + Color.fromRGBO(255, 187, 92, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(236, 83, 176, 1), + Color.fromRGBO(157, 68, 192, 1), + Color.fromRGBO(77, 45, 183, 1), + Color.fromRGBO(14, 33, 160, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(242, 236, 190, 1), + Color.fromRGBO(226, 199, 153, 1), + Color.fromRGBO(192, 130, 97, 1), + Color.fromRGBO(154, 59, 59, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(255, 253, 140, 1), + Color.fromRGBO(151, 255, 244, 1), + Color.fromRGBO(112, 145, 245, 1), + Color.fromRGBO(121, 63, 223, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(67, 83, 52, 1), + Color.fromRGBO(158, 179, 132, 1), + Color.fromRGBO(206, 222, 189, 1), + Color.fromRGBO(250, 241, 228, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(250, 240, 230, 1), + Color.fromRGBO(185, 180, 199, 1), + Color.fromRGBO(92, 84, 112, 1), + Color.fromRGBO(53, 47, 68, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(255, 186, 134, 1), + Color.fromRGBO(246, 99, 92, 1), + Color.fromRGBO(194, 51, 115, 1), + Color.fromRGBO(121, 21, 91, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(213, 255, 208, 1), + Color.fromRGBO(64, 248, 255, 1), + Color.fromRGBO(39, 158, 255, 1), + Color.fromRGBO(12, 53, 106, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(131, 96, 150, 1), + Color.fromRGBO(237, 123, 123, 1), + Color.fromRGBO(240, 184, 110, 1), + Color.fromRGBO(235, 231, 108, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(63, 29, 56, 1), + Color.fromRGBO(77, 60, 119, 1), + Color.fromRGBO(162, 103, 138, 1), + Color.fromRGBO(225, 152, 152, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(254, 123, 229, 1), + Color.fromRGBO(151, 78, 195, 1), + Color.fromRGBO(80, 64, 153, 1), + Color.fromRGBO(49, 56, 102, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(248, 222, 34, 1), + Color.fromRGBO(249, 76, 16, 1), + Color.fromRGBO(199, 0, 57, 1), + Color.fromRGBO(144, 12, 63, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(101, 69, 31, 1), + Color.fromRGBO(118, 88, 39, 1), + Color.fromRGBO(200, 174, 125, 1), + Color.fromRGBO(234, 198, 150, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(255, 246, 224, 1), + Color.fromRGBO(216, 217, 218, 1), + Color.fromRGBO(97, 103, 122, 1), + Color.fromRGBO(39, 40, 41, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(145, 109, 179, 1), + Color.fromRGBO(228, 133, 134, 1), + Color.fromRGBO(252, 186, 173, 1), + Color.fromRGBO(253, 229, 236, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(124, 115, 192, 1), + Color.fromRGBO(148, 173, 215, 1), + Color.fromRGBO(172, 250, 223, 1), + Color.fromRGBO(232, 255, 206, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(174, 216, 204, 1), + Color.fromRGBO(205, 102, 136, 1), + Color.fromRGBO(122, 49, 111, 1), + Color.fromRGBO(70, 25, 89, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(237, 228, 255, 1), + Color.fromRGBO(215, 187, 245, 1), + Color.fromRGBO(160, 118, 249, 1), + Color.fromRGBO(101, 40, 247, 1) + ]), + LinearGradient(colors: [ + Color.fromRGBO(255, 236, 175, 1), + Color.fromRGBO(255, 176, 127, 1), + Color.fromRGBO(255, 82, 162, 1), + Color.fromRGBO(243, 21, 89, 1) + ]), +]; diff --git a/lib/collections/initializers.dart b/lib/collections/initializers.dart new file mode 100644 index 00000000..9627de1c --- /dev/null +++ b/lib/collections/initializers.dart @@ -0,0 +1,25 @@ +import 'dart:io'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:win32_registry/win32_registry.dart'; + +Future registerWindowsScheme(String scheme) async { + if (!DesktopTools.platform.isWindows) return; + String appPath = Platform.resolvedExecutable; + + String protocolRegKey = 'Software\\Classes\\$scheme'; + RegistryValue protocolRegValue = const RegistryValue( + 'URL Protocol', + RegistryValueType.string, + '', + ); + String protocolCmdRegKey = 'shell\\open\\command'; + RegistryValue protocolCmdRegValue = RegistryValue( + '', + RegistryValueType.string, + '"$appPath" "%1"', + ); + + final regKey = Registry.currentUser.createKey(protocolRegKey); + regKey.createValue(protocolRegValue); + regKey.createKey(protocolCmdRegKey).createValue(protocolCmdRegValue); +} diff --git a/lib/collections/intents.dart b/lib/collections/intents.dart index 4b5deac0..abccb3ad 100644 --- a/lib/collections/intents.dart +++ b/lib/collections/intents.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -8,7 +10,6 @@ import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:window_manager/window_manager.dart'; class PlayPauseIntent extends Intent { final WidgetRef ref; @@ -115,7 +116,7 @@ class CloseAppAction extends Action { @override invoke(intent) { if (kIsDesktop) { - windowManager.close(); + exit(0); } else { SystemNavigator.pop(); } diff --git a/lib/collections/language_codes.dart b/lib/collections/language_codes.dart index 8209f33d..4554de63 100644 --- a/lib/collections/language_codes.dart +++ b/lib/collections/language_codes.dart @@ -36,10 +36,10 @@ abstract class LanguageLocals { // name: "Amharic", // nativeName: "አማርኛ", // ), - // "ar": const ISOLanguageName( - // name: "Arabic", - // nativeName: "العربية", - // ), + "ar": const ISOLanguageName( + name: "Arabic", + nativeName: "العربية", + ), // "an": const ISOLanguageName( // name: "Aragonese", // nativeName: "Aragonés", @@ -164,10 +164,10 @@ abstract class LanguageLocals { // name: "Maldivian;", // nativeName: "ދިވެހި", // ), - // "nl": const ISOLanguageName( - // name: "Dutch", - // nativeName: "Vlaams", - // ), + "nl": const ISOLanguageName( + name: "Dutch", + nativeName: "Nederlands", + ), "en": const ISOLanguageName( name: "English", nativeName: "English", @@ -288,10 +288,10 @@ abstract class LanguageLocals { // name: "Icelandic", // nativeName: "Íslenska", // ), - // "it": const ISOLanguageName( - // name: "Italian", - // nativeName: "Italiano", - // ), + "it": const ISOLanguageName( + name: "Italian", + nativeName: "Italiano", + ), // "iu": const ISOLanguageName( // name: "Inuktitut", // nativeName: "ᐃᓄᒃᑎᑐᑦ", @@ -452,10 +452,10 @@ abstract class LanguageLocals { // name: "North Ndebele", // nativeName: "isiNdebele", // ), - // "ne": const ISOLanguageName( - // name: "Nepali", - // nativeName: "नेपाली", - // ), + "ne": const ISOLanguageName( + name: "Nepali", + nativeName: "नेपाली", + ), // "ng": const ISOLanguageName( // name: "Ndonga", // nativeName: "Owambo", @@ -508,10 +508,10 @@ abstract class LanguageLocals { // name: "Pāli", // nativeName: "पाऴि", // ), - // "fa": const ISOLanguageName( - // name: "Persian", - // nativeName: "فارسی", - // ), + "fa": const ISOLanguageName( + name: "Persian", + nativeName: "فارسی", + ), "pl": const ISOLanguageName( name: "Polish", nativeName: "polski", @@ -520,10 +520,10 @@ abstract class LanguageLocals { // name: "Pashto, Pushto", // nativeName: "پښتو", // ), - // "pt": const ISOLanguageName( - // name: "Portuguese", - // nativeName: "Português", - // ), + "pt": const ISOLanguageName( + name: "Portuguese", + nativeName: "Português", + ), // "qu": const ISOLanguageName( // name: "Quechua", // nativeName: "Runa Simi, Kichwa", @@ -540,10 +540,10 @@ abstract class LanguageLocals { // name: "Romanian, Moldavian, Moldovan", // nativeName: "română", // ), - // "ru": const ISOLanguageName( - // name: "Russian", - // nativeName: "русский язык", - // ), + "ru": const ISOLanguageName( + name: "Russian", + nativeName: "русский язык", + ), // "sa": const ISOLanguageName( // name: "Sanskrit (Saṁskṛta)", // nativeName: "संस्कृतम्", @@ -660,10 +660,10 @@ abstract class LanguageLocals { // name: "Tonga (Tonga Islands)", // nativeName: "faka Tonga", // ), - // "tr": const ISOLanguageName( - // name: "Turkish", - // nativeName: "Türkçe", - // ), + "tr": const ISOLanguageName( + name: "Turkish", + nativeName: "Türkçe", + ), // "ts": const ISOLanguageName( // name: "Tsonga", // nativeName: "Xitsonga", @@ -684,10 +684,10 @@ abstract class LanguageLocals { // name: "Uighur, Uyghur", // nativeName: "Uyƣurqə, ئۇيغۇرچە‎", // ), - // "uk": const ISOLanguageName( - // name: "Ukrainian", - // nativeName: "українська", - // ), + "uk": const ISOLanguageName( + name: "Ukrainian", + nativeName: "українська", + ), // "ur": const ISOLanguageName( // name: "Urdu", // nativeName: "اردو", diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 0faf432b..3e2c42e0 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -1,32 +1,35 @@ -import 'package:catcher/catcher.dart'; -import 'package:flutter/foundation.dart'; +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:spotify/spotify.dart' hide Search; +import 'package:spotube/pages/album/album.dart'; +import 'package:spotube/pages/home/genres/genre_playlists.dart'; +import 'package:spotube/pages/home/genres/genres.dart'; import 'package:spotube/pages/home/home.dart'; +import 'package:spotube/pages/lastfm_login/lastfm_login.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; +import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart'; import 'package:spotube/pages/lyrics/mini_lyrics.dart'; +import 'package:spotube/pages/playlist/liked_playlist.dart'; +import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/settings/blacklist.dart'; import 'package:spotube/pages/settings/about.dart'; import 'package:spotube/pages/settings/logs.dart'; +import 'package:spotube/pages/track/track.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/components/shared/spotube_page_route.dart'; -import 'package:spotube/pages/album/album.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/player/player.dart'; -import 'package:spotube/pages/playlist/playlist.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'; -import '../pages/library/playlist_generate/playlist_generate_result.dart'; - -final rootNavigatorKey = Catcher.navigatorKey; +final rootNavigatorKey = Catcher2.navigatorKey; final shellRouteNavigatorKey = GlobalKey(); final router = GoRouter( navigatorKey: rootNavigatorKey, @@ -38,6 +41,21 @@ final router = GoRouter( GoRoute( path: "/", pageBuilder: (context, state) => const SpotubePage(child: HomePage()), + routes: [ + GoRoute( + path: "genres", + pageBuilder: (context, state) => + const SpotubePage(child: GenrePage()), + ), + GoRoute( + path: "genre/:categoryId", + pageBuilder: (context, state) => SpotubePage( + child: GenrePlaylistsPage( + category: state.extra as Category, + ), + ), + ), + ], ), GoRoute( path: "/search", @@ -104,7 +122,9 @@ final router = GoRouter( path: "/album/:id", pageBuilder: (context, state) { assert(state.extra is AlbumSimple); - return SpotubePage(child: AlbumPage(state.extra as AlbumSimple)); + return SpotubePage( + child: AlbumPage(album: state.extra as AlbumSimple), + ); }, ), GoRoute( @@ -119,7 +139,18 @@ final router = GoRouter( pageBuilder: (context, state) { assert(state.extra is PlaylistSimple); return SpotubePage( - child: PlaylistView(state.extra as PlaylistSimple), + child: state.pathParameters["id"] == "user-liked-tracks" + ? LikedPlaylistPage(playlist: state.extra as PlaylistSimple) + : PlaylistPage(playlist: state.extra as PlaylistSimple), + ); + }, + ), + GoRoute( + path: "/track/:id", + pageBuilder: (context, state) { + final id = state.pathParameters["id"]!; + return SpotubePage( + child: TrackPage(trackId: id), ); }, ), @@ -128,8 +159,8 @@ final router = GoRouter( GoRoute( path: "/mini-player", parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) => const SpotubePage( - child: MiniLyricsPage(), + pageBuilder: (context, state) => SpotubePage( + child: MiniLyricsPage(prevSize: state.extra as Size), ), ), GoRoute( @@ -147,13 +178,10 @@ final router = GoRouter( ), ), GoRoute( - path: "/player", + path: "/lastfm-login", parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) { - return const SpotubePage( - child: PlayerView(), - ); - }, + pageBuilder: (context, state) => + const SpotubePage(child: LastFMLoginPage()), ), ], ); diff --git a/lib/collections/spotify_markets.dart b/lib/collections/spotify_markets.dart index a24fd768..514b3f0b 100644 --- a/lib/collections/spotify_markets.dart +++ b/lib/collections/spotify_markets.dart @@ -1,187 +1,189 @@ // Country Codes contributed by momobobe +import 'package:spotify/spotify.dart'; + final spotifyMarkets = [ - ("AL", "Albania (AL)"), - ("DZ", "Algeria (DZ)"), - ("AD", "Andorra (AD)"), - ("AO", "Angola (AO)"), - ("AG", "Antigua and Barbuda (AG)"), - ("AR", "Argentina (AR)"), - ("AM", "Armenia (AM)"), - ("AU", "Australia (AU)"), - ("AT", "Austria (AT)"), - ("AZ", "Azerbaijan (AZ)"), - ("BH", "Bahrain (BH)"), - ("BD", "Bangladesh (BD)"), - ("BB", "Barbados (BB)"), - ("BY", "Belarus (BY)"), - ("BE", "Belgium (BE)"), - ("BZ", "Belize (BZ)"), - ("BJ", "Benin (BJ)"), - ("BT", "Bhutan (BT)"), - ("BO", "Bolivia (BO)"), - ("BA", "Bosnia and Herzegovina (BA)"), - ("BW", "Botswana (BW)"), - ("BR", "Brazil (BR)"), - ("BN", "Brunei Darussalam (BN)"), - ("BG", "Bulgaria (BG)"), - ("BF", "Burkina Faso (BF)"), - ("BI", "Burundi (BI)"), - ("CV", "Cabo Verde / Cape Verde (CV)"), - ("KH", "Cambodia (KH)"), - ("CM", "Cameroon (CM)"), - ("CA", "Canada (CA)"), - ("TD", "Chad (TD)"), - ("CL", "Chile (CL)"), - ("CO", "Colombia (CO)"), - ("KM", "Comoros (KM)"), - ("CR", "Costa Rica (CR)"), - ("HR", "Croatia (HR)"), - ("CW", "Curaçao (CW)"), - ("CY", "Cyprus (CY)"), - ("CZ", "Czech Republic (CZ)"), - ("CI", "Ivory Coast (CI)"), - ("CD", "Congo (CD)"), - ("DK", "Denmark (DK)"), - ("DJ", "Djibouti (DJ)"), - ("DM", "Dominica (DM)"), - ("DO", "Dominican Republic (DO)"), - ("EC", "Ecuador (EC)"), - ("EG", "Egypt (EG)"), - ("SV", "El Salvador (SV)"), - ("GQ", "Equatorial Guinea (GQ)"), - ("EE", "Estonia (EE)"), - ("SZ", "Eswatini (SZ)"), - ("FJ", "Fiji (FJ)"), - ("FI", "Finland (FI)"), - ("FR", "France (FR)"), - ("GA", "Gabon (GA)"), - ("GE", "Georgia (GE)"), - ("DE", "Germany (DE)"), - ("GH", "Ghana (GH)"), - ("GR", "Greece (GR)"), - ("GD", "Grenada (GD)"), - ("GT", "Guatemala (GT)"), - ("GN", "Guinea (GN)"), - ("GW", "Guinea-Bissau (GW)"), - ("GY", "Guyana (GY)"), - ("HT", "Haiti (HT)"), - ("HN", "Honduras (HN)"), - ("HK", "Hong Kong (HK)"), - ("HU", "Hungary (HU)"), - ("IS", "Iceland (IS)"), - ("IN", "India (IN)"), - ("ID", "Indonesia (ID)"), - ("IQ", "Iraq (IQ)"), - ("IE", "Ireland (IE)"), - ("IL", "Israel (IL)"), - ("IT", "Italy (IT)"), - ("JM", "Jamaica (JM)"), - ("JP", "Japan (JP)"), - ("JO", "Jordan (JO)"), - ("KZ", "Kazakhstan (KZ)"), - ("KE", "Kenya (KE)"), - ("KI", "Kiribati (KI)"), - ("XK", "Kosovo (XK)"), - ("KW", "Kuwait (KW)"), - ("KG", "Kyrgyzstan (KG)"), - ("LA", "Laos (LA)"), - ("LV", "Latvia (LV)"), - ("LB", "Lebanon (LB)"), - ("LS", "Lesotho (LS)"), - ("LR", "Liberia (LR)"), - ("LY", "Libya (LY)"), - ("LI", "Liechtenstein (LI)"), - ("LT", "Lithuania (LT)"), - ("LU", "Luxembourg (LU)"), - ("MO", "Macao / Macau (MO)"), - ("MG", "Madagascar (MG)"), - ("MW", "Malawi (MW)"), - ("MY", "Malaysia (MY)"), - ("MV", "Maldives (MV)"), - ("ML", "Mali (ML)"), - ("MT", "Malta (MT)"), - ("MH", "Marshall Islands (MH)"), - ("MR", "Mauritania (MR)"), - ("MU", "Mauritius (MU)"), - ("MX", "Mexico (MX)"), - ("FM", "Micronesia (FM)"), - ("MD", "Moldova (MD)"), - ("MC", "Monaco (MC)"), - ("MN", "Mongolia (MN)"), - ("ME", "Montenegro (ME)"), - ("MA", "Morocco (MA)"), - ("MZ", "Mozambique (MZ)"), - ("NA", "Namibia (NA)"), - ("NR", "Nauru (NR)"), - ("NP", "Nepal (NP)"), - ("NL", "Netherlands (NL)"), - ("NZ", "New Zealand (NZ)"), - ("NI", "Nicaragua (NI)"), - ("NE", "Niger (NE)"), - ("NG", "Nigeria (NG)"), - ("MK", "North Macedonia (MK)"), - ("NO", "Norway (NO)"), - ("OM", "Oman (OM)"), - ("PK", "Pakistan (PK)"), - ("PW", "Palau (PW)"), - ("PS", "Palestine (PS)"), - ("PA", "Panama (PA)"), - ("PG", "Papua New Guinea (PG)"), - ("PY", "Paraguay (PY)"), - ("PE", "Peru (PE)"), - ("PH", "Philippines (PH)"), - ("PL", "Poland (PL)"), - ("PT", "Portugal (PT)"), - ("QA", "Qatar (QA)"), - ("CG", "Congo (CG)"), - ("RO", "Romania (RO)"), - ("RU", "Russia (RU)"), - ("RW", "Rwanda (RW)"), - ("WS", "Samoa (WS)"), - ("SM", "San Marino (SM)"), - ("SA", "Saudi Arabia (SA)"), - ("SN", "Senegal (SN)"), - ("RS", "Serbia (RS)"), - ("SC", "Seychelles (SC)"), - ("SL", "Sierra Leone (SL)"), - ("SG", "Singapore (SG)"), - ("SK", "Slovakia (SK)"), - ("SI", "Slovenia (SI)"), - ("SB", "Solomon Islands (SB)"), - ("ZA", "South Africa (ZA)"), - ("KR", "South Korea (KR)"), - ("ES", "Spain (ES)"), - ("LK", "Sri Lanka (LK)"), - ("KN", "St. Kitts and Nevis (KN)"), - ("LC", "St. Lucia (LC)"), - ("SR", "Suriname (SR)"), - ("SE", "Sweden (SE)"), - ("CH", "Switzerland (CH)"), - ("ST", "São Tomé and Príncipe (ST)"), - ("TW", "Taiwan (TW)"), - ("TJ", "Tajikistan (TJ)"), - ("TZ", "Tanzania (TZ)"), - ("TH", "Thailand (TH)"), - ("BS", "The Bahamas (BS)"), - ("GM", "The Gambia (GM)"), - ("TL", "East Timor (TL)"), - ("TG", "Togo (TG)"), - ("TO", "Tonga (TO)"), - ("TT", "Trinidad and Tobago (TT)"), - ("TN", "Tunisia (TN)"), - ("TR", "Turkey (TR)"), - ("TV", "Tuvalu (TV)"), - ("UG", "Uganda (UG)"), - ("UA", "Ukraine (UA)"), - ("AE", "United Arab Emirates (AE)"), - ("GB", "United Kingdom (GB)"), - ("US", "United States (US)"), - ("UY", "Uruguay (UY)"), - ("UZ", "Uzbekistan (UZ)"), - ("VU", "Vanuatu (VU)"), - ("VE", "Venezuela (VE)"), - ("VN", "Vietnam (VN)"), - ("ZM", "Zambia (ZM)"), - ("ZW", "Zimbabwe (ZW)"), + (Market.AL, "Albania (AL)"), + (Market.DZ, "Algeria (DZ)"), + (Market.AD, "Andorra (AD)"), + (Market.AO, "Angola (AO)"), + (Market.AG, "Antigua and Barbuda (AG)"), + (Market.AR, "Argentina (AR)"), + (Market.AM, "Armenia (AM)"), + (Market.AU, "Australia (AU)"), + (Market.AT, "Austria (AT)"), + (Market.AZ, "Azerbaijan (AZ)"), + (Market.BH, "Bahrain (BH)"), + (Market.BD, "Bangladesh (BD)"), + (Market.BB, "Barbados (BB)"), + (Market.BY, "Belarus (BY)"), + (Market.BE, "Belgium (BE)"), + (Market.BZ, "Belize (BZ)"), + (Market.BJ, "Benin (BJ)"), + (Market.BT, "Bhutan (BT)"), + (Market.BO, "Bolivia (BO)"), + (Market.BA, "Bosnia and Herzegovina (BA)"), + (Market.BW, "Botswana (BW)"), + (Market.BR, "Brazil (BR)"), + (Market.BN, "Brunei Darussalam (BN)"), + (Market.BG, "Bulgaria (BG)"), + (Market.BF, "Burkina Faso (BF)"), + (Market.BI, "Burundi (BI)"), + (Market.CV, "Cabo Verde / Cape Verde (CV)"), + (Market.KH, "Cambodia (KH)"), + (Market.CM, "Cameroon (CM)"), + (Market.CA, "Canada (CA)"), + (Market.TD, "Chad (TD)"), + (Market.CL, "Chile (CL)"), + (Market.CO, "Colombia (CO)"), + (Market.KM, "Comoros (KM)"), + (Market.CR, "Costa Rica (CR)"), + (Market.HR, "Croatia (HR)"), + (Market.CW, "Curaçao (CW)"), + (Market.CY, "Cyprus (CY)"), + (Market.CZ, "Czech Republic (CZ)"), + (Market.CI, "Ivory Coast (CI)"), + (Market.CD, "Congo (CD)"), + (Market.DK, "Denmark (DK)"), + (Market.DJ, "Djibouti (DJ)"), + (Market.DM, "Dominica (DM)"), + (Market.DO, "Dominican Republic (DO)"), + (Market.EC, "Ecuador (EC)"), + (Market.EG, "Egypt (EG)"), + (Market.SV, "El Salvador (SV)"), + (Market.GQ, "Equatorial Guinea (GQ)"), + (Market.EE, "Estonia (EE)"), + (Market.SZ, "Eswatini (SZ)"), + (Market.FJ, "Fiji (FJ)"), + (Market.FI, "Finland (FI)"), + (Market.FR, "France (FR)"), + (Market.GA, "Gabon (GA)"), + (Market.GE, "Georgia (GE)"), + (Market.DE, "Germany (DE)"), + (Market.GH, "Ghana (GH)"), + (Market.GR, "Greece (GR)"), + (Market.GD, "Grenada (GD)"), + (Market.GT, "Guatemala (GT)"), + (Market.GN, "Guinea (GN)"), + (Market.GW, "Guinea-Bissau (GW)"), + (Market.GY, "Guyana (GY)"), + (Market.HT, "Haiti (HT)"), + (Market.HN, "Honduras (HN)"), + (Market.HK, "Hong Kong (HK)"), + (Market.HU, "Hungary (HU)"), + (Market.IS, "Iceland (IS)"), + (Market.IN, "India (IN)"), + (Market.ID, "Indonesia (ID)"), + (Market.IQ, "Iraq (IQ)"), + (Market.IE, "Ireland (IE)"), + (Market.IL, "Israel (IL)"), + (Market.IT, "Italy (IT)"), + (Market.JM, "Jamaica (JM)"), + (Market.JP, "Japan (JP)"), + (Market.JO, "Jordan (JO)"), + (Market.KZ, "Kazakhstan (KZ)"), + (Market.KE, "Kenya (KE)"), + (Market.KI, "Kiribati (KI)"), + (Market.XK, "Kosovo (XK)"), + (Market.KW, "Kuwait (KW)"), + (Market.KG, "Kyrgyzstan (KG)"), + (Market.LA, "Laos (LA)"), + (Market.LV, "Latvia (LV)"), + (Market.LB, "Lebanon (LB)"), + (Market.LS, "Lesotho (LS)"), + (Market.LR, "Liberia (LR)"), + (Market.LY, "Libya (LY)"), + (Market.LI, "Liechtenstein (LI)"), + (Market.LT, "Lithuania (LT)"), + (Market.LU, "Luxembourg (LU)"), + (Market.MO, "Macao / Macau (MO)"), + (Market.MG, "Madagascar (MG)"), + (Market.MW, "Malawi (MW)"), + (Market.MY, "Malaysia (MY)"), + (Market.MV, "Maldives (MV)"), + (Market.ML, "Mali (ML)"), + (Market.MT, "Malta (MT)"), + (Market.MH, "Marshall Islands (MH)"), + (Market.MR, "Mauritania (MR)"), + (Market.MU, "Mauritius (MU)"), + (Market.MX, "Mexico (MX)"), + (Market.FM, "Micronesia (FM)"), + (Market.MD, "Moldova (MD)"), + (Market.MC, "Monaco (MC)"), + (Market.MN, "Mongolia (MN)"), + (Market.ME, "Montenegro (ME)"), + (Market.MA, "Morocco (MA)"), + (Market.MZ, "Mozambique (MZ)"), + (Market.NA, "Namibia (NA)"), + (Market.NR, "Nauru (NR)"), + (Market.NP, "Nepal (NP)"), + (Market.NL, "Netherlands (NL)"), + (Market.NZ, "New Zealand (NZ)"), + (Market.NI, "Nicaragua (NI)"), + (Market.NE, "Niger (NE)"), + (Market.NG, "Nigeria (NG)"), + (Market.MK, "North Macedonia (MK)"), + (Market.NO, "Norway (NO)"), + (Market.OM, "Oman (OM)"), + (Market.PK, "Pakistan (PK)"), + (Market.PW, "Palau (PW)"), + (Market.PS, "Palestine (PS)"), + (Market.PA, "Panama (PA)"), + (Market.PG, "Papua New Guinea (PG)"), + (Market.PY, "Paraguay (PY)"), + (Market.PE, "Peru (PE)"), + (Market.PH, "Philippines (PH)"), + (Market.PL, "Poland (PL)"), + (Market.PT, "Portugal (PT)"), + (Market.QA, "Qatar (QA)"), + (Market.CG, "Congo (CG)"), + (Market.RO, "Romania (RO)"), + (Market.RU, "Russia (RU)"), + (Market.RW, "Rwanda (RW)"), + (Market.WS, "Samoa (WS)"), + (Market.SM, "San Marino (SM)"), + (Market.SA, "Saudi Arabia (SA)"), + (Market.SN, "Senegal (SN)"), + (Market.RS, "Serbia (RS)"), + (Market.SC, "Seychelles (SC)"), + (Market.SL, "Sierra Leone (SL)"), + (Market.SG, "Singapore (SG)"), + (Market.SK, "Slovakia (SK)"), + (Market.SI, "Slovenia (SI)"), + (Market.SB, "Solomon Islands (SB)"), + (Market.ZA, "South Africa (ZA)"), + (Market.KR, "South Korea (KR)"), + (Market.ES, "Spain (ES)"), + (Market.LK, "Sri Lanka (LK)"), + (Market.KN, "St. Kitts and Nevis (KN)"), + (Market.LC, "St. Lucia (LC)"), + (Market.SR, "Suriname (SR)"), + (Market.SE, "Sweden (SE)"), + (Market.CH, "Switzerland (CH)"), + (Market.ST, "São Tomé and Príncipe (ST)"), + (Market.TW, "Taiwan (TW)"), + (Market.TJ, "Tajikistan (TJ)"), + (Market.TZ, "Tanzania (TZ)"), + (Market.TH, "Thailand (TH)"), + (Market.BS, "The Bahamas (BS)"), + (Market.GM, "The Gambia (GM)"), + (Market.TL, "East Timor (TL)"), + (Market.TG, "Togo (TG)"), + (Market.TO, "Tonga (TO)"), + (Market.TT, "Trinidad and Tobago (TT)"), + (Market.TN, "Tunisia (TN)"), + (Market.TR, "Turkey (TR)"), + (Market.TV, "Tuvalu (TV)"), + (Market.UG, "Uganda (UG)"), + (Market.UA, "Ukraine (UA)"), + (Market.AE, "United Arab Emirates (AE)"), + (Market.GB, "United Kingdom (GB)"), + (Market.US, "United States (US)"), + (Market.UY, "Uruguay (UY)"), + (Market.UZ, "Uzbekistan (UZ)"), + (Market.VU, "Vanuatu (VU)"), + (Market.VE, "Venezuela (VE)"), + (Market.VN, "Vietnam (VN)"), + (Market.ZM, "Zambia (ZM)"), + (Market.ZW, "Zimbabwe (ZW)"), ]; diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 754c5f7e..c00643ce 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -1,6 +1,7 @@ import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:simple_icons/simple_icons.dart'; abstract class SpotubeIcons { static const home = FluentIcons.home_12_regular; @@ -39,6 +40,8 @@ abstract class SpotubeIcons { static const trash = FeatherIcons.trash2; static const clock = FeatherIcons.clock; static const lyrics = Icons.lyrics_rounded; + static const lyricsOff = Icons.lyrics_outlined; + static const noLyrics = Icons.music_off_outlined; static const logout = FeatherIcons.logOut; static const login = FeatherIcons.logIn; static const dashboard = FeatherIcons.grid; @@ -91,4 +94,22 @@ abstract class SpotubeIcons { static const clipboard = FeatherIcons.clipboard; static const api = FeatherIcons.database; static const skip = FeatherIcons.fastForward; + static const noWifi = FeatherIcons.wifiOff; + static const wifi = FeatherIcons.wifi; + static const window = Icons.window_rounded; + static const user = FeatherIcons.user; + static const edit = FeatherIcons.edit; + static const web = FeatherIcons.globe; + static const amoled = FeatherIcons.sunset; + static const file = FeatherIcons.file; + static const stream = Icons.stream_rounded; + static const lastFm = SimpleIcons.lastdotfm; + static const spotify = SimpleIcons.spotify; + static const eye = FeatherIcons.eye; + static const noEye = FeatherIcons.eyeOff; + static const normalize = FeatherIcons.barChart2; + static const wikipedia = SimpleIcons.wikipedia; + static const discord = SimpleIcons.discord; + static const youtube = SimpleIcons.youtube; + static const radio = FeatherIcons.radio; } diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index afb637a0..c7ae2f9a 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -4,36 +4,21 @@ 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/hooks/use_breakpoint_value.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/infinite_query.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/queries/album.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; -enum AlbumType { - album, - single, - compilation; - - factory AlbumType.from(String? type) { - switch (type) { - case "album": - return AlbumType.album; - case "single": - return AlbumType.single; - case "compilation": - return AlbumType.compilation; - default: - return AlbumType.album; - } - } - +extension FormattedAlbumType on AlbumType { String get formatted => name.replaceFirst(name[0], name[0].toUpperCase()); } class AlbumCard extends HookConsumerWidget { - final Album album; + final AlbumSimple album; const AlbumCard( this.album, { Key? key, @@ -45,48 +30,72 @@ class AlbumCard extends HookConsumerWidget { final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final queryClient = useQueryClient(); + bool isPlaylistPlaying = useMemoized( () => playlist.containsCollection(album.id!), [playlist, album.id], ); - final int marginH = - useBreakpointValue(xs: 10, sm: 10, md: 15, lg: 20, xl: 20, xxl: 20); final updating = useState(false); final spotify = ref.watch(spotifyProvider); + final scaffoldMessenger = ScaffoldMessenger.maybeOf(context); + + Future> fetchAllTrack() async { + if (album.tracks != null && album.tracks!.isNotEmpty) { + return album.tracks! + .map((track) => + TypeConversionUtils.simpleTrack_X_Track(track, album)) + .toList(); + } + final job = AlbumQueries.tracksOfJob(album.id!); + + final query = queryClient.createInfiniteQuery( + job.queryKey, + (page) => job.task(page, (spotify: spotify, album: album)), + initialPage: 0, + nextPage: job.nextPage, + ); + + return await query.fetchAllTracks( + getAllTracks: () async { + final res = await spotify.albums.tracks(album.id!).all(); + return res + .map((e) => TypeConversionUtils.simpleTrack_X_Track(e, album)) + .toList(); + }, + ); + } + return PlaybuttonCard( imageUrl: TypeConversionUtils.image_X_UrlString( album.images, placeholder: ImagePlaceholder.collection, ), - margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()), + margin: const EdgeInsets.symmetric(horizontal: 10), isPlaying: isPlaylistPlaying, - isLoading: isPlaylistPlaying && playlist.isFetching == true, + isLoading: (isPlaylistPlaying && playlist.isFetching == true) || + updating.value, title: album.name!, description: - "${AlbumType.from(album.albumType!).formatted} • ${TypeConversionUtils.artists_X_String(album.artists ?? [])}", + "${album.albumType?.formatted} • ${TypeConversionUtils.artists_X_String(album.artists ?? [])}", onTap: () { ServiceUtils.push(context, "/album/${album.id}", extra: album); }, onPlaybuttonPressed: () async { updating.value = true; try { - if (isPlaylistPlaying && playing) { - return audioPlayer.pause(); - } else if (isPlaylistPlaying && !playing) { - return audioPlayer.resume(); + if (isPlaylistPlaying) { + return playing ? audioPlayer.pause() : audioPlayer.resume(); } - await playlistNotifier.load( - album.tracks - ?.map((e) => - TypeConversionUtils.simpleTrack_X_Track(e, album)) - .toList() ?? - [], - autoPlay: true, - ); + final fetchedTracks = await fetchAllTrack(); + + if (fetchedTracks.isEmpty) return; + + await playlistNotifier.load(fetchedTracks, autoPlay: true); playlistNotifier.addCollection(album.id!); } finally { updating.value = false; @@ -99,28 +108,16 @@ class AlbumCard extends HookConsumerWidget { updating.value = true; try { - final fetchedTracks = - await queryClient.fetchQuery, SpotifyApi>( - "album-tracks/${album.id}", - () { - return spotify.albums - .getTracks(album.id!) - .all() - .then((value) => value.toList()); - }, - ).then( - (tracks) => tracks - ?.map( - (e) => TypeConversionUtils.simpleTrack_X_Track(e, album)) - .toList(), - ); + final fetchedTracks = await fetchAllTrack(); - if (fetchedTracks == null || fetchedTracks.isEmpty) return; + if (fetchedTracks.isEmpty) return; playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addCollection(album.id!); if (context.mounted) { final snackbar = SnackBar( - content: Text("Added ${album.tracks?.length} tracks to queue"), + content: Text( + context.l10n.added_to_queue(fetchedTracks.length), + ), action: SnackBarAction( label: "Undo", onPressed: () { @@ -129,7 +126,8 @@ class AlbumCard extends HookConsumerWidget { }, ), ); - ScaffoldMessenger.maybeOf(context)?.showSnackBar(snackbar); + + scaffoldMessenger?.showSnackBar(snackbar); } } finally { updating.value = false; diff --git a/lib/components/artist/artist_album_list.dart b/lib/components/artist/artist_album_list.dart index 8fa9be87..5114170c 100644 --- a/lib/components/artist/artist_album_list.dart +++ b/lib/components/artist/artist_album_list.dart @@ -1,11 +1,9 @@ -import 'package:flutter/gestures.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/album/album_card.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; -import 'package:spotube/components/shared/waypoint.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'; @@ -20,7 +18,6 @@ class ArtistAlbumList extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final scrollController = useScrollController(); final albumsQuery = useQueries.artist.albumsOf(ref, artistId); final albums = useMemoized(() { @@ -29,40 +26,17 @@ class ArtistAlbumList extends HookConsumerWidget { .toList(); }, [albumsQuery.pages]); - final hasNextPage = albumsQuery.pages.isEmpty - ? false - : (albumsQuery.pages.last.items?.length ?? 0) == 5; + final theme = Theme.of(context); - return Column( - children: [ - ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - interactive: false, - controller: scrollController, - child: Waypoint( - controller: scrollController, - onTouchEdge: albumsQuery.fetchNext, - child: SingleChildScrollView( - controller: scrollController, - scrollDirection: Axis.horizontal, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...albums.map((album) => AlbumCard(album)), - if (hasNextPage) const ShimmerPlaybuttonCard(count: 1), - ], - ), - ), - ), - ), - ), - ], + return HorizontalPlaybuttonCardView( + isLoadingNextPage: albumsQuery.isLoadingNextPage, + hasNextPage: albumsQuery.hasNextPage, + items: albums, + onFetchMore: albumsQuery.fetchNext, + title: Text( + context.l10n.albums, + style: theme.textTheme.headlineSmall, + ), ); } } diff --git a/lib/components/artist/artist_card.dart b/lib/components/artist/artist_card.dart index 993e9f6a..3526e88f 100644 --- a/lib/components/artist/artist_card.dart +++ b/lib/components/artist/artist_card.dart @@ -1,12 +1,13 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_breakpoint_value.dart'; -import 'package:spotube/hooks/use_brightness_value.dart'; +import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; +import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -91,12 +92,14 @@ class ArtistCard extends HookConsumerWidget { decoration: BoxDecoration( color: Colors.blue, borderRadius: BorderRadius.circular(50)), - child: Text( - context.l10n.artist, - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.bold, + child: Skeleton.ignore( + child: Text( + context.l10n.artist, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), ), ), ), diff --git a/lib/components/desktop_login/login_form.dart b/lib/components/desktop_login/login_form.dart index 42b8fb56..5abb9524 100644 --- a/lib/components/desktop_login/login_form.dart +++ b/lib/components/desktop_login/login_form.dart @@ -17,9 +17,10 @@ class TokenLoginForm extends HookConsumerWidget { final authenticationNotifier = ref.watch(AuthenticationNotifier.provider.notifier); final directCodeController = useTextEditingController(); - final keyCodeController = useTextEditingController(); final mounted = useIsMounted(); + final isLoading = useState(false); + return ConstrainedBox( constraints: const BoxConstraints( maxWidth: 400, @@ -35,37 +36,35 @@ class TokenLoginForm extends HookConsumerWidget { keyboardType: TextInputType.visiblePassword, ), const SizedBox(height: 10), - TextField( - controller: keyCodeController, - decoration: InputDecoration( - hintText: context.l10n.spotify_cookie("\"sp_key (or sp_gaid)\""), - labelText: context.l10n.cookie_name_cookie("sp_key (or sp_gaid)"), - ), - keyboardType: TextInputType.visiblePassword, - ), - const SizedBox(height: 20), FilledButton( - onPressed: () async { - if (keyCodeController.text.isEmpty || - 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}; sp_key=${keyCodeController.text}"; + 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(); - } - }, + authenticationNotifier.setCredentials( + await AuthenticationCredentials.fromCookie( + cookieHeader), + ); + if (mounted()) { + onDone?.call(); + } + } finally { + isLoading.value = false; + } + }, child: Text(context.l10n.submit), ) ], diff --git a/lib/components/genre/category_card.dart b/lib/components/genre/category_card.dart deleted file mode 100644 index 42654ed9..00000000 --- a/lib/components/genre/category_card.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'dart:ui'; - -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/playlist/playlist_card.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; -import 'package:spotube/components/shared/waypoint.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:spotube/services/queries/queries.dart'; - -class CategoryCard extends HookConsumerWidget { - final Category category; - CategoryCard( - this.category, { - Key? key, - }) : super(key: key); - - final logger = getLogger(CategoryCard); - - @override - Widget build(BuildContext context, ref) { - final scrollController = useScrollController(); - final playlistQuery = useQueries.category.playlistsOf( - ref, - category.id!, - ); - - if (playlistQuery.hasErrors && !playlistQuery.hasPageData) { - return const SizedBox.shrink(); - } - final playlists = playlistQuery.pages.expand( - (page) { - return page.items?.where((i) => i != null) ?? const Iterable.empty(); - }, - ).toList(); - return Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - category.name!, - style: Theme.of(context).textTheme.titleMedium, - ), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Waypoint( - controller: scrollController, - onTouchEdge: playlistQuery.fetchNext, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: scrollController, - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...playlists.map((playlist) => PlaylistCard(playlist)), - if (playlistQuery.hasNextPage) - const ShimmerPlaybuttonCard(count: 1), - ], - ), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/components/home/sections/featured.dart b/lib/components/home/sections/featured.dart new file mode 100644 index 00000000..8a7c2c95 --- /dev/null +++ b/lib/components/home/sections/featured.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart' hide Page; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/services/queries/queries.dart'; + +class HomeFeaturedSection extends HookConsumerWidget { + const HomeFeaturedSection({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final featuredPlaylistsQuery = useQueries.playlist.featured(ref); + final playlists = useMemoized( + () => featuredPlaylistsQuery.pages + .whereType>() + .expand((page) => page.items ?? const []), + [featuredPlaylistsQuery.pages], + ); + final isLoadingFeaturedPlaylists = !featuredPlaylistsQuery.hasPageData && + !featuredPlaylistsQuery.isLoadingNextPage; + + return Skeletonizer( + enabled: isLoadingFeaturedPlaylists, + child: HorizontalPlaybuttonCardView( + items: playlists.toList(), + title: Text(context.l10n.featured), + isLoadingNextPage: featuredPlaylistsQuery.isLoadingNextPage, + hasNextPage: featuredPlaylistsQuery.hasNextPage, + onFetchMore: featuredPlaylistsQuery.fetchNext, + ), + ); + } +} diff --git a/lib/components/home/sections/friends.dart b/lib/components/home/sections/friends.dart new file mode 100644 index 00000000..ef24b8d5 --- /dev/null +++ b/lib/components/home/sections/friends.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotube/collections/fake.dart'; +import 'package:spotube/components/home/sections/friends/friend_item.dart'; +import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; +import 'package:spotube/models/spotify_friends.dart'; +import 'package:spotube/services/queries/queries.dart'; + +class HomePageFriendsSection extends HookConsumerWidget { + const HomePageFriendsSection({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final friendsQuery = useQueries.user.friendActivity(ref); + final friends = friendsQuery.data?.friends ?? FakeData.friends.friends; + + final groupCount = useBreakpointValue( + sm: 3, + xs: 2, + md: 4, + lg: 5, + xl: 6, + xxl: 7, + ); + + final friendGroup = friends.fold>>( + [], + (previousValue, element) { + if (previousValue.isEmpty) { + return [ + [element] + ]; + } + + final lastGroup = previousValue.last; + if (lastGroup.length < groupCount) { + return [ + ...previousValue.sublist(0, previousValue.length - 1), + [...lastGroup, element] + ]; + } + + return [ + ...previousValue, + [element] + ]; + }, + ); + + if (!friendsQuery.isLoading && + (!friendsQuery.hasData || friendsQuery.data!.friends.isEmpty)) { + return const SliverToBoxAdapter( + child: SizedBox.shrink(), + ); + } + + return Skeletonizer.sliver( + enabled: friendsQuery.isLoading, + child: SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'Friends', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ), + SliverToBoxAdapter( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final group in friendGroup) + Row( + children: [ + for (final friend in group) + Padding( + padding: const EdgeInsets.all(8.0), + child: FriendItem(friend: friend), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/components/home/sections/friends/friend_item.dart b/lib/components/home/sections/friends/friend_item.dart new file mode 100644 index 00000000..fcdadab7 --- /dev/null +++ b/lib/components/home/sections/friends/friend_item.dart @@ -0,0 +1,136 @@ +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/models/spotify_friends.dart'; +import 'package:spotube/provider/spotify_provider.dart'; + +class FriendItem extends HookConsumerWidget { + final SpotifyFriendActivity friend; + const FriendItem({ + Key? key, + required this.friend, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final ThemeData( + textTheme: textTheme, + colorScheme: colorScheme, + ) = Theme.of(context); + + final queryClient = useQueryClient(); + final spotify = ref.watch(spotifyProvider); + + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colorScheme.surfaceVariant.withOpacity(0.3), + borderRadius: BorderRadius.circular(15), + ), + constraints: const BoxConstraints( + minWidth: 300, + ), + height: 80, + child: Row( + children: [ + CircleAvatar( + backgroundImage: UniversalImage.imageProvider( + friend.user.imageUrl, + ), + ), + const Gap(8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + friend.user.name, + style: textTheme.bodyLarge, + ), + RichText( + text: TextSpan( + style: textTheme.bodySmall, + children: [ + TextSpan( + text: friend.track.name, + recognizer: TapGestureRecognizer() + ..onTap = () { + context.push("/track/${friend.track.id}"); + }, + ), + const TextSpan(text: " • "), + const WidgetSpan( + child: Icon( + SpotubeIcons.artist, + size: 12, + ), + ), + TextSpan( + text: " ${friend.track.artist.name}", + recognizer: TapGestureRecognizer() + ..onTap = () { + context.push( + "/artist/${friend.track.artist.id}", + ); + }, + ), + const TextSpan(text: "\n"), + TextSpan( + text: friend.track.context.name, + recognizer: TapGestureRecognizer() + ..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, + ), + ), + ); + }, + ), + const TextSpan(text: " • "), + const WidgetSpan( + child: Icon( + SpotubeIcons.album, + size: 12, + ), + ), + TextSpan( + text: " ${friend.track.album.name}", + recognizer: TapGestureRecognizer() + ..onTap = () async { + final album = + await queryClient.fetchQuery( + "album/${friend.track.album.id}", + () => spotify.albums.get( + friend.track.album.id, + ), + ); + if (context.mounted) { + context.push( + "/album/${friend.track.album.id}", + extra: album, + ); + } + }, + ), + ], + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/components/home/sections/genres.dart b/lib/components/home/sections/genres.dart new file mode 100644 index 00000000..41ba235c --- /dev/null +++ b/lib/components/home/sections/genres.dart @@ -0,0 +1,154 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/fake.dart'; +import 'package:spotube/collections/gradients.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/queries/queries.dart'; + +class HomeGenresSection extends HookConsumerWidget { + const HomeGenresSection({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:textTheme, :colorScheme) = Theme.of(context); + final mediaQuery = MediaQuery.of(context); + + final recommendationMarket = ref.watch( + userPreferencesProvider.select((s) => s.recommendationMarket), + ); + final categoriesQuery = + useQueries.category.listAll(ref, recommendationMarket); + + final categories = categoriesQuery.data + ?.where((c) => (c.icons?.length ?? 0) > 0) + .take(mediaQuery.mdAndDown ? 6 : 10) + .toList() ?? + []; + + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.l10n.genres, + style: textTheme.headlineSmall, + ), + Directionality( + textDirection: TextDirection.rtl, + child: TextButton.icon( + onPressed: () { + context.push('/genres'); + }, + icon: const Icon(SpotubeIcons.angleRight), + label: Text( + "Browse All", + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.secondary, + ), + ), + ), + ), + ], + ), + ), + ), + const SliverGap(8), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: Skeletonizer.sliver( + enabled: categoriesQuery.isLoading, + child: SliverGrid.builder( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: mediaQuery.mdAndDown ? 200 : 250, + mainAxisExtent: 50, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: categoriesQuery.isLoading + ? mediaQuery.mdAndDown + ? 6 + : 10 + : categories.length, + itemBuilder: (context, index) { + final category = + categories.elementAtOrNull(index) ?? FakeData.category; + + return HookBuilder(builder: (context) { + final (:gradient, :textColor) = useMemoized( + () { + final gradient = + gradients[Random().nextInt(gradients.length)]; + final text = gradient.colors + .take(2) + .any((c) => c.computeLuminance() > 0.5) + ? Colors.grey[900] + : Colors.white; + return ( + gradient: LinearGradient( + colors: gradient.colors + .map((c) => c.withOpacity(0.8)) + .toList(), + ), + textColor: text + ); + }, + [], + ); + + return InkWell( + onTap: () { + context.push('/genre/${category.id}', extra: category); + }, + borderRadius: BorderRadius.circular(8), + child: Ink( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: DecorationImage( + image: UniversalImage.imageProvider( + category.icons!.first.url!, + ), + fit: BoxFit.cover, + ), + ), + child: Ink( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + color: colorScheme.surfaceVariant, + gradient: categoriesQuery.isLoading ? null : gradient, + ), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + category.name!, + style: textTheme.titleMedium + ?.copyWith(color: textColor), + ), + ), + ), + ), + ); + }); + }, + ), + ), + ), + ], + ); + } +} diff --git a/lib/components/home/sections/made_for_user.dart b/lib/components/home/sections/made_for_user.dart new file mode 100644 index 00000000..a3f96899 --- /dev/null +++ b/lib/components/home/sections/made_for_user.dart @@ -0,0 +1,35 @@ +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'; + +class HomeMadeForUserSection extends HookConsumerWidget { + const HomeMadeForUserSection({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final madeForUser = useQueries.views.get(ref, "made-for-x-hub"); + + return SliverList.builder( + itemCount: madeForUser.data?["content"]?["items"]?.length ?? 0, + itemBuilder: (context, index) { + final item = madeForUser.data?["content"]?["items"]?[index]; + final playlists = item["content"]?["items"] + ?.where((itemL2) => itemL2["type"] == "playlist") + .map((itemL2) => PlaylistSimple.fromJson(itemL2)) + .toList() + .cast() ?? + []; + if (playlists.isEmpty) return const SizedBox.shrink(); + return HorizontalPlaybuttonCardView( + items: playlists, + title: Text(item["name"] ?? ""), + hasNextPage: false, + isLoadingNextPage: false, + onFetchMore: () {}, + ); + }, + ); + } +} diff --git a/lib/components/home/sections/new_releases.dart b/lib/components/home/sections/new_releases.dart new file mode 100644 index 00000000..0f4a046a --- /dev/null +++ b/lib/components/home/sections/new_releases.dart @@ -0,0 +1,56 @@ +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/library/user_albums.dart b/lib/components/library/user_albums.dart index 014a84f6..200d1c59 100644 --- a/lib/components/library/user_albums.dart +++ b/lib/components/library/user_albums.dart @@ -3,13 +3,17 @@ 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/shimmers/shimmer_playbutton_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/hooks/use_breakpoint_value.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/services/queries/queries.dart'; @@ -23,76 +27,98 @@ class UserAlbums extends HookConsumerWidget { final auth = ref.watch(AuthenticationNotifier.provider); final albumsQuery = useQueries.album.ofMine(ref); - final spacing = useBreakpointValue( - xs: 0, - sm: 0, - others: 20, - ); + 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 albumsQuery.data?.toList() ?? []; + return allAlbums; } - return albumsQuery.data - ?.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.data, searchText.value]); + 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: SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SearchBar( + 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, ), - const SizedBox(height: 20), - AnimatedCrossFade( - duration: const Duration(milliseconds: 300), - firstChild: Container( - alignment: Alignment.topLeft, - padding: const EdgeInsets.all(16.0), - child: const ShimmerPlaybuttonCard(count: 7), + ), + ), + ), + 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), + ) + ], + ), ), - secondChild: Wrap( - spacing: spacing, // gap between adjacent chips - runSpacing: 20, // gap between lines - alignment: WrapAlignment.center, - children: albums - .map((album) => AlbumCard( - TypeConversionUtils.simpleAlbum_X_Album(album), - )) - .toList(), - ), - crossFadeState: albumsQuery.isLoading || - !albumsQuery.hasData || - searchText.value.isNotEmpty - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, ), - ], + ), ), ), ), diff --git a/lib/components/library/user_artists.dart b/lib/components/library/user_artists.dart index c90d8010..36b8528e 100644 --- a/lib/components/library/user_artists.dart +++ b/lib/components/library/user_artists.dart @@ -3,10 +3,14 @@ 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'; @@ -78,18 +82,38 @@ class UserArtists extends HookConsumerWidget { onRefresh: () async { await artistQuery.refresh(); }, - child: SingleChildScrollView( + child: InterScrollbar( controller: controller, - child: SizedBox( - width: double.infinity, - child: SafeArea( - child: Center( - child: Wrap( - spacing: 15, - runSpacing: 5, - children: filteredArtists - .mapIndexed((index, artist) => ArtistCard(artist)) - .toList(), + 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_downloads/download_item.dart b/lib/components/library/user_downloads/download_item.dart index ae8a2513..10dec410 100644 --- a/lib/components/library/user_downloads/download_item.dart +++ b/lib/components/library/user_downloads/download_item.dart @@ -5,9 +5,9 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/spotube_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'; class DownloadItem extends HookConsumerWidget { @@ -24,25 +24,25 @@ class DownloadItem extends HookConsumerWidget { final taskStatus = useState(null); useEffect(() { - if (track is! SpotubeTrack) return null; - final notifier = downloadManager.getStatusNotifier(track as SpotubeTrack); + if (track is! SourcedTrack) return null; + final notifier = downloadManager.getStatusNotifier(track as SourcedTrack); taskStatus.value = notifier?.value; - listener() { + + void listener() { taskStatus.value = notifier?.value; } - downloadManager - .getStatusNotifier(track as SpotubeTrack) - ?.addListener(listener); + notifier?.addListener(listener); return () { - downloadManager - .getStatusNotifier(track as SpotubeTrack) - ?.removeListener(listener); + notifier?.removeListener(listener); }; }, [track]); + final isQueryingSourceInfo = + taskStatus.value == null || track is! SourcedTrack; + return ListTile( leading: Padding( padding: const EdgeInsets.symmetric(horizontal: 5), @@ -63,7 +63,7 @@ class DownloadItem extends HookConsumerWidget { track.artists ?? [], mainAxisAlignment: WrapAlignment.start, ), - trailing: taskStatus.value == null || track is! SpotubeTrack + trailing: isQueryingSourceInfo ? Text( context.l10n.querying_info, style: Theme.of(context).textTheme.labelMedium, @@ -72,7 +72,7 @@ class DownloadItem extends HookConsumerWidget { DownloadStatus.downloading => HookBuilder(builder: (context) { final taskProgress = useListenable(useMemoized( () => downloadManager - .getProgressNotifier(track as SpotubeTrack), + .getProgressNotifier(track as SourcedTrack), [track], )); return SizedBox( @@ -86,13 +86,13 @@ class DownloadItem extends HookConsumerWidget { IconButton( icon: const Icon(SpotubeIcons.pause), onPressed: () { - downloadManager.pause(track as SpotubeTrack); + downloadManager.pause(track as SourcedTrack); }), const SizedBox(width: 10), IconButton( icon: const Icon(SpotubeIcons.close), onPressed: () { - downloadManager.cancel(track as SpotubeTrack); + downloadManager.cancel(track as SourcedTrack); }), ], ), @@ -104,13 +104,13 @@ class DownloadItem extends HookConsumerWidget { IconButton( icon: const Icon(SpotubeIcons.play), onPressed: () { - downloadManager.resume(track as SpotubeTrack); + downloadManager.resume(track as SourcedTrack); }), const SizedBox(width: 10), IconButton( icon: const Icon(SpotubeIcons.close), onPressed: () { - downloadManager.cancel(track as SpotubeTrack); + downloadManager.cancel(track as SourcedTrack); }) ], ), @@ -126,7 +126,7 @@ class DownloadItem extends HookConsumerWidget { IconButton( icon: const Icon(SpotubeIcons.refresh), onPressed: () { - downloadManager.retry(track as SpotubeTrack); + downloadManager.retry(track as SourcedTrack); }, ), ], @@ -137,7 +137,7 @@ class DownloadItem extends HookConsumerWidget { DownloadStatus.queued => IconButton( icon: const Icon(SpotubeIcons.close), onPressed: () { - downloadManager.removeFromQueue(track as SpotubeTrack); + downloadManager.removeFromQueue(track as SourcedTrack); }), }, ); diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index a36be283..f4e782d9 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -1,7 +1,6 @@ import 'dart:io'; -import 'package:catcher/catcher.dart'; -import 'package:device_info_plus/device_info_plus.dart'; +import 'package:catcher_2/catcher_2.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -12,20 +11,20 @@ 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:permission_handler/permission_handler.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/shimmers/shimmer_track_tile.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_table/track_tile.dart'; +import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_async_effect.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/utils/platform.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; @@ -76,14 +75,14 @@ final localTracksProvider = FutureProvider>((ref) async { final mimetype = lookupMimeType(file.path); return mimetype != null && supportedAudioTypes.contains(mimetype); }).map( - (f) async { + (file) async { try { - final metadata = await MetadataGod.readMetadata(file: f.path); + final metadata = await MetadataGod.readMetadata(file: file.path); final imageFile = File(join( (await getTemporaryDirectory()).path, "spotube", - basenameWithoutExtension(f.path) + + basenameWithoutExtension(file.path) + imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!, )); if (!await imageFile.exists() && metadata.picture != null) { @@ -94,12 +93,12 @@ final localTracksProvider = FutureProvider>((ref) async { ); } - return {"metadata": metadata, "file": f, "art": imageFile.path}; + return {"metadata": metadata, "file": file, "art": imageFile.path}; } catch (e, stack) { if (e is FfiException) { - return {"file": f}; + return {"file": file}; } - Catcher.reportCheckedError(e, stack); + Catcher2.reportCheckedError(e, stack); return {}; } }, @@ -123,7 +122,7 @@ final localTracksProvider = FutureProvider>((ref) async { return tracks; } catch (e, stack) { - Catcher.reportCheckedError(e, stack); + Catcher2.reportCheckedError(e, stack); return []; } }); @@ -131,7 +130,7 @@ final localTracksProvider = FutureProvider>((ref) async { class UserLocalTracks extends HookConsumerWidget { const UserLocalTracks({Key? key}) : super(key: key); - void playLocalTracks( + Future playLocalTracks( WidgetRef ref, List tracks, { LocalTrack? currentTrack, @@ -160,39 +159,13 @@ class UserLocalTracks extends HookConsumerWidget { final trackSnapshot = ref.watch(localTracksProvider); final isPlaylistPlaying = playlist.containsTracks(trackSnapshot.value ?? []); - final isMounted = useIsMounted(); final searchController = useTextEditingController(); useValueListenable(searchController); final searchFocus = useFocusNode(); final isFiltering = useState(false); - useAsyncEffect( - () async { - if (!kIsMobile) return; - - final androidInfo = await DeviceInfoPlugin().androidInfo; - - final hasNoStoragePerm = androidInfo.version.sdkInt < 33 && - !await Permission.storage.isGranted && - !await Permission.storage.isLimited; - - final hasNoAudioPerm = androidInfo.version.sdkInt >= 33 && - !await Permission.audio.isGranted && - !await Permission.audio.isLimited; - - if (hasNoStoragePerm) { - await Permission.storage.request(); - if (isMounted()) ref.refresh(localTracksProvider); - } - if (hasNoAudioPerm) { - await Permission.audio.request(); - if (isMounted()) ref.refresh(localTracksProvider); - } - }, - null, - [], - ); + final controller = useScrollController(); return Column( children: [ @@ -203,10 +176,10 @@ class UserLocalTracks extends HookConsumerWidget { const SizedBox(width: 10), FilledButton( onPressed: trackSnapshot.value != null - ? () { + ? () async { if (trackSnapshot.value?.isNotEmpty == true) { if (!isPlaylistPlaying) { - playLocalTracks( + await playLocalTracks( ref, trackSnapshot.value!, ); @@ -228,7 +201,8 @@ class UserLocalTracks extends HookConsumerWidget { ), const Spacer(), ExpandableSearchButton( - isFiltering: isFiltering, + isFiltering: isFiltering.value, + onPressed: (value) => isFiltering.value = value, searchFocus: searchFocus, ), const SizedBox(width: 10), @@ -251,7 +225,8 @@ class UserLocalTracks extends HookConsumerWidget { ExpandableSearchField( searchController: searchController, searchFocus: searchFocus, - isFiltering: isFiltering, + isFiltering: isFiltering.value, + onChangeFiltering: (value) => isFiltering.value = value, ), trackSnapshot.when( data: (tracks) { @@ -281,35 +256,64 @@ class UserLocalTracks extends HookConsumerWidget { .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: ListView.builder( - physics: const AlwaysScrollableScrollPhysics(), - itemCount: filteredTracks.length, - itemBuilder: (context, index) { - final track = filteredTracks[index]; - return TrackTile( - index: index, - track: track, - userPlaylist: false, - onTap: () { - playLocalTracks( - ref, - sortedTracks, - currentTrack: track, + 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: () => - const Expanded(child: ShimmerTrackTile(noSliver: true)), + loading: () => Expanded( + child: Skeletonizer( + enabled: true, + child: ListView.builder( + itemCount: 5, + itemBuilder: (context, index) => + TrackTile(track: FakeData.track, index: index), + ), + ), + ), error: (error, stackTrace) => Text(error.toString() + stackTrace.toString()), ) diff --git a/lib/components/library/user_playlists.dart b/lib/components/library/user_playlists.dart index 3f4029fe..32e91ed6 100644 --- a/lib/components/library/user_playlists.dart +++ b/lib/components/library/user_playlists.dart @@ -4,14 +4,17 @@ import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:collection/collection.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/playlist/playlist_create_dialog.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; +import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/playlist/playlist_card.dart'; import 'package:spotube/components/shared/waypoint.dart'; +import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/services/queries/queries.dart'; @@ -34,21 +37,21 @@ class UserPlaylists extends HookConsumerWidget { ); final likedTracksPlaylist = useMemoized( - () => PlaylistSimple() - ..name = context.l10n.liked_tracks - ..description = context.l10n.liked_tracks_description - ..type = "playlist" - ..collaborative = false - ..public = false - ..id = "user-liked-tracks" - ..images = [ - Image() - ..height = 300 - ..width = 300 - ..url = - "https://t.scdn.co/images/3099b3803ad9496896c43f22fe9be8c4.png" - ], - [context.l10n]); + () => PlaylistSimple() + ..name = context.l10n.liked_tracks + ..description = context.l10n.liked_tracks_description + ..type = "playlist" + ..collaborative = false + ..public = false + ..id = "user-liked-tracks" + ..images = [ + Image() + ..height = 300 + ..width = 300 + ..url = "assets/liked-tracks.jpg" + ], + [context.l10n], + ); final playlists = useMemoized( () { @@ -79,60 +82,79 @@ class UserPlaylists extends HookConsumerWidget { return RefreshIndicator( onRefresh: playlistsQuery.refresh, - child: SingleChildScrollView( - controller: controller, - physics: const AlwaysScrollableScrollPhysics(), - child: Waypoint( + child: SafeArea( + child: InterScrollbar( controller: controller, - onTouchEdge: () { - if (playlistsQuery.hasNextPage) { - playlistsQuery.fetchNext(); - } - }, - child: SafeArea( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(10), - child: SearchBar( - onChanged: (value) => searchText.value = value, - hintText: context.l10n.filter_playlists, - leading: const Icon(SpotubeIcons.filter), - ), - ), - AnimatedCrossFade( - duration: const Duration(milliseconds: 300), - crossFadeState: playlistsQuery.isLoadingPage || - !playlistsQuery.hasPageData - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - firstChild: - const Center(child: ShimmerPlaybuttonCard(count: 7)), - secondChild: Wrap( - runSpacing: 10, - alignment: WrapAlignment.center, - children: [ - 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), - ], + 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), ), - ...playlists.map((playlist) => PlaylistCard(playlist)) - ], - ), + ), + Row( + children: [ + const SizedBox(width: 10), + const PlaylistCreateDialogButton(), + const SizedBox(width: 10), + ElevatedButton.icon( + icon: const Icon(SpotubeIcons.magic), + label: Text(context.l10n.generate_playlist), + onPressed: () { + GoRouter.of(context).push("/library/generate"); + }, + ), + const SizedBox(width: 10), + ], + ), + ], ), - ], - ), + ), + const SliverToBoxAdapter( + child: SizedBox(height: 10), + ), + SliverLayoutBuilder(builder: (context, constrains) { + return SliverGrid.builder( + itemCount: playlists.isEmpty ? 6 : playlists.length + 1, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: constrains.smAndDown ? 225 : 250, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) { + if (playlists.isNotEmpty && index == playlists.length) { + if (!playlistsQuery.hasNextPage) { + return const SizedBox.shrink(); + } + + return Waypoint( + controller: controller, + isGrid: true, + onTouchEdge: playlistsQuery.fetchNext, + child: Skeletonizer( + enabled: true, + child: PlaylistCard(FakeData.playlistSimple), + ), + ); + } + + return PlaylistCard( + playlists.elementAtOrNull(index) ?? + FakeData.playlistSimple, + ); + }, + ); + }) + ], ), ), ), diff --git a/lib/hooks/use_synced_lyrics.dart b/lib/components/lyrics/use_synced_lyrics.dart similarity index 100% rename from lib/hooks/use_synced_lyrics.dart rename to lib/components/lyrics/use_synced_lyrics.dart diff --git a/lib/pages/player/player.dart b/lib/components/player/player.dart similarity index 70% rename from lib/pages/player/player.dart rename to lib/components/player/player.dart index e925ba60..33283c3e 100644 --- a/lib/pages/player/player.dart +++ b/lib/components/player/player.dart @@ -15,10 +15,11 @@ import 'package:spotube/components/shared/animated_gradient.dart'; import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/panels/sliding_up_panel.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_custom_status_bar_color.dart'; -import 'package:spotube/hooks/use_palette_color.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'; @@ -26,8 +27,12 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerView extends HookConsumerWidget { + final PanelController panelController; + final ScrollController scrollController; const PlayerView({ Key? key, + required this.panelController, + required this.scrollController, }) : super(key: key); @override @@ -45,7 +50,7 @@ class PlayerView extends HookConsumerWidget { useEffect(() { if (mediaQuery.lgAndUp) { WidgetsBinding.instance.addPostFrameCallback((_) { - GoRouter.of(context).pop(); + panelController.close(); }); } return null; @@ -60,64 +65,103 @@ class PlayerView extends HookConsumerWidget { ); final palette = usePaletteGenerator(albumArt); - final bgColor = palette.dominantColor?.color ?? theme.colorScheme.primary; final titleTextColor = palette.dominantColor?.titleTextColor; final bodyTextColor = palette.dominantColor?.bodyTextColor; + final bgColor = palette.dominantColor?.color ?? theme.colorScheme.primary; + + final GlobalKey scaffoldKey = + useMemoized(() => GlobalKey(), []); + + useEffect(() { + for (final renderView in WidgetsBinding.instance.renderViews) { + renderView.automaticSystemUiAdjustment = false; + } + + return () { + for (final renderView in WidgetsBinding.instance.renderViews) { + renderView.automaticSystemUiAdjustment = true; + } + }; + }, [panelController.isPanelOpen]); + useCustomStatusBarColor( bgColor, - GoRouterState.of(context).matchedLocation == "/player", + panelController.isPanelOpen, noSetBGColor: true, + automaticSystemUiAdjustment: false, ); - return IconTheme( - data: theme.iconTheme.copyWith(color: bodyTextColor), - child: Scaffold( - appBar: PageWindowTitleBar( - backgroundColor: Colors.transparent, - foregroundColor: titleTextColor, - toolbarOpacity: 1, - leading: const BackButton(), - actions: [ - IconButton( - icon: const Icon(SpotubeIcons.info, size: 18), - tooltip: context.l10n.details, - style: IconButton.styleFrom(foregroundColor: bodyTextColor), - onPressed: currentTrack == null - ? null - : () { - showDialog( - context: context, - builder: (context) { - return TrackDetailsDialog( - track: currentTrack, - ); - }); - }, - ) + final topPadding = MediaQueryData.fromView(View.of(context)).padding.top; + + return PopScope( + canPop: false, + onPopInvoked: (didPop) async { + panelController.close(); + }, + child: IconTheme( + data: theme.iconTheme.copyWith(color: bodyTextColor), + child: AnimateGradient( + animateAlignments: true, + primaryBegin: Alignment.topLeft, + primaryEnd: Alignment.bottomLeft, + secondaryBegin: Alignment.bottomRight, + secondaryEnd: Alignment.topRight, + duration: const Duration(seconds: 15), + primaryColors: [ + palette.dominantColor?.color ?? theme.colorScheme.primary, + palette.mutedColor?.color ?? theme.colorScheme.secondary, ], - ), - extendBodyBehindAppBar: true, - body: SizedBox( - height: double.infinity, - child: AnimateGradient( - animateAlignments: true, - primaryBegin: Alignment.topLeft, - primaryEnd: Alignment.bottomLeft, - secondaryBegin: Alignment.bottomRight, - secondaryEnd: Alignment.topRight, - duration: const Duration(seconds: 15), - primaryColors: [ - palette.dominantColor?.color ?? theme.colorScheme.primary, - palette.mutedColor?.color ?? theme.colorScheme.secondary, - ], - secondaryColors: [ - (palette.darkVibrantColor ?? palette.lightVibrantColor)?.color ?? - theme.colorScheme.primaryContainer, - (palette.darkMutedColor ?? palette.lightMutedColor)?.color ?? - theme.colorScheme.secondaryContainer, - ], - child: SingleChildScrollView( + secondaryColors: [ + (palette.darkVibrantColor ?? palette.lightVibrantColor)?.color ?? + theme.colorScheme.primaryContainer, + (palette.darkMutedColor ?? palette.lightMutedColor)?.color ?? + theme.colorScheme.secondaryContainer, + ], + child: Scaffold( + key: scaffoldKey, + backgroundColor: Colors.transparent, + appBar: PreferredSize( + preferredSize: Size.fromHeight( + kToolbarHeight + topPadding, + ), + child: ForceDraggableWidget( + child: Padding( + padding: EdgeInsets.only(top: topPadding), + child: PageWindowTitleBar( + backgroundColor: Colors.transparent, + foregroundColor: titleTextColor, + toolbarOpacity: 1, + leading: IconButton( + icon: const Icon(SpotubeIcons.angleDown, size: 18), + onPressed: panelController.close, + ), + actions: [ + IconButton( + icon: const Icon(SpotubeIcons.info, size: 18), + tooltip: context.l10n.details, + style: IconButton.styleFrom( + foregroundColor: bodyTextColor), + onPressed: currentTrack == null + ? null + : () { + showDialog( + context: context, + builder: (context) { + return TrackDetailsDialog( + track: currentTrack, + ); + }); + }, + ) + ], + ), + ), + ), + ), + extendBodyBehindAppBar: true, + body: SingleChildScrollView( + controller: scrollController, child: Container( alignment: Alignment.center, width: double.infinity, @@ -128,27 +172,29 @@ class PlayerView extends HookConsumerWidget { padding: const EdgeInsets.all(8.0), child: Column( children: [ - Container( - margin: const EdgeInsets.all(8), - constraints: const BoxConstraints( - maxHeight: 300, maxWidth: 300), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - spreadRadius: 2, - blurRadius: 10, - offset: Offset(0, 0), + ForceDraggableWidget( + child: Container( + margin: const EdgeInsets.all(8), + constraints: const BoxConstraints( + maxHeight: 300, maxWidth: 300), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + spreadRadius: 2, + blurRadius: 10, + offset: Offset(0, 0), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: UniversalImage( + path: albumArt, + placeholder: Assets.albumPlaceholder.path, + fit: BoxFit.cover, ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: UniversalImage( - path: albumArt, - placeholder: Assets.albumPlaceholder.path, - fit: BoxFit.cover, ), ), ), @@ -190,7 +236,7 @@ class PlayerView extends HookConsumerWidget { color: bodyTextColor, ), onRouteChange: (route) { - GoRouter.of(context).pop(); + panelController.close(); GoRouter.of(context).push(route); }, ), diff --git a/lib/components/player/player_actions.dart b/lib/components/player/player_actions.dart index 78fb53b7..7a248aa5 100644 --- a/lib/components/player/player_actions.dart +++ b/lib/components/player/player_actions.dart @@ -5,15 +5,14 @@ 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/player_queue.dart'; import 'package:spotube/components/player/sibling_tracks_sheet.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/heart_button.dart'; +import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/logger.dart'; -import 'package:spotube/models/spotube_track.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'; @@ -36,6 +35,7 @@ class PlayerActions extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { + final mediaQuery = MediaQuery.of(context); final playlist = ref.watch(ProxyPlaylistNotifier.provider); final isLocalTrack = playlist.activeTrack is LocalTrack; ref.watch(downloadManagerProvider); @@ -87,23 +87,7 @@ class PlayerActions extends HookConsumerWidget { tooltip: context.l10n.queue, onPressed: playlist.activeTrack != null ? () { - showModalBottomSheet( - context: context, - isDismissible: true, - enableDrag: true, - isScrollControlled: true, - backgroundColor: Colors.black12, - barrierColor: Colors.black12, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * .7, - ), - builder: (context) { - return PlayerQueue(floating: floatingQueue); - }, - ); + Scaffold.of(context).openEndDrawer(); } : null, ), @@ -120,6 +104,7 @@ class PlayerActions extends HookConsumerWidget { isScrollControlled: true, backgroundColor: Colors.black12, barrierColor: Colors.black12, + elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), diff --git a/lib/components/player/player_controls.dart b/lib/components/player/player_controls.dart index 7ae4fa82..1000af18 100644 --- a/lib/components/player/player_controls.dart +++ b/lib/components/player/player_controls.dart @@ -7,12 +7,12 @@ 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/hooks/use_progress.dart'; +import 'package:spotube/extensions/duration.dart'; +import 'package:spotube/components/player/use_progress.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/loop_mode.dart'; -import 'package:spotube/utils/primitive_utils.dart'; class PlayerControls extends HookConsumerWidget { final PaletteGenerator? palette; @@ -113,19 +113,6 @@ class PlayerControls extends HookConsumerWidget { :progressStatic ) = useProgress(ref); - final totalMinutes = PrimitiveUtils.zeroPadNumStr( - duration.inMinutes.remainder(60), - ); - final totalSeconds = PrimitiveUtils.zeroPadNumStr( - duration.inSeconds.remainder(60), - ); - final currentMinutes = PrimitiveUtils.zeroPadNumStr( - position.inMinutes.remainder(60), - ); - final currentSeconds = PrimitiveUtils.zeroPadNumStr( - position.inSeconds.remainder(60), - ); - final progress = useState( useMemoized(() => progressStatic, []), ); @@ -173,8 +160,8 @@ class PlayerControls extends HookConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text("$currentMinutes:$currentSeconds"), - Text("$totalMinutes:$totalSeconds"), + Text(position.toHumanReadableString()), + Text(duration.toHumanReadableString()), ], ), ), diff --git a/lib/components/player/player_overlay.dart b/lib/components/player/player_overlay.dart index f4984ad2..2d63811e 100644 --- a/lib/components/player/player_overlay.dart +++ b/lib/components/player/player_overlay.dart @@ -2,16 +2,17 @@ import 'dart:ui'; 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/spotube_icons.dart'; import 'package:spotube/components/player/player_track_details.dart'; +import 'package:spotube/components/root/spotube_navigation_bar.dart'; +import 'package:spotube/components/shared/panels/sliding_up_panel.dart'; +import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/intents.dart'; -import 'package:spotube/hooks/use_progress.dart'; +import 'package:spotube/components/player/use_progress.dart'; +import 'package:spotube/components/player/player.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/utils/service_utils.dart'; class PlayerOverlay extends HookConsumerWidget { final String albumArt; @@ -39,22 +40,33 @@ class PlayerOverlay extends HookConsumerWidget { topRight: Radius.circular(10), ); - return GestureDetector( - onVerticalDragEnd: (details) { - int sensitivity = 8; - if (details.primaryVelocity != null && - details.primaryVelocity! < -sensitivity) { - ServiceUtils.push(context, "/player"); - } + final mediaQuery = MediaQuery.of(context); + + final panelController = useMemoized(() => PanelController(), []); + final scrollController = useScrollController(); + + useEffect(() { + return () { + panelController.dispose(); + }; + }, []); + + return SlidingUpPanel( + maxHeight: mediaQuery.size.height, + backdropEnabled: false, + minHeight: canShow ? 53 : 0, + onPanelSlide: (position) { + final invertedPosition = 1 - position; + ref.read(navigationPanelHeight.notifier).state = 50 * invertedPosition; }, - child: ClipRRect( + controller: panelController, + collapsed: ClipRRect( borderRadius: radius, child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), child: AnimatedContainer( duration: const Duration(milliseconds: 250), - width: MediaQuery.of(context).size.width, - height: canShow ? 53 : 0, + width: mediaQuery.size.width, decoration: BoxDecoration( color: theme.colorScheme.secondaryContainer.withOpacity(.8), borderRadius: radius, @@ -95,11 +107,13 @@ class PlayerOverlay extends HookConsumerWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => - GoRouter.of(context).push("/player"), + child: GestureDetector( + onTap: () { + panelController.open(); + }, + child: Container( + width: double.infinity, + color: Colors.transparent, child: PlayerTrackDetails( albumArt: albumArt, color: textColor, @@ -114,7 +128,9 @@ class PlayerOverlay extends HookConsumerWidget { SpotubeIcons.skipBack, color: textColor, ), - onPressed: playlistNotifier.previous, + onPressed: playlist.isFetching + ? null + : playlistNotifier.previous, ), Consumer( builder: (context, ref, _) { @@ -143,7 +159,9 @@ class PlayerOverlay extends HookConsumerWidget { SpotubeIcons.skipForward, color: textColor, ), - onPressed: playlistNotifier.next, + onPressed: playlist.isFetching + ? null + : playlistNotifier.next, ), ], ), @@ -157,6 +175,30 @@ class PlayerOverlay extends HookConsumerWidget { ), ), ), + scrollController: scrollController, + panelBuilder: (position) { + // this is the reason we're getting an update + final navigationHeight = ref.watch(navigationPanelHeight); + + if (navigationHeight == 50) return const SizedBox(); + + return IgnorePointer( + ignoring: !panelController.isPanelOpen, + child: AnimatedContainer( + clipBehavior: Clip.antiAlias, + duration: const Duration(milliseconds: 250), + decoration: navigationHeight == 0 + ? const BoxDecoration(borderRadius: BorderRadius.zero) + : const BoxDecoration(borderRadius: radius), + child: IgnoreDraggableWidget( + child: PlayerView( + panelController: panelController, + scrollController: scrollController, + ), + ), + ), + ); + }, ); } } diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 599da26e..2784fb5f 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -1,16 +1,22 @@ 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/track_table/track_tile.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/use_auto_scroll_controller.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; @@ -24,21 +30,44 @@ class PlayerQueue extends HookConsumerWidget { 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; - - if (tracks.isEmpty) { - return const NotFound(vertical: true); - } - final borderRadius = floating - ? BorderRadius.circular(10) + ? 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; @@ -50,103 +79,203 @@ class PlayerQueue extends HookConsumerWidget { return null; }, []); - return BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 12.0, - sigmaY: 12.0, - ), - child: Container( - margin: EdgeInsets.all(floating ? 8.0 : 0), - padding: const EdgeInsets.only( - top: 5.0, + if (tracks.isEmpty) { + return const NotFound(vertical: true); + } + + return ClipRRect( + borderRadius: borderRadius, + clipBehavior: Clip.hardEdge, + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 15, + sigmaY: 15, ), - decoration: BoxDecoration( - color: theme.scaffoldBackgroundColor.withOpacity(0.5), - borderRadius: borderRadius, - ), - child: Column( - children: [ - Container( - height: 5, - width: 100, - margin: const EdgeInsets.only(bottom: 5, top: 2), - decoration: BoxDecoration( - color: headlineColor, - borderRadius: BorderRadius.circular(20), - ), - ), - Row( + 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: [ - const SizedBox(width: 10), - Text( - context.l10n.tracks_in_queue(tracks.length), - style: TextStyle( - color: headlineColor, - fontWeight: FontWeight.bold, - fontSize: 18, + if (!floating) + Container( + height: 5, + width: 100, + margin: const EdgeInsets.only(bottom: 5, top: 2), + decoration: BoxDecoration( + color: headlineColor, + borderRadius: BorderRadius.circular(20), + ), ), - ), - const Spacer(), - 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), - Flexible( - child: ReorderableListView.builder( - onReorder: (oldIndex, newIndex) { - playlistNotifier.moveTrack(oldIndex, newIndex); - }, - scrollController: controller, - itemCount: tracks.length, - shrinkWrap: true, - buildDefaultDragHandles: false, - 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), - ), - ], + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (mediaQuery.mdAndUp || !isSearching.value) ...[ + const SizedBox(width: 10), + Text( + context.l10n.tracks_in_queue(tracks.length), + style: TextStyle( + color: headlineColor, + fontWeight: FontWeight.bold, + fontSize: 18, ), ), - ); - }), + const Spacer(), + ], + if (mediaQuery.mdAndUp || isSearching.value) + TextField( + onChanged: (value) { + searchText.value = value; + }, + decoration: InputDecoration( + hintText: context.l10n.search, + isDense: true, + prefixIcon: mediaQuery.smAndDown + ? IconButton( + icon: const Icon( + Icons.arrow_back_ios_new_outlined, + ), + onPressed: () { + isSearching.value = false; + searchText.value = ''; + }, + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size.square(20), + ), + ) + : const Icon(SpotubeIcons.filter), + constraints: BoxConstraints( + maxHeight: 40, + maxWidth: mediaQuery.smAndDown + ? mediaQuery.size.width - 40 + : 300, + ), + ), + ) + else + IconButton.filledTonal( + icon: const Icon(SpotubeIcons.filter), + onPressed: () { + isSearching.value = !isSearching.value; + }, + ), + if (mediaQuery.mdAndUp || !isSearching.value) ...[ + const SizedBox(width: 10), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: + theme.scaffoldBackgroundColor.withOpacity(0.5), + foregroundColor: theme.textTheme.headlineSmall?.color, + ), + child: Row( + children: [ + const Icon(SpotubeIcons.playlistRemove), + const SizedBox(width: 5), + Text(context.l10n.clear_all), + ], + ), + onPressed: () { + playlistNotifier.stop(); + Navigator.of(context).pop(); + }, + ), + const SizedBox(width: 10), + ], + ], + ), + const SizedBox(height: 10), + if (!isSearching.value && searchText.value.isEmpty) + Flexible( + child: ReorderableListView.builder( + onReorder: (oldIndex, newIndex) { + playlistNotifier.moveTrack(oldIndex, newIndex); + }, + scrollController: controller, + itemCount: tracks.length, + shrinkWrap: true, + buildDefaultDragHandles: false, + onReorderStart: (index) { + HapticFeedback.selectionClick(); + }, + onReorderEnd: (index) { + HapticFeedback.selectionClick(); + }, + itemBuilder: (context, i) { + final track = tracks.elementAt(i); + return AutoScrollTag( + key: ValueKey(i), + controller: controller, + index: i, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8.0), + child: TrackTile( + index: i, + track: track, + onTap: () async { + if (playlist.activeTrack?.id == track.id) { + return; + } + await playlistNotifier.jumpToTrack(track); + }, + leadingActions: [ + ReorderableDragStartListener( + index: i, + child: const Icon(SpotubeIcons.dragHandle), + ), + ], + ), + ), + ); + }, + ), + ) + else + Flexible( + child: InterScrollbar( + controller: controller, + child: ListView.builder( + controller: controller, + itemCount: filteredTracks.length, + itemBuilder: (context, i) { + final track = filteredTracks.elementAt(i); + return Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8.0), + child: TrackTile( + index: i, + track: track, + onTap: () async { + if (playlist.activeTrack?.id == track.id) { + return; + } + await playlistNotifier.jumpToTrack(track); + }, + ), + ); + }, + ), + ), + ), + ], ), - ], + ), ), ), ); diff --git a/lib/components/player/player_track_details.dart b/lib/components/player/player_track_details.dart index d6f275fa..66cb9ef5 100644 --- a/lib/components/player/player_track_details.dart +++ b/lib/components/player/player_track_details.dart @@ -4,6 +4,7 @@ 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/extensions/constrains.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -44,10 +45,12 @@ class PlayerTrackDetails extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 4), - Text( + LinkText( playback.activeTrack?.name ?? "", + "/track/${playback.activeTrack?.id}", + push: true, overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( + style: theme.textTheme.bodyMedium!.copyWith( color: color, ), ), @@ -66,8 +69,10 @@ class PlayerTrackDetails extends HookConsumerWidget { flex: 1, child: Column( children: [ - Text( + LinkText( playback.activeTrack?.name ?? "", + "/track/${playback.activeTrack?.id}", + push: true, overflow: TextOverflow.ellipsis, style: TextStyle(fontWeight: FontWeight.bold, color: color), ), diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index b7d802f9..58b1ca8c 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -1,25 +1,47 @@ import 'dart:ui'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart' hide Offset; +import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_debounce.dart'; -import 'package:spotube/models/matched_track.dart'; -import 'package:spotube/models/spotube_track.dart'; +import 'package:spotube/extensions/duration.dart'; +import 'package:spotube/hooks/utils/use_debounce.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/provider/youtube_provider.dart'; -import 'package:spotube/services/youtube/youtube.dart'; -import 'package:spotube/utils/primitive_utils.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/services/sourced_track/models/source_info.dart'; +import 'package:spotube/services/sourced_track/models/video_info.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:spotube/services/sourced_track/sources/jiosaavn.dart'; +import 'package:spotube/services/sourced_track/sources/piped.dart'; +import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; +final sourceInfoToIconMap = { + YoutubeSourceInfo: const Icon(SpotubeIcons.youtube, color: Color(0xFFFF0000)), + JioSaavnSourceInfo: Container( + height: 30, + width: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(90), + image: DecorationImage( + image: Assets.jiosaavn.provider(), + fit: BoxFit.cover, + ), + ), + ), + PipedSourceInfo: const Icon(SpotubeIcons.piped), +}; + class SiblingTracksSheet extends HookConsumerWidget { final bool floating; const SiblingTracksSheet({ @@ -33,7 +55,6 @@ class SiblingTracksSheet extends HookConsumerWidget { final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final preferences = ref.watch(userPreferencesProvider); - final youtube = ref.watch(youtubeProvider); final isSearching = useState(false); final searchMode = useState(preferences.searchMode); @@ -55,20 +76,67 @@ class SiblingTracksSheet extends HookConsumerWidget { useValueListenable(searchController).text, ); + final controller = useScrollController(); + final searchRequest = useMemoized(() async { if (searchTerm.trim().isEmpty) { - return []; + return []; } + if (preferences.audioSource == AudioSource.jiosaavn) { + final resultsJioSaavn = + await jiosaavnClient.search.songs(searchTerm.trim()); + final results = await Future.wait( + resultsJioSaavn.results.mapIndexed((i, song) async { + final siblingType = JioSaavnSourcedTrack.toSiblingType(song); + return siblingType.info; + })); - return youtube.search(searchTerm.trim()); + final activeSourceInfo = + (playlist.activeTrack! as SourcedTrack).sourceInfo; + + return results + ..removeWhere((element) => element.id == activeSourceInfo.id) + ..insert( + 0, + activeSourceInfo, + ); + } else { + final resultsYt = await youtubeClient.search.search(searchTerm.trim()); + + final searchResults = await Future.wait( + resultsYt + .map(YoutubeVideoInfo.fromVideo) + .mapIndexed((i, video) async { + final siblingType = + await YoutubeSourcedTrack.toSiblingType(i, video); + return siblingType.info; + }), + ); + final activeSourceInfo = + (playlist.activeTrack! as SourcedTrack).sourceInfo; + return searchResults + ..removeWhere((element) => element.id == activeSourceInfo.id) + ..insert( + 0, + activeSourceInfo, + ); + } }, [ searchTerm, searchMode.value, + playlist.activeTrack, + preferences.audioSource, ]); - final siblings = playlist.isFetching == false - ? (playlist.activeTrack as SpotubeTrack).siblings - : []; + final siblings = useMemoized( + () => playlist.isFetching == false + ? [ + (playlist.activeTrack as SourcedTrack).sourceInfo, + ...(playlist.activeTrack as SourcedTrack).siblings, + ] + : [], + [playlist.isFetching, playlist.activeTrack], + ); final borderRadius = floating ? BorderRadius.circular(10) @@ -78,157 +146,173 @@ class SiblingTracksSheet extends HookConsumerWidget { ); useEffect(() { - if (playlist.activeTrack is SpotubeTrack && - (playlist.activeTrack as SpotubeTrack).siblings.isEmpty) { + if (playlist.activeTrack is SourcedTrack && + (playlist.activeTrack as SourcedTrack).siblings.isEmpty) { playlistNotifier.populateSibling(); } return null; }, [playlist.activeTrack]); - final itemBuilder = useCallback((YoutubeVideoInfo video) { - return ListTile( - title: Text(video.title), - leading: Padding( - padding: const EdgeInsets.all(8.0), - child: UniversalImage( - path: video.thumbnailUrl, - height: 60, - width: 60, - ), - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - ), - trailing: Text( - PrimitiveUtils.toReadableDuration(video.duration), - ), - subtitle: Text(video.channelName), - enabled: playlist.isFetching != true, - selected: playlist.isFetching != true && - video.id == (playlist.activeTrack as SpotubeTrack).ytTrack.id, - selectedTileColor: theme.popupMenuTheme.color, - onTap: () { - if (playlist.isFetching == false && - video.id != (playlist.activeTrack as SpotubeTrack).ytTrack.id) { - playlistNotifier.swapSibling(video); - Navigator.of(context).pop(); - } - }, - ); - }, [ - playlist.isFetching, - playlist.activeTrack, - siblings, - ]); - - var mediaQuery = MediaQuery.of(context); - return SafeArea( - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 12.0, - sigmaY: 12.0, - ), - child: AnimatedSize( - duration: const Duration(milliseconds: 300), - child: Container( - height: isSearching.value && mediaQuery.smAndDown - ? mediaQuery.size.height - : mediaQuery.size.height * .6, - margin: const EdgeInsets.all(8.0), - decoration: BoxDecoration( - borderRadius: borderRadius, - color: theme.scaffoldBackgroundColor.withOpacity(.3), + final itemBuilder = useCallback( + (SourceInfo sourceInfo) { + final icon = sourceInfoToIconMap[sourceInfo.runtimeType]; + return ListTile( + title: Text(sourceInfo.title), + leading: Padding( + padding: const EdgeInsets.all(8.0), + child: UniversalImage( + path: sourceInfo.thumbnail, + height: 60, + width: 60, ), - child: Scaffold( - backgroundColor: Colors.transparent, - appBar: AppBar( - centerTitle: true, - title: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: !isSearching.value - ? Text( - context.l10n.alternative_track_sources, - style: theme.textTheme.headlineSmall, - ) - : TextField( - autofocus: true, - controller: searchController, - decoration: InputDecoration( - hintText: context.l10n.search, - hintStyle: theme.textTheme.headlineSmall, - border: InputBorder.none, - ), - style: theme.textTheme.headlineSmall, - ), - ), - automaticallyImplyLeading: false, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + trailing: Text(sourceInfo.duration.toHumanReadableString()), + subtitle: Row( + children: [ + if (icon != null) icon, + Text(" • ${sourceInfo.artist}"), + ], + ), + enabled: playlist.isFetching != true, + selected: playlist.isFetching != true && + sourceInfo.id == + (playlist.activeTrack as SourcedTrack).sourceInfo.id, + selectedTileColor: theme.popupMenuTheme.color, + onTap: () { + if (playlist.isFetching == false && + sourceInfo.id != + (playlist.activeTrack as SourcedTrack).sourceInfo.id) { + playlistNotifier.swapSibling(sourceInfo); + Navigator.of(context).pop(); + } + }, + ); + }, + [playlist.isFetching, playlist.activeTrack, siblings], + ); + + final mediaQuery = MediaQuery.of(context); + return SafeArea( + child: ClipRRect( + borderRadius: borderRadius, + clipBehavior: Clip.hardEdge, + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 12.0, + sigmaY: 12.0, + ), + child: AnimatedSize( + duration: const Duration(milliseconds: 300), + child: Container( + height: isSearching.value && mediaQuery.smAndDown + ? mediaQuery.size.height - 50 + : mediaQuery.size.height * .6, + decoration: BoxDecoration( + borderRadius: borderRadius, + color: theme.colorScheme.surfaceVariant.withOpacity(.5), + ), + child: Scaffold( backgroundColor: Colors.transparent, - actions: [ - if (!isSearching.value) - IconButton( - icon: const Icon(SpotubeIcons.search, size: 18), - onPressed: () { - isSearching.value = true; - }, - ) - else ...[ - if (preferences.youtubeApiType == YoutubeApiType.piped) - PopupMenuButton( - icon: const Icon(SpotubeIcons.filter, size: 18), - onSelected: (SearchMode mode) { - searchMode.value = mode; + appBar: AppBar( + centerTitle: true, + title: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: !isSearching.value + ? Text( + context.l10n.alternative_track_sources, + style: theme.textTheme.headlineSmall, + ) + : TextField( + autofocus: true, + controller: searchController, + decoration: InputDecoration( + hintText: context.l10n.search, + hintStyle: theme.textTheme.headlineSmall, + border: InputBorder.none, + ), + style: theme.textTheme.headlineSmall, + ), + ), + automaticallyImplyLeading: false, + backgroundColor: Colors.transparent, + actions: [ + if (!isSearching.value) + IconButton( + icon: const Icon(SpotubeIcons.search, size: 18), + onPressed: () { + isSearching.value = true; + }, + ) + else ...[ + if (preferences.audioSource == AudioSource.piped) + PopupMenuButton( + icon: const Icon(SpotubeIcons.filter, size: 18), + onSelected: (SearchMode mode) { + searchMode.value = mode; + }, + initialValue: searchMode.value, + itemBuilder: (context) => SearchMode.values + .map( + (e) => PopupMenuItem( + value: e, + child: Text(e.label), + ), + ) + .toList(), + ), + IconButton( + icon: const Icon(SpotubeIcons.close, size: 18), + onPressed: () { + isSearching.value = false; }, - initialValue: searchMode.value, - itemBuilder: (context) => SearchMode.values - .map( - (e) => PopupMenuItem( - value: e, - child: Text(e.label), - ), - ) - .toList(), ), - IconButton( - icon: const Icon(SpotubeIcons.close, size: 18), - onPressed: () { - isSearching.value = false; + ] + ], + ), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: InterScrollbar( + controller: controller, + child: switch (isSearching.value) { + false => ListView.builder( + controller: controller, + itemCount: siblings.length, + itemBuilder: (context, index) => + itemBuilder(siblings[index]), + ), + true => FutureBuilder( + future: searchRequest, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text(snapshot.error.toString()), + ); + } else if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator()); + } + + return InterScrollbar( + controller: controller, + child: ListView.builder( + controller: controller, + itemCount: snapshot.data!.length, + itemBuilder: (context, index) => + itemBuilder(snapshot.data![index]), + ), + ); + }, + ), }, ), - ] - ], - ), - body: Padding( - padding: const EdgeInsets.all(8.0), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - transitionBuilder: (child, animation) => - FadeTransition(opacity: animation, child: child), - child: switch (isSearching.value) { - false => ListView.builder( - itemCount: siblings.length, - itemBuilder: (context, index) => - itemBuilder(siblings[index]), - ), - true => FutureBuilder( - future: searchRequest, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Center( - child: Text(snapshot.error.toString()), - ); - } else if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator()); - } - - return ListView.builder( - itemCount: snapshot.data!.length, - itemBuilder: (context, index) => - itemBuilder(snapshot.data![index]), - ); - }, - ), - }, + ), ), ), ), diff --git a/lib/hooks/use_progress.dart b/lib/components/player/use_progress.dart similarity index 93% rename from lib/hooks/use_progress.dart rename to lib/components/player/use_progress.dart index 62dccbce..15a979af 100644 --- a/lib/hooks/use_progress.dart +++ b/lib/components/player/use_progress.dart @@ -40,14 +40,14 @@ import 'package:spotube/services/audio_player/audio_player.dart'; } }); + var lastPosition = position.value; + // audioPlayer.positionStream is fired every 200ms and only 1s delay is // enough. Thus only update the position if the difference is more than 1s // Reduces CPU usage - var lastPosition = position.value; - final positionSubscription = audioPlayer.positionStream.listen((event) { - if (event.inMilliseconds > 1000 && - event.inMilliseconds - lastPosition.inMilliseconds < 1000) return; + final diff = event.inMilliseconds - lastPosition.inMilliseconds; + if (event.inMilliseconds > 1000 && diff < 1000 && diff > 0) return; lastPosition = event; position.value = event; diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index be7abfb9..f429a0ab 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -4,6 +4,7 @@ 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'; @@ -23,7 +24,7 @@ class PlaylistCard extends HookConsumerWidget { final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; - final queryBowl = QueryClient.of(context); + final queryClient = QueryClient.of(context); final tracks = useState?>(null); bool isPlaylistPlaying = useMemoized( () => playlistQueue.containsCollection(playlist.id!), @@ -32,6 +33,32 @@ class PlaylistCard extends HookConsumerWidget { 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), @@ -44,6 +71,7 @@ class PlaylistCard extends HookConsumerWidget { isPlaying: isPlaylistPlaying, isLoading: (isPlaylistPlaying && playlistQueue.isFetching) || updating.value, + isOwner: playlist.owner?.id == me.data?.id && me.data?.id != null, onTap: () { ServiceUtils.push( context, @@ -60,11 +88,7 @@ class PlaylistCard extends HookConsumerWidget { return audioPlayer.resume(); } - List fetchedTracks = await queryBowl.fetchQuery( - "playlist-tracks/${playlist.id}", - () => useQueries.playlist.tracksOf(playlist.id!, spotify), - ) ?? - []; + List fetchedTracks = await fetchAllTracks(); if (fetchedTracks.isEmpty) return; @@ -81,11 +105,8 @@ class PlaylistCard extends HookConsumerWidget { updating.value = true; try { if (isPlaylistPlaying) return; - List fetchedTracks = await queryBowl.fetchQuery( - "playlist-tracks/${playlist.id}", - () => useQueries.playlist.tracksOf(playlist.id!, spotify), - ) ?? - []; + + final fetchedTracks = await fetchAllTracks(); if (fetchedTracks.isEmpty) return; diff --git a/lib/components/playlist/playlist_create_dialog.dart b/lib/components/playlist/playlist_create_dialog.dart index b7cee79d..2e11a209 100644 --- a/lib/components/playlist/playlist_create_dialog.dart +++ b/lib/components/playlist/playlist_create_dialog.dart @@ -1,106 +1,274 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:form_validator/form_validator.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/services/mutations/mutations.dart'; +import 'package:spotube/services/mutations/playlist.dart'; +import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistCreateDialog extends HookConsumerWidget { /// Track ids to add to the playlist final List trackIds; - const PlaylistCreateDialog({ + final String? playlistId; + PlaylistCreateDialog({ Key? key, this.trackIds = const [], + this.playlistId, }) : super(key: key); + final formKey = GlobalKey(); + @override Widget build(BuildContext context, ref) { - final spotify = ref.watch(spotifyProvider); - final playlistName = useTextEditingController(); - final description = useTextEditingController(); - final public = useState(false); - final collaborative = useState(false); - final client = useQueryClient(); - final navigator = Navigator.of(context); + return ScaffoldMessenger( + child: Scaffold( + backgroundColor: Colors.transparent, + body: HookBuilder(builder: (context) { + final userPlaylists = useQueries.playlist.ofMine(ref); + final updatingPlaylist = useMemoized( + () => userPlaylists.pages + .expand((p) => p.items ?? []) + .firstWhereOrNull((playlist) => playlist.id == playlistId), + [ + userPlaylists.pages, + playlistId, + ], + ); - Future onCreate() async { - if (playlistName.text.isEmpty) return; - final me = await spotify.me.get(); - final playlist = await spotify.playlists.createPlaylist( - me.id!, - playlistName.text, - collaborative: collaborative.value, - public: public.value, - description: description.text, - ); - if (trackIds.isNotEmpty) { - await spotify.playlists.addTracks( - trackIds.map((id) => "spotify:track:$id").toList(), - playlist.id!, - ); - } - await client - .getQuery( - "current-user-playlists", - ) - ?.refresh(); - navigator.pop(playlist); - } + final playlistName = useTextEditingController( + text: updatingPlaylist?.name, + ); + final description = useTextEditingController( + text: updatingPlaylist?.description, + ); + final public = useState( + updatingPlaylist?.public ?? false, + ); + final collaborative = useState( + updatingPlaylist?.collaborative ?? false, + ); + final image = useState(null); - return AlertDialog( - title: Text(context.l10n.create_a_playlist), - actions: [ - OutlinedButton( - child: Text(context.l10n.cancel), - onPressed: () { - Navigator.pop(context); - }, - ), - FilledButton( - onPressed: onCreate, - child: Text(context.l10n.create), - ), - ], - content: Container( - width: MediaQuery.of(context).size.width, - constraints: const BoxConstraints(maxWidth: 500), - child: ListView( - shrinkWrap: true, - children: [ - TextField( - controller: playlistName, - decoration: InputDecoration( - hintText: context.l10n.name_of_playlist, - labelText: context.l10n.name_of_playlist, + final isUpdatingPlaylist = playlistId != null; + + final l10n = context.l10n; + final theme = Theme.of(context); + final scaffold = ScaffoldMessenger.of(context); + + final onError = useCallback((error) { + if (error is SpotifyError || error is SpotifyException) { + scaffold.showSnackBar( + SnackBar( + content: Text( + l10n.error(error.message ?? "Epic failure!"), + style: theme.textTheme.bodyMedium!.copyWith( + color: theme.colorScheme.onError, + ), + ), + backgroundColor: theme.colorScheme.error, + ), + ); + } + }, [scaffold, l10n, theme]); + + final playlistCreateMutation = useMutations.playlist.create( + ref, + trackIds: trackIds, + onData: (value) { + Navigator.pop(context); + }, + onError: onError, + ); + + final playlistUpdateMutation = useMutations.playlist.update( + ref, + playlistId: playlistId, + onData: (value) { + Navigator.pop(context); + }, + onError: onError, + ); + + Future onCreate() async { + if (!formKey.currentState!.validate()) return; + + final PlaylistCRUDVariables payload = ( + playlistName: playlistName.text, + collaborative: collaborative.value, + public: public.value, + description: description.text, + base64Image: image.value?.path != null + ? await image.value! + .readAsBytes() + .then((bytes) => base64Encode(bytes)) + : null, + ); + + if (isUpdatingPlaylist) { + await playlistUpdateMutation.mutate(payload); + } else { + await playlistCreateMutation.mutate(payload); + } + } + + return AlertDialog( + title: Text( + isUpdatingPlaylist + ? context.l10n.update_playlist + : context.l10n.create_a_playlist, + ), + actions: [ + OutlinedButton( + child: Text(context.l10n.cancel), + onPressed: () { + Navigator.pop(context); + }, + ), + FilledButton( + onPressed: onCreate, + child: Text( + isUpdatingPlaylist + ? context.l10n.update + : context.l10n.create, + ), + ), + ], + insetPadding: const EdgeInsets.all(8), + content: Container( + width: MediaQuery.of(context).size.width, + constraints: const BoxConstraints(maxWidth: 500), + child: Form( + key: formKey, + child: ListView( + shrinkWrap: true, + children: [ + FormField( + initialValue: image.value, + onSaved: (newValue) { + image.value = newValue; + }, + validator: (value) { + if (value == null) return null; + final file = File(value.path); + + if (file.lengthSync() > 256000) { + return "Image size should be less than 256kb"; + } + return null; + }, + builder: (field) { + return Column( + children: [ + UniversalImage( + path: field.value?.path ?? + TypeConversionUtils.image_X_UrlString( + updatingPlaylist?.images, + placeholder: ImagePlaceholder.collection, + ), + height: 200, + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FilledButton.icon( + icon: const Icon(SpotubeIcons.edit), + label: Text( + field.value?.path != null || + updatingPlaylist?.images != null + ? context.l10n.change_cover + : context.l10n.add_cover, + ), + onPressed: () async { + final imageFile = await ImagePicker() + .pickImage( + source: ImageSource.gallery); + + if (imageFile != null) { + field.didChange(imageFile); + field.validate(); + field.save(); + } + }, + ), + const SizedBox(width: 10), + IconButton.filled( + icon: const Icon(SpotubeIcons.trash), + style: IconButton.styleFrom( + backgroundColor: + theme.colorScheme.errorContainer, + foregroundColor: theme.colorScheme.error, + ), + onPressed: field.value == null + ? null + : () { + field.didChange(null); + field.validate(); + field.save(); + }, + ), + ], + ), + if (field.hasError) + Text( + field.errorText ?? "", + style: theme.textTheme.bodyMedium!.copyWith( + color: theme.colorScheme.error, + ), + ) + ], + ); + }), + const SizedBox(height: 10), + TextFormField( + controller: playlistName, + decoration: InputDecoration( + hintText: context.l10n.name_of_playlist, + labelText: context.l10n.name_of_playlist, + ), + validator: ValidationBuilder().required().build(), + ), + const SizedBox(height: 10), + TextFormField( + controller: description, + decoration: InputDecoration( + hintText: context.l10n.description, + ), + keyboardType: TextInputType.multiline, + validator: ValidationBuilder().required().build(), + maxLines: 5, + ), + const SizedBox(height: 10), + CheckboxListTile( + title: Text(context.l10n.public), + value: public.value, + onChanged: (val) => public.value = val ?? false, + ), + const SizedBox(height: 10), + CheckboxListTile( + title: Text(context.l10n.collaborative), + value: collaborative.value, + onChanged: (val) => collaborative.value = val ?? false, + ), + ], + ), ), ), - const SizedBox(height: 10), - TextField( - controller: description, - decoration: InputDecoration( - hintText: context.l10n.description, - ), - keyboardType: TextInputType.multiline, - maxLines: 5, - ), - const SizedBox(height: 10), - CheckboxListTile( - title: Text(context.l10n.public), - value: public.value, - onChanged: (val) => public.value = val ?? false, - ), - const SizedBox(height: 10), - CheckboxListTile( - title: Text(context.l10n.collaborative), - value: collaborative.value, - onChanged: (val) => collaborative.value = val ?? false, - ), - ], - ), + ); + }), ), ); } @@ -112,7 +280,7 @@ class PlaylistCreateDialogButton extends HookConsumerWidget { showPlaylistDialog(BuildContext context, SpotifyApi spotify) { showDialog( context: context, - builder: (context) => const PlaylistCreateDialog(), + builder: (context) => PlaylistCreateDialog(), ); } @@ -132,11 +300,12 @@ class PlaylistCreateDialogButton extends HookConsumerWidget { } return FilledButton.tonalIcon( - style: FilledButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.primary, - ), - icon: const Icon(SpotubeIcons.addFilled), - label: Text(context.l10n.create_playlist), - onPressed: () => showPlaylistDialog(context, spotify)); + style: FilledButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.primary, + ), + icon: const Icon(SpotubeIcons.addFilled), + label: Text(context.l10n.create_playlist), + onPressed: () => showPlaylistDialog(context, spotify), + ); } } diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index 5a09ffa5..617e760b 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -14,12 +14,13 @@ 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/use_brightness_value.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_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'; @@ -92,6 +93,8 @@ class BottomPlayer extends HookConsumerWidget { 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), ); @@ -106,7 +109,10 @@ class BottomPlayer extends HookConsumerWidget { await Future.delayed( const Duration(milliseconds: 100), () async { - GoRouter.of(context).go('/mini-player'); + GoRouter.of(context).go( + '/mini-player', + extra: prevSize, + ); }, ); }, diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index dcbc2c39..a55ef947 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -11,18 +11,19 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_brightness_value.dart'; -import 'package:spotube/hooks/use_sidebarx_controller.dart'; +import 'package:spotube/hooks/utils/use_brightness_value.dart'; +import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class Sidebar extends HookConsumerWidget { - final int selectedIndex; + final int? selectedIndex; final void Function(int) onSelectedIndexChanged; final Widget child; @@ -57,7 +58,7 @@ class Sidebar extends HookConsumerWidget { ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); final controller = useSidebarXController( - selectedIndex: selectedIndex, + selectedIndex: selectedIndex ?? 0, extended: mediaQuery.lgAndUp, ); @@ -69,14 +70,27 @@ class Sidebar extends HookConsumerWidget { Color.lerp(bg, Colors.black, 0.45)!, ); - final sidebarTileList = - useMemoized(() => getSidebarTileList(context.l10n), [context.l10n]); + final sidebarTileList = useMemoized( + () => getSidebarTileList(context.l10n), + [context.l10n], + ); useEffect(() { - controller.addListener(() { - onSelectedIndexChanged(controller.selectedIndex); - }); + if (controller.selectedIndex != selectedIndex && selectedIndex != null) { + controller.selectIndex(selectedIndex!); + } return null; + }, [selectedIndex]); + + useEffect(() { + void listener() { + onSelectedIndexChanged(controller.selectedIndex); + } + + controller.addListener(listener); + return () { + controller.removeListener(listener); + }; }, [controller]); useEffect(() { @@ -145,7 +159,7 @@ class Sidebar extends HookConsumerWidget { margin: EdgeInsets.only( bottom: 10, left: 0, - top: kIsMacOS ? 35 : 5, + top: kIsMacOS ? 0 : 5, ), padding: const EdgeInsets.symmetric(horizontal: 6), decoration: BoxDecoration( @@ -168,6 +182,9 @@ class Sidebar extends HookConsumerWidget { ), itemTextPadding: const EdgeInsets.only(left: 10), selectedItemTextPadding: const EdgeInsets.only(left: 10), + hoverTextStyle: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.primary, + ), ), ), ), diff --git a/lib/components/root/spotube_navigation_bar.dart b/lib/components/root/spotube_navigation_bar.dart index ee8e3319..0853c60c 100644 --- a/lib/components/root/spotube_navigation_bar.dart +++ b/lib/components/root/spotube_navigation_bar.dart @@ -9,12 +9,15 @@ 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/use_brightness_value.dart'; +import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + +final navigationPanelHeight = StateProvider((ref) => 50); class SpotubeNavigationBar extends HookConsumerWidget { - final int selectedIndex; + final int? selectedIndex; final void Function(int) onSelectedIndexChanged; const SpotubeNavigationBar({ @@ -31,7 +34,7 @@ class SpotubeNavigationBar extends HookConsumerWidget { final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); - final insideSelectedIndex = useState(selectedIndex); + final insideSelectedIndex = useState(selectedIndex ?? 0); final buttonColor = useBrightnessValue( theme.colorScheme.inversePrimary, @@ -41,54 +44,63 @@ class SpotubeNavigationBar extends HookConsumerWidget { final navbarTileList = useMemoized(() => getNavbarTileList(context.l10n), [context.l10n]); + final panelHeight = ref.watch(navigationPanelHeight); + useEffect(() { - insideSelectedIndex.value = selectedIndex; + if (selectedIndex != null) { + insideSelectedIndex.value = selectedIndex!; + } return null; }, [selectedIndex]); if (layoutMode == LayoutMode.extended || - (mediaQuery.mdAndUp && layoutMode == LayoutMode.adaptive)) { + (mediaQuery.mdAndUp && layoutMode == LayoutMode.adaptive) || + panelHeight < 10) { return const SizedBox(); } - return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: CurvedNavigationBar( - backgroundColor: - theme.colorScheme.secondaryContainer.withOpacity(0.72), - buttonBackgroundColor: buttonColor, - color: theme.colorScheme.background, - height: 50, - animationDuration: const Duration(milliseconds: 350), - items: navbarTileList.map( - (e) { - /// Using this [Builder] as an workaround for the first item's - /// icon color not updating unless navigating to another page - return Builder(builder: (context) { - return MouseRegion( - cursor: SystemMouseCursors.click, - child: Badge( - isLabelVisible: e.id == "library" && downloadCount > 0, - label: Text(downloadCount.toString()), - child: Icon( - e.icon, - color: Theme.of(context).colorScheme.primary, + return AnimatedContainer( + duration: const Duration(milliseconds: 100), + height: panelHeight, + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), + child: CurvedNavigationBar( + backgroundColor: + theme.colorScheme.secondaryContainer.withOpacity(0.72), + buttonBackgroundColor: buttonColor, + color: theme.colorScheme.background, + height: panelHeight, + animationDuration: const Duration(milliseconds: 350), + items: navbarTileList.map( + (e) { + /// Using this [Builder] as an workaround for the first item's + /// icon color not updating unless navigating to another page + return Builder(builder: (context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: Badge( + isLabelVisible: e.id == "library" && downloadCount > 0, + label: Text(downloadCount.toString()), + child: Icon( + e.icon, + color: Theme.of(context).colorScheme.primary, + ), ), - ), - ); - }); + ); + }); + }, + ).toList(), + index: insideSelectedIndex.value, + onTap: (i) { + insideSelectedIndex.value = i; + if (navbarTileList[i].id == "settings") { + Sidebar.goToSettings(context); + return; + } + onSelectedIndexChanged(i); }, - ).toList(), - index: insideSelectedIndex.value, - onTap: (i) { - insideSelectedIndex.value = i; - if (navbarTileList[i].id == "settings") { - Sidebar.goToSettings(context); - return; - } - onSelectedIndexChanged(i); - }, + ), ), ), ); diff --git a/lib/components/settings/color_scheme_picker_dialog.dart b/lib/components/settings/color_scheme_picker_dialog.dart index 170bae94..e0c3d618 100644 --- a/lib/components/settings/color_scheme_picker_dialog.dart +++ b/lib/components/settings/color_scheme_picker_dialog.dart @@ -2,7 +2,7 @@ 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_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:system_theme/system_theme.dart'; class SpotubeColor extends Color { @@ -49,6 +49,7 @@ class ColorSchemePickerDialog extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final preferences = ref.watch(userPreferencesProvider); + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); final scheme = preferences.accentColorScheme; final active = useState(colorsMap.firstWhere( (element) { @@ -57,7 +58,7 @@ class ColorSchemePickerDialog extends HookConsumerWidget { ).name); onOk() { - preferences.setAccentColorScheme( + preferencesNotifier.setAccentColorScheme( colorsMap.firstWhere( (element) { return element.name == active.value; diff --git a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart b/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart index 41534cb3..21f56a22 100644 --- a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart +++ b/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart @@ -78,6 +78,31 @@ class AdaptivePopSheetList extends StatelessWidget { 'Either icon or child must be provided', ); + Future showPopupMenu(BuildContext context, RelativeRect position) { + final mediaQuery = MediaQuery.of(context); + + return showMenu( + context: context, + useRootNavigator: useRootNavigator, + constraints: BoxConstraints( + maxHeight: mediaQuery.size.height * 0.6, + ), + position: position, + items: children + .map( + (item) => PopupMenuItem( + padding: EdgeInsets.zero, + enabled: false, + child: _AdaptivePopSheetListItem( + item: item, + onSelected: onSelected, + ), + ), + ) + .toList(), + ); + } + @override Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); diff --git a/lib/components/shared/adaptive/adaptive_select_tile.dart b/lib/components/shared/adaptive/adaptive_select_tile.dart index 17e652b7..58666e46 100644 --- a/lib/components/shared/adaptive/adaptive_select_tile.dart +++ b/lib/components/shared/adaptive/adaptive_select_tile.dart @@ -42,6 +42,7 @@ class AdaptiveSelectTile extends HookWidget { items: options, value: value, onChanged: onChanged, + menuMaxHeight: mediaQuery.size.height * 0.6, ); final controlPlaceholder = useMemoized( () => options diff --git a/lib/components/shared/dialogs/confirm_download_dialog.dart b/lib/components/shared/dialogs/confirm_download_dialog.dart index 0413260c..c371e803 100644 --- a/lib/components/shared/dialogs/confirm_download_dialog.dart +++ b/lib/components/shared/dialogs/confirm_download_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; class ConfirmDownloadDialog extends StatelessWidget { @@ -24,8 +25,9 @@ class ConfirmDownloadDialog extends StatelessWidget { ], ), ), - content: Padding( + content: Container( padding: const EdgeInsets.all(15), + constraints: BoxConstraints(maxWidth: Breakpoints.sm), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, @@ -87,7 +89,7 @@ class BulletPoint extends StatelessWidget { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text("●"), + const Text("\u2022"), const SizedBox(width: 5), Flexible(child: Text(text)), ], diff --git a/lib/components/shared/dialogs/piped_down_dialog.dart b/lib/components/shared/dialogs/piped_down_dialog.dart index 03362ed4..6220adeb 100644 --- a/lib/components/shared/dialogs/piped_down_dialog.dart +++ b/lib/components/shared/dialogs/piped_down_dialog.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; class PipedDownDialog extends HookConsumerWidget { const PipedDownDialog({Key? key}) : super(key: key); diff --git a/lib/components/shared/dialogs/playlist_add_track_dialog.dart b/lib/components/shared/dialogs/playlist_add_track_dialog.dart index 29f64268..51b77c76 100644 --- a/lib/components/shared/dialogs/playlist_add_track_dialog.dart +++ b/lib/components/shared/dialogs/playlist_add_track_dialog.dart @@ -1,10 +1,11 @@ -import 'package:async/async.dart'; import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/spotify_provider.dart'; @@ -12,41 +13,35 @@ import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistAddTrackDialog extends HookConsumerWidget { + /// The id of the playlist this dialog was opened from + final String? openFromPlaylist; final List tracks; const PlaylistAddTrackDialog({ required this.tracks, + required this.openFromPlaylist, Key? key, }) : super(key: key); @override Widget build(BuildContext context, ref) { + final ThemeData(:textTheme) = Theme.of(context); final spotify = ref.watch(spotifyProvider); - final userPlaylists = useQueries.playlist.ofMine(ref); - - useEffect(() { - final op = CancelableOperation.fromFuture( - () async { - while (userPlaylists.hasNextPage) { - await userPlaylists.fetchNext(); - } - }(), - ); - - return () { - op.cancel(); - }; - }, [userPlaylists.hasNextPage]); + final userPlaylists = useQueries.playlist.ofMineAll(ref); final me = useQueries.user.me(ref); final filteredPlaylists = useMemoized( - () => userPlaylists.pages - .expand((page) => page.items?.toList() ?? []) - .where( - (playlist) => - playlist.owner?.id != null && playlist.owner!.id == me.data?.id, - ), - [userPlaylists.pages, me.data?.id], + () => + userPlaylists.data + ?.where( + (playlist) => + playlist.owner?.id != null && + playlist.owner!.id == me.data?.id && + playlist.id != openFromPlaylist, + ) + .toList() ?? + [], + [userPlaylists.data, me.data?.id, openFromPlaylist], ); final playlistsCheck = useState({}); @@ -77,7 +72,18 @@ class PlaylistAddTrackDialog extends HookConsumerWidget { } return AlertDialog( - title: Text(context.l10n.add_to_playlist), + insetPadding: EdgeInsets.zero, + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.l10n.add_to_playlist, + style: textTheme.titleMedium, + ), + const Gap(20), + const PlaylistCreateDialogButton(), + ], + ), actions: [ OutlinedButton( child: Text(context.l10n.cancel), @@ -93,7 +99,7 @@ class PlaylistAddTrackDialog extends HookConsumerWidget { content: SizedBox( height: 300, width: 300, - child: userPlaylists.hasNextPage + child: userPlaylists.isLoading ? const Center(child: CircularProgressIndicator()) : ListView.builder( shrinkWrap: true, diff --git a/lib/components/shared/dialogs/track_details_dialog.dart b/lib/components/shared/dialogs/track_details_dialog.dart index 9e29c32d..8634776f 100644 --- a/lib/components/shared/dialogs/track_details_dialog.dart +++ b/lib/components/shared/dialogs/track_details_dialog.dart @@ -6,8 +6,7 @@ import 'package:spotube/components/shared/links/hyper_link.dart'; import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/spotube_track.dart'; -import 'package:spotube/utils/primitive_utils.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/extensions/duration.dart'; @@ -37,8 +36,8 @@ class TrackDetailsDialog extends HookWidget { overflow: TextOverflow.ellipsis, style: const TextStyle(color: Colors.blue), ), - context.l10n.duration: (track is SpotubeTrack - ? (track as SpotubeTrack).ytTrack.duration + context.l10n.duration: (track is SourcedTrack + ? (track as SourcedTrack).sourceInfo.duration : track.duration!) .toHumanReadableString(), if (track.album!.releaseDate != null) @@ -46,33 +45,27 @@ class TrackDetailsDialog extends HookWidget { context.l10n.popularity: track.popularity?.toString() ?? "0", }; - final ytTrack = - track is SpotubeTrack ? (track as SpotubeTrack).ytTrack : null; + final sourceInfo = + track is SourcedTrack ? (track as SourcedTrack).sourceInfo : null; - final ytTracksDetailsMap = ytTrack == null + final ytTracksDetailsMap = sourceInfo == null ? {} : { context.l10n.youtube: Hyperlink( - "https://piped.video/watch?v=${ytTrack.id}", - "https://piped.video/watch?v=${ytTrack.id}", + "https://piped.video/watch?v=${sourceInfo.id}", + "https://piped.video/watch?v=${sourceInfo.id}", maxLines: 2, overflow: TextOverflow.ellipsis, ), context.l10n.channel: Hyperlink( - ytTrack.channelName, - "https://youtube.com${ytTrack.channelName}", + sourceInfo.artist, + sourceInfo.artistUrl, maxLines: 2, overflow: TextOverflow.ellipsis, ), - context.l10n.likes: - PrimitiveUtils.toReadableNumber(ytTrack.likes.toDouble()), - context.l10n.dislikes: - PrimitiveUtils.toReadableNumber(ytTrack.dislikes.toDouble()), - context.l10n.views: - PrimitiveUtils.toReadableNumber(ytTrack.views.toDouble()), context.l10n.streamUrl: Hyperlink( - (track as SpotubeTrack).ytUri, - (track as SpotubeTrack).ytUri, + (track as SourcedTrack).url, + (track as SourcedTrack).url, maxLines: 2, overflow: TextOverflow.ellipsis, ), diff --git a/lib/components/shared/expandable_search/expandable_search.dart b/lib/components/shared/expandable_search/expandable_search.dart index 684e373e..75ac6841 100644 --- a/lib/components/shared/expandable_search/expandable_search.dart +++ b/lib/components/shared/expandable_search/expandable_search.dart @@ -4,13 +4,15 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/extensions/context.dart'; class ExpandableSearchField extends StatelessWidget { - final ValueNotifier isFiltering; + final bool isFiltering; + final ValueChanged onChangeFiltering; final TextEditingController searchController; final FocusNode searchFocus; const ExpandableSearchField({ Key? key, required this.isFiltering, + required this.onChangeFiltering, required this.searchController, required this.searchFocus, }) : super(key: key); @@ -19,17 +21,17 @@ class ExpandableSearchField extends StatelessWidget { Widget build(BuildContext context) { return AnimatedOpacity( duration: const Duration(milliseconds: 200), - opacity: isFiltering.value ? 1 : 0, + opacity: isFiltering ? 1 : 0, child: AnimatedSize( duration: const Duration(milliseconds: 200), child: SizedBox( - height: isFiltering.value ? 50 : 0, + height: isFiltering ? 50 : 0, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: CallbackShortcuts( bindings: { LogicalKeySet(LogicalKeyboardKey.escape): () { - isFiltering.value = false; + onChangeFiltering(false); searchController.clear(); searchFocus.unfocus(); } @@ -52,7 +54,7 @@ class ExpandableSearchField extends StatelessWidget { } class ExpandableSearchButton extends StatelessWidget { - final ValueNotifier isFiltering; + final bool isFiltering; final FocusNode searchFocus; final Widget icon; final ValueChanged? onPressed; @@ -73,18 +75,17 @@ class ExpandableSearchButton extends StatelessWidget { icon: icon, style: IconButton.styleFrom( backgroundColor: - isFiltering.value ? theme.colorScheme.secondaryContainer : null, - foregroundColor: isFiltering.value ? theme.colorScheme.secondary : null, + isFiltering ? theme.colorScheme.secondaryContainer : null, + foregroundColor: isFiltering ? theme.colorScheme.secondary : null, minimumSize: const Size(25, 25), ), onPressed: () { - isFiltering.value = !isFiltering.value; - if (isFiltering.value) { + if (isFiltering) { searchFocus.requestFocus(); } else { searchFocus.unfocus(); } - onPressed?.call(isFiltering.value); + onPressed?.call(!isFiltering); }, ); } diff --git a/lib/components/shared/heart_button.dart b/lib/components/shared/heart_button.dart index cf790918..81ccffdb 100644 --- a/lib/components/shared/heart_button.dart +++ b/lib/components/shared/heart_button.dart @@ -1,4 +1,5 @@ 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'; @@ -6,6 +7,7 @@ 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'; @@ -28,7 +30,7 @@ class HeartButton extends HookConsumerWidget { Widget build(BuildContext context, ref) { final auth = ref.watch(AuthenticationNotifier.provider); - if (auth == null) return Container(); + if (auth == null) return const SizedBox.shrink(); return IconButton( tooltip: tooltip, @@ -56,47 +58,73 @@ class HeartButton extends HookConsumerWidget { } } -({ +typedef UseTrackToggleLike = ({ bool isLiked, Mutation toggleTrackLike, Query me, -}) useTrackToggleLike(Track track, WidgetRef ref) { +}); + +UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) { final me = useQueries.user.me(ref); - final savedTracks = - useQueries.playlist.tracksOfQuery(ref, "user-liked-tracks"); + final savedTracks = useQueries.playlist.likedTracksQuery(ref); - final isLiked = - savedTracks.data?.any((element) => element.id == track.id) ?? false; + 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) { - savedTracks.setData( - [ - if (isLiked == true) - ...?savedTracks.data?.where((element) => element.id != track.id) - else - ...?savedTracks.data?..add(track) - ], - ); + if (isLiked) { + savedTracks.setData( + savedTracks.data + ?.where((element) => element.id != track.id) + .toList() ?? + [], + ); + } else { + savedTracks.setData( + [ + ...?savedTracks.data, + track, + ], + ); + } return isLiked; }, - onData: (data, recoveryData) async { + onData: (isLiked, recoveryData) async { await savedTracks.refresh(); + if (isLiked) { + await scrobblerNotifier.love(track); + } else { + await scrobblerNotifier.unlove(track); + } }, onError: (payload, isLiked) { if (!mounted()) return; - savedTracks.setData([ - if (isLiked != true) - ...?savedTracks.data?.where((element) => element.id != track.id) - else - ...?savedTracks.data?..add(track), - ]); + if (isLiked != true) { + savedTracks.setData( + savedTracks.data + ?.where((element) => element.id != track.id) + .toList() ?? + [], + ); + } else { + savedTracks.setData( + [ + ...?savedTracks.data, + track, + ], + ); + } }, ); @@ -112,21 +140,21 @@ class TrackHeartButton extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final savedTracks = - useQueries.playlist.tracksOfQuery(ref, "user-liked-tracks"); - final toggler = useTrackToggleLike(track, ref); - if (toggler.me.isLoading || !toggler.me.hasData) { + final savedTracks = useQueries.playlist.likedTracksQuery(ref); + final (:me, :isLiked, :toggleTrackLike) = useTrackToggleLike(track, ref); + + if (me.isLoading || !me.hasData) { return const CircularProgressIndicator(); } return HeartButton( - tooltip: toggler.isLiked + tooltip: isLiked ? context.l10n.remove_from_favorites : context.l10n.save_as_favorite, - isLiked: toggler.isLiked, + isLiked: isLiked, onPressed: savedTracks.hasData ? () { - toggler.toggleTrackLike.mutate(toggler.isLiked); + toggleTrackLike.mutate(isLiked); } : null, ); @@ -135,10 +163,14 @@ class TrackHeartButton extends HookConsumerWidget { 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 @@ -157,6 +189,7 @@ class PlaylistHeartButton extends HookConsumerWidget { refreshQueries: [ isLikedQuery.key, ], + onData: onData, ); if (me.isLoading || !me.hasData) { @@ -169,6 +202,7 @@ class PlaylistHeartButton extends HookConsumerWidget { ? context.l10n.remove_from_favorites : context.l10n.save_as_favorite, color: Colors.white, + icon: icon, onPressed: isLikedQuery.hasData ? () { togglePlaylistLike.mutate(isLikedQuery.data!); @@ -188,6 +222,7 @@ class AlbumHeartButton extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { + final client = useQueryClient(); final me = useQueries.user.me(ref); final albumIsSaved = useQueries.album.isSavedForMe(ref, album.id!); @@ -196,10 +231,10 @@ class AlbumHeartButton extends HookConsumerWidget { final toggleAlbumLike = useMutations.album.toggleFavorite( ref, album.id!, - refreshQueries: [ - albumIsSaved.key, - "current-user-albums", - ], + refreshQueries: [albumIsSaved.key], + onData: (_, __) async { + await client.refreshInfiniteQueryAllPages("current-user-albums"); + }, ); if (me.isLoading || !me.hasData) { diff --git a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart new file mode 100644 index 00000000..dc9d30da --- /dev/null +++ b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart @@ -0,0 +1,107 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +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/hooks/utils/use_breakpoint_value.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; + +class HorizontalPlaybuttonCardView extends HookWidget { + final Widget title; + final List items; + final VoidCallback onFetchMore; + final bool isLoadingNextPage; + final bool hasNextPage; + + const 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); + + @override + Widget build(BuildContext context) { + final ThemeData(:textTheme) = Theme.of(context); + final scrollController = useScrollController(); + final height = useBreakpointValue( + xs: 226, + sm: 226, + md: 236, + others: 266, + ); + + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DefaultTextStyle( + style: textTheme.titleMedium!, + child: title, + ), + SizedBox( + height: height, + child: NotificationListener( + // disable multiple scrollbar to use this + onNotification: (notification) => true, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: PointerDeviceKind.values.toSet(), + ), + child: items.isEmpty + ? ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: 5, + itemBuilder: (context, index) { + return AlbumCard(FakeData.albumSimple); + }, + ) + : InfiniteList( + scrollController: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(vertical: 8.0), + itemCount: items.length, + onFetchData: onFetchMore, + loadingBuilder: (context) => Skeletonizer( + enabled: true, + child: AlbumCard(FakeData.albumSimple), + ), + isLoading: isLoadingNextPage, + hasReachedMax: !hasNextPage, + itemBuilder: (context, index) { + final item = items[index]; + + return switch (item.runtimeType) { + PlaylistSimple => + PlaylistCard(item as PlaylistSimple), + Album => AlbumCard(item as Album), + Artist => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0), + child: ArtistCard(item as Artist), + ), + _ => const SizedBox.shrink(), + }; + }), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/components/shared/inter_scrollbar/inter_scrollbar.dart b/lib/components/shared/inter_scrollbar/inter_scrollbar.dart new file mode 100644 index 00000000..11f75829 --- /dev/null +++ b/lib/components/shared/inter_scrollbar/inter_scrollbar.dart @@ -0,0 +1,27 @@ +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'; + +class InterScrollbar extends HookWidget { + final Widget child; + final ScrollController controller; + + const InterScrollbar({ + super.key, + required this.child, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (DesktopTools.platform.isDesktop) return child; + + return DraggableScrollbar.semicircle( + controller: controller, + child: child, + ); + } +} diff --git a/lib/components/shared/links/link_text.dart b/lib/components/shared/links/link_text.dart index 217b247d..d7b00b72 100644 --- a/lib/components/shared/links/link_text.dart +++ b/lib/components/shared/links/link_text.dart @@ -8,6 +8,7 @@ class LinkText extends StatelessWidget { final TextAlign? textAlign; final TextOverflow? overflow; final String route; + final int? maxLines; final T? extra; final bool push; @@ -19,6 +20,7 @@ class LinkText extends StatelessWidget { this.extra, this.overflow, this.style = const TextStyle(), + this.maxLines, this.push = false, }) : super(key: key); @@ -37,6 +39,7 @@ class LinkText extends StatelessWidget { overflow: overflow, style: style, textAlign: textAlign, + maxLines: maxLines, ); } } diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart index b46795c1..9aa2d4a8 100644 --- a/lib/components/shared/page_window_title_bar.dart +++ b/lib/components/shared/page_window_title_bar.dart @@ -1,27 +1,15 @@ 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_provider.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'; import 'dart:math'; import 'package:flutter/foundation.dart' show kIsWeb; import 'dart:io' show Platform; import 'package:flutter_desktop_tools/flutter_desktop_tools.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) { - windowManager.close(); - }; - -class PageWindowTitleBar extends StatefulHookWidget +class PageWindowTitleBar extends StatefulHookConsumerWidget implements PreferredSizeWidget { final Widget? leading; final bool automaticallyImplyLeading; @@ -60,42 +48,59 @@ class PageWindowTitleBar extends StatefulHookWidget Size get preferredSize => const Size.fromHeight(kToolbarHeight); @override - State createState() => _PageWindowTitleBarState(); + ConsumerState createState() => _PageWindowTitleBarState(); } -class _PageWindowTitleBarState extends State { +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) { - return GestureDetector( - onHorizontalDragStart: (details) { - if (kIsDesktop) { - windowManager.startDragging(); - } - }, - onVerticalDragStart: (details) { - if (kIsDesktop) { - windowManager.startDragging(); - } - }, - 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, - ), - ); + 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, + ), + ), + ); + }); } } @@ -108,30 +113,24 @@ class WindowTitleBarButtons extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final closeBehavior = - ref.watch(userPreferencesProvider.select((s) => s.closeBehavior)); + final preferences = ref.watch(userPreferencesProvider); final isMaximized = useState(null); const type = ThemeType.auto; Future onClose() async { - if (closeBehavior == CloseBehavior.close) { - await windowManager.close(); - } else { - await windowManager.hide(); - await closeNotification?.show(); - } + await DesktopTools.window.close(); } useEffect(() { if (kIsDesktop) { - windowManager.isMaximized().then((value) { + DesktopTools.window.isMaximized().then((value) { isMaximized.value = value; }); } return null; }, []); - if (!kIsDesktop || kIsMacOS) { + if (!kIsDesktop || kIsMacOS || preferences.systemTitleBar) { return const SizedBox.shrink(); } @@ -161,14 +160,14 @@ class WindowTitleBarButtons extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ MinimizeWindowButton( - onPressed: windowManager.minimize, + onPressed: DesktopTools.window.minimize, colors: colors, ), if (isMaximized.value != true) MaximizeWindowButton( colors: colors, onPressed: () { - windowManager.maximize(); + DesktopTools.window.maximize(); isMaximized.value = true; }, ) @@ -176,7 +175,7 @@ class WindowTitleBarButtons extends HookConsumerWidget { RestoreWindowButton( colors: colors, onPressed: () { - windowManager.unmaximize(); + DesktopTools.window.unmaximize(); isMaximized.value = false; }, ), @@ -196,16 +195,16 @@ class WindowTitleBarButtons extends HookConsumerWidget { children: [ DecoratedMinimizeButton( type: type, - onPressed: windowManager.minimize, + onPressed: DesktopTools.window.minimize, ), DecoratedMaximizeButton( type: type, onPressed: () async { - if (await windowManager.isMaximized()) { - await windowManager.unmaximize(); + if (await DesktopTools.window.isMaximized()) { + await DesktopTools.window.unmaximize(); isMaximized.value = false; } else { - await windowManager.maximize(); + await DesktopTools.window.maximize(); isMaximized.value = true; } }, diff --git a/lib/components/shared/panels/controller.dart b/lib/components/shared/panels/controller.dart new file mode 100644 index 00000000..a573c06c --- /dev/null +++ b/lib/components/shared/panels/controller.dart @@ -0,0 +1,142 @@ +part of panels; + +class PanelController extends ChangeNotifier { + SlidingUpPanelState? _panelState; + + void _addState(SlidingUpPanelState panelState) { + _panelState = panelState; + notifyListeners(); + } + + bool _forceScrollChange = false; + + /// use this function when scroll change in func + /// Example: + /// panelController.forseScrollChange(scrollController.animateTo(100, duration: Duration(milliseconds: 400), curve: Curves.ease)) + Future forceScrollChange(Future func) async { + _forceScrollChange = true; + _panelState!._scrollingEnabled = true; + await func; + // if (_panelState!._sc.offset == 0) { + // _panelState!._scrollingEnabled = true; + // } + if (panelPosition < 1) { + _panelState!._scMinOffset = _panelState!._scrollController.offset; + } + _forceScrollChange = false; + } + + bool __nowTargetForceDraggable = false; + + bool get _nowTargetForceDraggable => __nowTargetForceDraggable; + + set _nowTargetForceDraggable(bool value) { + __nowTargetForceDraggable = value; + notifyListeners(); + } + + /// Determine if the panelController is attached to an instance + /// of the SlidingUpPanel (this property must return true before any other + /// functions can be used) + bool get isAttached => _panelState != null; + + /// Closes the sliding panel to its collapsed state (i.e. to the minHeight) + Future close() { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._close(); + } + + /// Opens the sliding panel fully + /// (i.e. to the maxHeight) + Future open() { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._open(); + } + + /// Hides the sliding panel (i.e. is invisible) + Future hide() { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._hide(); + } + + /// Shows the sliding panel in its collapsed state + /// (i.e. "un-hide" the sliding panel) + Future show() { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._show(); + } + + /// Animates the panel position to the value. + /// The value must between 0.0 and 1.0 + /// where 0.0 is fully collapsed and 1.0 is completely open. + /// (optional) duration specifies the time for the animation to complete + /// (optional) curve specifies the easing behavior of the animation. + Future animatePanelToPosition(double value, + {Duration? duration, Curve curve = Curves.linear}) { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + assert(0.0 <= value && value <= 1.0); + return _panelState! + ._animatePanelToPosition(value, duration: duration, curve: curve); + } + + /// Animates the panel position to the snap point + /// Requires that the SlidingUpPanel snapPoint property is not null + /// (optional) duration specifies the time for the animation to complete + /// (optional) curve specifies the easing behavior of the animation. + Future animatePanelToSnapPoint( + {Duration? duration, Curve curve = Curves.linear}) { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + assert(_panelState!.widget.snapPoint != null, + "SlidingUpPanel snapPoint property must not be null"); + return _panelState! + ._animatePanelToSnapPoint(duration: duration, curve: curve); + } + + /// Sets the panel position (without animation). + /// The value must between 0.0 and 1.0 + /// where 0.0 is fully collapsed and 1.0 is completely open. + set panelPosition(double value) { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + assert(0.0 <= value && value <= 1.0); + _panelState!._panelPosition = value; + } + + /// Gets the current panel position. + /// Returns the % offset from collapsed state + /// to the open state + /// as a decimal between 0.0 and 1.0 + /// where 0.0 is fully collapsed and + /// 1.0 is full open. + double get panelPosition { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._panelPosition; + } + + /// Returns whether or not the panel is + /// currently animating. + bool get isPanelAnimating { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._isPanelAnimating; + } + + /// Returns whether or not the + /// panel is open. + bool get isPanelOpen { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._isPanelOpen; + } + + /// Returns whether or not the + /// panel is closed. + bool get isPanelClosed { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._isPanelClosed; + } + + /// Returns whether or not the + /// panel is shown/hidden. + bool get isPanelShown { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._isPanelShown; + } +} diff --git a/lib/components/shared/panels/helpers.dart b/lib/components/shared/panels/helpers.dart new file mode 100644 index 00000000..2e754bdf --- /dev/null +++ b/lib/components/shared/panels/helpers.dart @@ -0,0 +1,96 @@ +part of panels; + +/// if you want to prevent the panel from being dragged using the widget, +/// wrap the widget with this +class IgnoreDraggableWidget extends SingleChildRenderObjectWidget { + const IgnoreDraggableWidget({ + super.key, + required super.child, + }); + + @override + IgnoreDraggableWidgetWidgetRenderBox createRenderObject( + BuildContext context, + ) { + return IgnoreDraggableWidgetWidgetRenderBox(); + } +} + +class IgnoreDraggableWidgetWidgetRenderBox extends RenderPointerListener { + @override + HitTestBehavior get behavior => HitTestBehavior.opaque; +} + +/// if you want to force the panel to be dragged using the widget, +/// wrap the widget with this +/// For example, use [Scrollable] inside to allow the panel to be dragged +/// even if the scroll is not at position 0. +class ForceDraggableWidget extends SingleChildRenderObjectWidget { + const ForceDraggableWidget({ + super.key, + required super.child, + }); + + @override + ForceDraggableWidgetRenderBox createRenderObject( + BuildContext context, + ) { + return ForceDraggableWidgetRenderBox(); + } +} + +class ForceDraggableWidgetRenderBox extends RenderPointerListener { + @override + HitTestBehavior get behavior => HitTestBehavior.opaque; +} + +/// To make [ForceDraggableWidget] work in [Scrollable] widgets +class PanelScrollPhysics extends ScrollPhysics { + final PanelController controller; + const PanelScrollPhysics({required this.controller, ScrollPhysics? parent}) + : super(parent: parent); + @override + PanelScrollPhysics applyTo(ScrollPhysics? ancestor) { + return PanelScrollPhysics( + controller: controller, parent: buildParent(ancestor)); + } + + @override + double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { + if (controller._nowTargetForceDraggable) return 0.0; + return super.applyPhysicsToUserOffset(position, offset); + } + + @override + Simulation? createBallisticSimulation( + ScrollMetrics position, double velocity) { + if (controller._nowTargetForceDraggable) { + return super.createBallisticSimulation(position, 0); + } + return super.createBallisticSimulation(position, velocity); + } + + @override + bool get allowImplicitScrolling => false; +} + +/// if you want to prevent unwanted panel dragging when scrolling widgets [Scrollable] with horizontal axis +/// wrap the widget with this +class HorizontalScrollableWidget extends SingleChildRenderObjectWidget { + const HorizontalScrollableWidget({ + super.key, + required super.child, + }); + + @override + HorizontalScrollableWidgetRenderBox createRenderObject( + BuildContext context, + ) { + return HorizontalScrollableWidgetRenderBox(); + } +} + +class HorizontalScrollableWidgetRenderBox extends RenderPointerListener { + @override + HitTestBehavior get behavior => HitTestBehavior.opaque; +} diff --git a/lib/components/shared/panels/sliding_up_panel.dart b/lib/components/shared/panels/sliding_up_panel.dart new file mode 100644 index 00000000..137d5eb7 --- /dev/null +++ b/lib/components/shared/panels/sliding_up_panel.dart @@ -0,0 +1,686 @@ +/* +Name: Zotov Vladimir +Date: 18/06/22 +Purpose: Defines the package: sliding_up_panel2 +Copyright: © 2022, Zotov Vladimir. All rights reserved. +Licensing: More information can be found here: https://github.com/Zotov-VD/sliding_up_panel/blob/master/LICENSE + +This product includes software developed by Akshath Jain (https://akshathjain.com) +*/ + +library panels; + +import 'dart:math'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/rendering.dart'; + +part 'controller.dart'; +part 'helpers.dart'; + +enum SlideDirection { up, down } + +enum PanelState { open, closed } + +class SlidingUpPanel extends StatefulWidget { + /// Returns the Widget that slides into view. When the + /// panel is collapsed and if [collapsed] is null, + /// then top portion of this Widget will be displayed; + /// otherwise, [collapsed] will be displayed overtop + /// of this Widget. + final Widget? Function(double position)? panelBuilder; + + /// The Widget displayed overtop the [panel] when collapsed. + /// This fades out as the panel is opened. + final Widget? collapsed; + + /// The Widget that lies underneath the sliding panel. + /// This Widget automatically sizes itself + /// to fill the screen. + final Widget? body; + + /// Optional persistent widget that floats above the [panel] and attaches + /// to the top of the [panel]. Content at the top of the panel will be covered + /// by this widget. Add padding to the bottom of the `panel` to + /// avoid coverage. + final Widget? header; + + /// Optional persistent widget that floats above the [panel] and + /// attaches to the bottom of the [panel]. Content at the bottom of the panel + /// will be covered by this widget. Add padding to the bottom of the `panel` + /// to avoid coverage. + final Widget? footer; + + /// The height of the sliding panel when fully collapsed. + final double minHeight; + + /// The height of the sliding panel when fully open. + final double maxHeight; + + /// A point between [minHeight] and [maxHeight] that the panel snaps to + /// while animating. A fast swipe on the panel will disregard this point + /// and go directly to the open/close position. This value is represented as a + /// percentage of the total animation distance ([maxHeight] - [minHeight]), + /// so it must be between 0.0 and 1.0, exclusive. + final double? snapPoint; + + /// The amount to inset the children of the sliding panel sheet. + final EdgeInsetsGeometry? padding; + + /// Empty space surrounding the sliding panel sheet. + final EdgeInsetsGeometry? margin; + + /// Set to false to disable the panel from snapping open or closed. + final bool panelSnapping; + + /// Disable panel draggable on scrolling. Defaults to false. + final bool disableDraggableOnScrolling; + + /// If non-null, this can be used to control the state of the panel. + final PanelController? controller; + + /// If non-null, shows a darkening shadow over the [body] as the panel slides open. + final bool backdropEnabled; + + /// Shows a darkening shadow of this [Color] over the [body] as the panel slides open. + final Color backdropColor; + + /// The opacity of the backdrop when the panel is fully open. + /// This value can range from 0.0 to 1.0 where 0.0 is completely transparent + /// and 1.0 is completely opaque. + final double backdropOpacity; + + /// Flag that indicates whether or not tapping the + /// backdrop closes the panel. Defaults to true. + final bool backdropTapClosesPanel; + + /// If non-null, this callback + /// is called as the panel slides around with the + /// current position of the panel. The position is a double + /// between 0.0 and 1.0 where 0.0 is fully collapsed and 1.0 is fully open. + final void Function(double position)? onPanelSlide; + + /// If non-null, this callback is called when the + /// panel is fully opened + final VoidCallback? onPanelOpened; + + /// If non-null, this callback is called when the panel + /// is fully collapsed. + final VoidCallback? onPanelClosed; + + /// If non-null and true, the SlidingUpPanel exhibits a + /// parallax effect as the panel slides up. Essentially, + /// the body slides up as the panel slides up. + final bool parallaxEnabled; + + /// Allows for specifying the extent of the parallax effect in terms + /// of the percentage the panel has slid up/down. Recommended values are + /// within 0.0 and 1.0 where 0.0 is no parallax and 1.0 mimics a + /// one-to-one scrolling effect. Defaults to a 10% parallax. + final double parallaxOffset; + + /// Allows toggling of the draggability of the SlidingUpPanel. + /// Set this to false to prevent the user from being able to drag + /// the panel up and down. Defaults to true. + final bool isDraggable; + + /// Either SlideDirection.UP or SlideDirection.DOWN. Indicates which way + /// the panel should slide. Defaults to UP. If set to DOWN, the panel attaches + /// itself to the top of the screen and is fully opened when the user swipes + /// down on the panel. + final SlideDirection slideDirection; + + /// The default state of the panel; either PanelState.OPEN or PanelState.CLOSED. + /// This value defaults to PanelState.CLOSED which indicates that the panel is + /// in the closed position and must be opened. PanelState.OPEN indicates that + /// by default the Panel is open and must be swiped closed by the user. + final PanelState defaultPanelState; + + /// To attach to a [Scrollable] on a panel that + /// links the panel's position to the scroll position. Useful for implementing + /// infinite scroll behavior + final ScrollController? scrollController; + + final BoxDecoration? panelDecoration; + + const SlidingUpPanel( + {Key? key, + this.body, + this.collapsed, + this.minHeight = 100.0, + this.maxHeight = 500.0, + this.snapPoint, + this.padding, + this.margin, + this.panelDecoration, + this.panelSnapping = true, + this.disableDraggableOnScrolling = false, + this.controller, + this.backdropEnabled = false, + this.backdropColor = Colors.black, + this.backdropOpacity = 0.5, + this.backdropTapClosesPanel = true, + this.onPanelSlide, + this.onPanelOpened, + this.onPanelClosed, + this.parallaxEnabled = false, + this.parallaxOffset = 0.1, + this.isDraggable = true, + this.slideDirection = SlideDirection.up, + this.defaultPanelState = PanelState.closed, + this.header, + this.footer, + this.scrollController, + this.panelBuilder}) + : assert(panelBuilder != null), + assert(0 <= backdropOpacity && backdropOpacity <= 1.0), + assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0), + super(key: key); + + @override + SlidingUpPanelState createState() => SlidingUpPanelState(); +} + +class SlidingUpPanelState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late final ScrollController _scrollController; + + bool _scrollingEnabled = false; + final VelocityTracker _velocityTracker = + VelocityTracker.withKind(PointerDeviceKind.touch); + + bool _isPanelVisible = true; + + @override + void initState() { + super.initState(); + + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + value: widget.defaultPanelState == PanelState.closed + ? 0.0 + : 1.0 //set the default panel state (i.e. set initial value of _ac) + ) + ..addListener(() { + if (widget.onPanelSlide != null) { + widget.onPanelSlide!(_animationController.value); + } + + if (widget.onPanelOpened != null && + (_animationController.value == 1.0 || + _animationController.value == 0.0)) { + widget.onPanelOpened!(); + } + }); + + // prevent the panel content from being scrolled only if the widget is + // draggable and panel scrolling is enabled + _scrollController = widget.scrollController ?? ScrollController(); + _scrollController.addListener(() { + if (widget.isDraggable && + !widget.disableDraggableOnScrolling && + (!_scrollingEnabled || _panelPosition < 1) && + widget.controller?._forceScrollChange != true) { + _scrollController.jumpTo(_scMinOffset); + } + }); + + widget.controller?._addState(this); + } + + @override + Widget build(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + + return Stack( + alignment: widget.slideDirection == SlideDirection.up + ? Alignment.bottomCenter + : Alignment.topCenter, + children: [ + //make the back widget take up the entire back side + if (widget.body != null) + AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Positioned( + top: widget.parallaxEnabled ? _getParallax() : 0.0, + child: child ?? const SizedBox(), + ); + }, + child: SizedBox( + height: mediaQuery.size.height, + width: mediaQuery.size.width, + child: widget.body, + ), + ), + + //the backdrop to overlay on the body + if (widget.backdropEnabled) + GestureDetector( + onVerticalDragEnd: widget.backdropTapClosesPanel + ? (DragEndDetails details) { + // only trigger a close if the drag is towards panel close position + if ((widget.slideDirection == SlideDirection.up ? 1 : -1) * + details.velocity.pixelsPerSecond.dy > + 0) _close(); + } + : null, + onTap: widget.backdropTapClosesPanel ? () => _close() : null, + child: AnimatedBuilder( + animation: _animationController, + builder: (context, _) { + return Container( + height: mediaQuery.size.height, + width: mediaQuery.size.width, + + //set color to null so that touch events pass through + //to the body when the panel is closed, otherwise, + //if a color exists, then touch events won't go through + color: _animationController.value == 0.0 + ? null + : widget.backdropColor.withOpacity( + widget.backdropOpacity * _animationController.value, + ), + ); + }), + ), + + //the actual sliding part + if (_isPanelVisible) + _gestureHandler( + child: AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Container( + height: _animationController.value * + (widget.maxHeight - widget.minHeight) + + widget.minHeight, + margin: widget.margin, + padding: widget.padding, + decoration: widget.panelDecoration, + child: child, + ); + }, + child: Stack( + children: [ + //open panel + Positioned( + top: + widget.slideDirection == SlideDirection.up ? 0.0 : null, + bottom: widget.slideDirection == SlideDirection.down + ? 0.0 + : null, + width: mediaQuery.size.width - + (widget.margin != null + ? widget.margin!.horizontal + : 0) - + (widget.padding != null + ? widget.padding!.horizontal + : 0), + child: SizedBox( + height: widget.maxHeight, + child: widget.panelBuilder!( + _animationController.value, + ), + ), + ), + + // footer + if (widget.footer != null) + Positioned( + top: widget.slideDirection == SlideDirection.up + ? null + : 0.0, + bottom: widget.slideDirection == SlideDirection.down + ? null + : 0.0, + child: widget.footer ?? const SizedBox()), + + // header + if (widget.header != null) + Positioned( + top: widget.slideDirection == SlideDirection.up + ? 0.0 + : null, + bottom: widget.slideDirection == SlideDirection.down + ? 0.0 + : null, + child: widget.header ?? const SizedBox(), + ), + + // collapsed panel + Positioned( + top: + widget.slideDirection == SlideDirection.up ? 0.0 : null, + bottom: widget.slideDirection == SlideDirection.down + ? 0.0 + : null, + width: mediaQuery.size.width - + (widget.margin != null + ? widget.margin!.horizontal + : 0) - + (widget.padding != null + ? widget.padding!.horizontal + : 0), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + height: widget.minHeight, + child: widget.collapsed == null + ? null + : FadeTransition( + opacity: Tween(begin: 1.0, end: 0.0) + .animate(_animationController), + + // if the panel is open ignore pointers (touch events) on the collapsed + // child so that way touch events go through to whatever is underneath + child: IgnorePointer( + ignoring: _animationController.value == 1.0, + child: widget.collapsed, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + double _getParallax() { + if (widget.slideDirection == SlideDirection.up) { + return -_animationController.value * + (widget.maxHeight - widget.minHeight) * + widget.parallaxOffset; + } else { + return _animationController.value * + (widget.maxHeight - widget.minHeight) * + widget.parallaxOffset; + } + } + + bool _ignoreScrollable = false; + bool _isHorizontalScrollableWidget = false; + Axis? _scrollableAxis; + + // returns a gesture detector if panel is used + // and a listener if panelBuilder is used. + // this is because the listener is designed only for use with linking the scrolling of + // panels and using it for panels that don't want to linked scrolling yields odd results + Widget _gestureHandler({required Widget child}) { + if (!widget.isDraggable) return child; + + return Listener( + onPointerDown: (PointerDownEvent e) { + var rb = context.findRenderObject() as RenderBox; + var result = BoxHitTestResult(); + rb.hitTest(result, position: e.position); + + if (_panelPosition == 1) { + _scMinOffset = 0.0; + } + // if there any widget in the path that must force graggable, + // stop it right here + if (result.path.any((entry) => + entry.target.runtimeType == ForceDraggableWidgetRenderBox)) { + widget.controller?._nowTargetForceDraggable = true; + _scMinOffset = _scrollController.offset; + _isHorizontalScrollableWidget = false; + } else if (result.path.any((entry) => + entry.target.runtimeType == HorizontalScrollableWidgetRenderBox)) { + _isHorizontalScrollableWidget = true; + widget.controller?._nowTargetForceDraggable = false; + } else if (result.path.any((entry) => + entry.target.runtimeType == IgnoreDraggableWidgetWidgetRenderBox)) { + _ignoreScrollable = true; + widget.controller?._nowTargetForceDraggable = false; + _isHorizontalScrollableWidget = false; + return; + } else { + widget.controller?._nowTargetForceDraggable = false; + _isHorizontalScrollableWidget = false; + } + _ignoreScrollable = false; + _velocityTracker.addPosition(e.timeStamp, e.position); + }, + onPointerMove: (PointerMoveEvent e) { + if (_scrollableAxis == null) { + if (e.delta.dx.abs() > e.delta.dy.abs()) { + _scrollableAxis = Axis.horizontal; + } else { + _scrollableAxis = Axis.vertical; + } + } + + if (_isHorizontalScrollableWidget && + _scrollableAxis == Axis.horizontal) { + return; + } + + if (_ignoreScrollable) return; + _velocityTracker.addPosition( + e.timeStamp, + e.position, + ); // add current position for velocity tracking + _onGestureSlide(e.delta.dy); + }, + onPointerUp: (PointerUpEvent e) { + if (_ignoreScrollable) return; + _scrollableAxis = null; + _onGestureEnd(_velocityTracker.getVelocity()); + }, + child: child, + ); + } + + double _scMinOffset = 0.0; + + // handles the sliding gesture + void _onGestureSlide(double dy) { + // only slide the panel if scrolling is not enabled + if (widget.controller?._nowTargetForceDraggable == false && + widget.disableDraggableOnScrolling) { + return; + } + if ((!_scrollingEnabled) || + _panelPosition < 1 || + widget.controller?._nowTargetForceDraggable == true) { + if (widget.slideDirection == SlideDirection.up) { + _animationController.value -= + dy / (widget.maxHeight - widget.minHeight); + } else { + _animationController.value += + dy / (widget.maxHeight - widget.minHeight); + } + } + + // if the panel is open and the user hasn't scrolled, we need to determine + // whether to enable scrolling if the user swipes up, or disable closing and + // begin to close the panel if the user swipes down + if (_isPanelOpen && + _scrollController.hasClients && + _scrollController.offset <= _scMinOffset) { + setState(() { + if (dy < 0) { + _scrollingEnabled = true; + } else { + _scrollingEnabled = false; + } + }); + } + } + + // handles when user stops sliding + void _onGestureEnd(Velocity v) { + if (widget.controller?._nowTargetForceDraggable == false && + widget.disableDraggableOnScrolling) { + return; + } + double minFlingVelocity = 365.0; + double kSnap = 8; + + //let the current animation finish before starting a new one + if (_animationController.isAnimating) return; + + // if scrolling is allowed and the panel is open, we don't want to close + // the panel if they swipe up on the scrollable + if (_isPanelOpen && _scrollingEnabled) return; + + //check if the velocity is sufficient to constitute fling to end + double visualVelocity = + -v.pixelsPerSecond.dy / (widget.maxHeight - widget.minHeight); + + // reverse visual velocity to account for slide direction + if (widget.slideDirection == SlideDirection.down) { + visualVelocity = -visualVelocity; + } + + // get minimum distances to figure out where the panel is at + double d2Close = _animationController.value; + double d2Open = 1 - _animationController.value; + double d2Snap = ((widget.snapPoint ?? 3) - _animationController.value) + .abs(); // large value if null results in not every being the min + double minDistance = min(d2Close, min(d2Snap, d2Open)); + + // check if velocity is sufficient for a fling + if (v.pixelsPerSecond.dy.abs() >= minFlingVelocity) { + // snapPoint exists + if (widget.panelSnapping && widget.snapPoint != null) { + if (v.pixelsPerSecond.dy.abs() >= kSnap * minFlingVelocity || + minDistance == d2Snap) { + _animationController.fling(velocity: visualVelocity); + } else { + _flingPanelToPosition(widget.snapPoint!, visualVelocity); + } + + // no snap point exists + } else if (widget.panelSnapping) { + _animationController.fling(velocity: visualVelocity); + + // panel snapping disabled + } else { + _animationController.animateTo( + _animationController.value + visualVelocity * 0.16, + duration: const Duration(milliseconds: 410), + curve: Curves.decelerate, + ); + } + + return; + } + + // check if the controller is already halfway there + if (widget.panelSnapping) { + if (minDistance == d2Close) { + _close(); + } else if (minDistance == d2Snap) { + _flingPanelToPosition(widget.snapPoint!, visualVelocity); + } else { + _open(); + } + } + } + + void _flingPanelToPosition(double targetPos, double velocity) { + final Simulation simulation = SpringSimulation( + SpringDescription.withDampingRatio( + mass: 1.0, + stiffness: 500.0, + ratio: 1.0, + ), + _animationController.value, + targetPos, + velocity); + + _animationController.animateWith(simulation); + } + + //--------------------------------- + //PanelController related functions + //--------------------------------- + + //close the panel + Future _close() { + return _animationController.fling(velocity: -1.0); + } + + //open the panel + Future _open() { + return _animationController.fling(velocity: 1.0); + } + + //hide the panel (completely offscreen) + Future _hide() { + return _animationController.fling(velocity: -1.0).then((x) { + setState(() { + _isPanelVisible = false; + }); + }); + } + + //show the panel (in collapsed mode) + Future _show() { + return _animationController.fling(velocity: -1.0).then((x) { + setState(() { + _isPanelVisible = true; + }); + }); + } + + //animate the panel position to value - must + //be between 0.0 and 1.0 + Future _animatePanelToPosition(double value, + {Duration? duration, Curve curve = Curves.linear}) { + assert(0.0 <= value && value <= 1.0); + return _animationController.animateTo(value, + duration: duration, curve: curve); + } + + //animate the panel position to the snap point + //REQUIRES that widget.snapPoint != null + Future _animatePanelToSnapPoint( + {Duration? duration, Curve curve = Curves.linear}) { + assert(widget.snapPoint != null); + return _animationController.animateTo(widget.snapPoint!, + duration: duration, curve: curve); + } + + //set the panel position to value - must + //be between 0.0 and 1.0 + set _panelPosition(double value) { + assert(0.0 <= value && value <= 1.0); + _animationController.value = value; + } + + //get the current panel position + //returns the % offset from collapsed state + //as a decimal between 0.0 and 1.0 + double get _panelPosition => _animationController.value; + + //returns whether or not + //the panel is still animating + bool get _isPanelAnimating => _animationController.isAnimating; + + //returns whether or not the + //panel is open + bool get _isPanelOpen => _animationController.value == 1.0; + + //returns whether or not the + //panel is closed + bool get _isPanelClosed => _animationController.value == 0.0; + + //returns whether or not the + //panel is shown/hidden + bool get _isPanelShown => _isPanelVisible; +} diff --git a/lib/components/shared/playbutton_card.dart b/lib/components/shared/playbutton_card.dart index 86c3f046..a8a75d30 100644 --- a/lib/components/shared/playbutton_card.dart +++ b/lib/components/shared/playbutton_card.dart @@ -1,13 +1,15 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/assets.gen.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/hooks/use_breakpoint_value.dart'; -import 'package:spotube/hooks/use_brightness_value.dart'; -import 'package:spotube/utils/platform.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; +import 'package:spotube/hooks/utils/use_brightness_value.dart'; final htmlTagRegexp = RegExp(r"<[^>]*>", caseSensitive: true); @@ -28,6 +30,7 @@ class PlaybuttonCard extends HookWidget { final bool isPlaying; final bool isLoading; final String title; + final bool isOwner; const PlaybuttonCard({ required this.imageUrl, @@ -39,6 +42,7 @@ class PlaybuttonCard extends HookWidget { this.onPlaybuttonPressed, this.onAddToQueuePressed, this.onTap, + this.isOwner = false, Key? key, }) : super(key: key); @@ -46,6 +50,7 @@ class PlaybuttonCard extends HookWidget { Widget build(BuildContext context) { final textsKey = useMemoized(() => GlobalKey(), []); final theme = Theme.of(context); + final mediaQuery = MediaQuery.of(context); final radius = BorderRadius.circular(15); final double size = useBreakpointValue( @@ -56,141 +61,169 @@ class PlaybuttonCard extends HookWidget { ); final end = useBreakpointValue( - xs: 15, - sm: 15, - others: 20, - ); - - final textsHeight = useState( - (textsKey.currentContext?.findRenderObject() as RenderBox?) - ?.size - .height ?? - 110.00, + xs: 7, + sm: 7, + others: 15, ); final cleanDescription = useDescription(description); - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - textsHeight.value = - (textsKey.currentContext?.findRenderObject() as RenderBox?) - ?.size - .height ?? - textsHeight.value; - }); - return null; - }, [textsKey]); - - return Stack( - children: [ - Container( - constraints: BoxConstraints(maxWidth: size), - margin: margin, - child: Material( - color: Color.lerp( - theme.colorScheme.surfaceVariant, - theme.colorScheme.surface, - useBrightnessValue(.9, .7), - ), - borderRadius: radius, - shadowColor: theme.colorScheme.background, - elevation: 3, - child: InkWell( - mouseCursor: SystemMouseCursors.click, - onTap: onTap, - borderRadius: radius, - splashFactory: theme.splashFactory, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + return Container( + constraints: BoxConstraints(maxWidth: size), + margin: margin, + child: Material( + color: Color.lerp( + theme.colorScheme.surfaceVariant, + theme.colorScheme.surface, + useBrightnessValue(.9, .7), + ), + borderRadius: radius, + shadowColor: theme.colorScheme.background, + elevation: 3, + child: InkWell( + mouseCursor: SystemMouseCursors.click, + onTap: onTap, + borderRadius: radius, + splashFactory: theme.splashFactory, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Stack( + clipBehavior: Clip.none, children: [ - Padding( + Container( + margin: const EdgeInsets.fromLTRB(8, 8, 8, 0), padding: const EdgeInsets.only( left: 8, right: 8, top: 8, ), - child: ClipRRect( + height: mediaQuery.smAndDown + ? 120 + : mediaQuery.mdAndDown + ? 130 + : 150, + decoration: BoxDecoration( borderRadius: radius, - child: UniversalImage( - path: imageUrl, - placeholder: Assets.albumPlaceholder.path, + image: DecorationImage( + image: UniversalImage.imageProvider(imageUrl), + fit: BoxFit.cover, ), ), ), - Column( - key: textsKey, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 15), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: AutoSizeText( - title, - maxLines: 1, - minFontSize: theme.textTheme.bodyMedium!.fontSize!, - overflow: TextOverflow.ellipsis, - ), - ), - if (cleanDescription != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: AutoSizeText( - cleanDescription, - maxLines: 2, - style: theme.textTheme.bodySmall?.copyWith( - color: - theme.colorScheme.onSurface.withOpacity(.5), + if (isOwner) + Positioned( + top: 15, + left: 15, + child: AnimatedSize( + duration: const Duration(milliseconds: 150), + alignment: Alignment.centerLeft, + curve: Curves.easeInExpo, + child: HoverBuilder(builder: (context, isHovered) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.blueAccent, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + SpotubeIcons.user, + color: Colors.white, + size: 16, + ), + if (isHovered) + Text( + "Owned by you", + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.white, + ), + ), + ], + ), + ); + }), + ), + ), + Positioned( + right: end, + bottom: -15, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isPlaying) + Skeleton.keep( + child: IconButton( + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.background, + foregroundColor: theme.colorScheme.primary, + minimumSize: const Size.square(10), + ), + icon: const Icon(SpotubeIcons.queueAdd), + onPressed: isLoading ? null : onAddToQueuePressed, ), - overflow: TextOverflow.ellipsis, ), + const Gap(5), + IconButton( + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.primaryContainer, + foregroundColor: theme.colorScheme.primary, + minimumSize: const Size.square(10), + ), + icon: Skeleton.keep( + child: isLoading + ? SizedBox.fromSize( + size: const Size.square(15), + child: const CircularProgressIndicator( + strokeWidth: 2), + ) + : isPlaying + ? const Icon(SpotubeIcons.pause) + : const Icon(SpotubeIcons.play), + ), + onPressed: isLoading ? null : onPlaybuttonPressed, ), - const SizedBox(height: 10), - ], + ], + ), ), ], ), - ), - ), - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 300), - right: end, - bottom: textsHeight.value - (kIsMobile ? 5 : 10), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!isPlaying) - IconButton( - style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.background, - foregroundColor: theme.colorScheme.primary, - minimumSize: const Size.square(10), + Column( + key: textsKey, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 15), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: AutoSizeText( + title, + maxLines: 1, + minFontSize: theme.textTheme.bodyMedium!.fontSize!, + overflow: TextOverflow.ellipsis, + ), ), - icon: const Icon(SpotubeIcons.queueAdd), - onPressed: isLoading ? null : onAddToQueuePressed, - ), - const SizedBox(height: 5), - IconButton( - style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.primaryContainer, - foregroundColor: theme.colorScheme.primary, - minimumSize: const Size.square(10), - ), - icon: isLoading - ? SizedBox.fromSize( - size: const Size.square(15), - child: const CircularProgressIndicator(strokeWidth: 2), - ) - : isPlaying - ? const Icon(SpotubeIcons.pause) - : const Icon(SpotubeIcons.play), - onPressed: isLoading ? null : onPlaybuttonPressed, + if (cleanDescription != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: AutoSizeText( + cleanDescription, + maxLines: 2, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(.5), + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(height: 10), + ], ), ], ), ), - ], + ), ); } } diff --git a/lib/components/shared/shimmers/shimmer_artist_profile.dart b/lib/components/shared/shimmers/shimmer_artist_profile.dart deleted file mode 100644 index 940c4e81..00000000 --- a/lib/components/shared/shimmers/shimmer_artist_profile.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -import 'package:skeleton_text/skeleton_text.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart'; -import 'package:spotube/extensions/theme.dart'; -import 'package:spotube/hooks/use_breakpoint_value.dart'; - -class ShimmerArtistProfile extends HookWidget { - const ShimmerArtistProfile({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final shimmerTheme = ShimmerColorTheme( - shimmerBackgroundColor: isDark ? Colors.grey[700] : Colors.grey[200], - shimmerColor: isDark ? Colors.grey[800] : Colors.grey[300], - ); - final shimmerColor = shimmerTheme.shimmerColor ?? Colors.white; - final shimmerBackgroundColor = - shimmerTheme.shimmerBackgroundColor ?? Colors.grey; - - final avatarWidth = useBreakpointValue( - xs: MediaQuery.of(context).size.width * 0.80, - sm: MediaQuery.of(context).size.width * 0.80, - md: MediaQuery.of(context).size.width * 0.50, - lg: MediaQuery.of(context).size.width * 0.30, - xl: MediaQuery.of(context).size.width * 0.30, - xxl: MediaQuery.of(context).size.width * 0.30, - ) ?? - 0; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(20), - child: SkeletonAnimation( - shimmerColor: shimmerColor, - borderRadius: BorderRadius.circular(avatarWidth), - shimmerDuration: 1000, - child: Container( - width: avatarWidth, - height: avatarWidth, - decoration: BoxDecoration( - color: shimmerBackgroundColor, - borderRadius: BorderRadius.circular(avatarWidth), - ), - ), - ), - ), - const SizedBox(width: 10), - const Flexible(child: ShimmerTrackTile(noSliver: true)), - ], - ); - } -} diff --git a/lib/components/shared/shimmers/shimmer_categories.dart b/lib/components/shared/shimmers/shimmer_categories.dart deleted file mode 100644 index e9f442d4..00000000 --- a/lib/components/shared/shimmers/shimmer_categories.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; -import 'package:spotube/extensions/theme.dart'; -import 'package:spotube/hooks/use_breakpoint_value.dart'; - -class ShimmerCategories extends HookWidget { - const ShimmerCategories({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final shimmerTheme = ShimmerColorTheme( - shimmerBackgroundColor: isDark ? Colors.grey[700] : Colors.grey[200], - ); - final shimmerBackgroundColor = - shimmerTheme.shimmerBackgroundColor ?? Colors.grey; - - final shimmerCount = useBreakpointValue( - xs: 2, - sm: 2, - md: 3, - lg: 3, - xl: 6, - xxl: 8, - ); - - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.only(left: 15), - height: 10, - width: 100, - decoration: BoxDecoration( - color: shimmerBackgroundColor, - borderRadius: BorderRadius.circular(10), - ), - ), - const SizedBox(height: 10), - Align( - alignment: Alignment.topLeft, - child: ShimmerPlaybuttonCard(count: shimmerCount), - ), - ], - ), - ); - } -} diff --git a/lib/components/shared/shimmers/shimmer_lyrics.dart b/lib/components/shared/shimmers/shimmer_lyrics.dart index b0fba340..b225c008 100644 --- a/lib/components/shared/shimmers/shimmer_lyrics.dart +++ b/lib/components/shared/shimmers/shimmer_lyrics.dart @@ -1,69 +1,38 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; -import 'package:skeleton_text/skeleton_text.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/theme.dart'; - -const widths = [20, 56, 89, 60, 25, 69]; +import 'package:skeletonizer/skeletonizer.dart'; class ShimmerLyrics extends HookWidget { const ShimmerLyrics({Key? key}) : super(key: key); @override Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final shimmerTheme = ShimmerColorTheme( - shimmerBackgroundColor: isDark ? Colors.grey[700] : Colors.grey[200], - shimmerColor: isDark ? Colors.grey[800] : Colors.grey[300], - ); - final shimmerColor = shimmerTheme.shimmerColor ?? Colors.white; - final shimmerBackgroundColor = - shimmerTheme.shimmerBackgroundColor ?? Colors.grey; - - final mediaQuery = MediaQuery.of(context); - - return ListView.builder( - itemCount: 20, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) { - final widthsCp = [...widths]; - if (mediaQuery.isMd) { - widthsCp.removeLast(); - } - if (mediaQuery.smAndDown) { - widthsCp.removeLast(); - widthsCp.removeLast(); - } - widthsCp.shuffle(); - return Container( - margin: const EdgeInsets.symmetric(vertical: 5), - child: Row( + return Skeletonizer( + enabled: true, + child: ListView.builder( + itemCount: 30, + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemBuilder: (context, index) { + final texts = [ + "Lorem ipsum", + "consectetur.", + "Sed", + "Sed non risus", + ]..shuffle(); + return Row( mainAxisAlignment: MainAxisAlignment.center, - children: widthsCp.map( - (width) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: SkeletonAnimation( - shimmerColor: shimmerColor, - shimmerDuration: 1000, - child: Container( - height: 10, - width: width.toDouble(), - decoration: BoxDecoration( - color: shimmerBackgroundColor, - borderRadius: BorderRadius.circular(10), - ), - margin: const EdgeInsets.only(top: 10), - ), - ), - ); - }, - ).toList(), - ), - ); - }, + children: [ + for (final text in texts) ...[ + Text(text), + if (text != texts.last) const Gap(10), + ], + ], + ); + }, + ), ); } } diff --git a/lib/components/shared/shimmers/shimmer_playbutton_card.dart b/lib/components/shared/shimmers/shimmer_playbutton_card.dart deleted file mode 100644 index 82da5bd9..00000000 --- a/lib/components/shared/shimmers/shimmer_playbutton_card.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -import 'package:spotube/hooks/use_breakpoint_value.dart'; - -class ShimmerPlaybuttonCardPainter extends CustomPainter { - final Color background; - final Color foreground; - ShimmerPlaybuttonCardPainter({ - required this.background, - required this.foreground, - }); - - @override - void paint(Canvas canvas, Size size) { - const radius = Radius.circular(15); - - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(0, 0, size.width, size.height), - radius, - ), - Paint()..color = background, - ); - - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(8, 8, size.width - 16, size.height - 90), - radius, - ), - Paint()..color = foreground, - ); - - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(12, size.height - 67, size.width / 2, 10), - radius, - ), - Paint()..color = foreground, - ); - - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(12, size.height - 45, size.width - 24, 8), - radius, - ), - Paint()..color = foreground, - ); - - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(12, size.height - 30, size.width * .4, 8), - radius, - ), - Paint()..color = foreground, - ); - - canvas.drawCircle( - Offset(size.width * .85, size.height * .50), - 17, - Paint()..color = background, - ); - - canvas.drawCircle( - Offset(size.width * .85, size.height * .67), - 17, - Paint()..color = background, - ); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) { - return false; - } -} - -class ShimmerPlaybuttonCard extends HookWidget { - final int count; - - const ShimmerPlaybuttonCard({ - Key? key, - this.count = 1, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final Size size = useBreakpointValue( - xs: const Size(130, 200), - sm: const Size(130, 200), - md: const Size(150, 220), - others: const Size(170, 240), - ); - - final isDark = theme.brightness == Brightness.dark; - final bgColor = theme.colorScheme.surfaceVariant.withOpacity(.2); - final fgColor = Color.lerp( - theme.colorScheme.surfaceVariant, - isDark ? Colors.black : Colors.white, - .4, - ); - - return Wrap( - spacing: 20, - runSpacing: 20, - children: [ - for (var i = 0; i < count; i++) ...[ - CustomPaint( - size: size, - painter: ShimmerPlaybuttonCardPainter( - background: bgColor, - foreground: fgColor!, - ), - ), - ] - ], - ); - } -} diff --git a/lib/components/shared/shimmers/shimmer_track_tile.dart b/lib/components/shared/shimmers/shimmer_track_tile.dart deleted file mode 100644 index 070b2f09..00000000 --- a/lib/components/shared/shimmers/shimmer_track_tile.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:spotube/extensions/theme.dart'; - -class ShimmerTrackTilePainter extends CustomPainter { - final Color background; - final Color foreground; - ShimmerTrackTilePainter({ - required this.background, - required this.foreground, - }); - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = background - ..style = PaintingStyle.fill; - - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(0, 0, size.width, size.height), - const Radius.circular(5), - ), - paint, - ); - - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(0, 0, size.height, size.height), - const Radius.circular(5), - ), - Paint()..color = foreground, - ); - - canvas.drawRRect( - RRect.fromRectAndRadius( - const Rect.fromLTWH(70, 10, 100, 10), - const Radius.circular(5), - ), - Paint()..color = foreground, - ); - - // draw Icons.play - const icon = Icons.play_arrow_outlined; - TextPainter textPainter = TextPainter(textDirection: TextDirection.rtl); - textPainter.text = TextSpan( - text: String.fromCharCode(icon.codePoint), - style: TextStyle( - fontSize: 40.0, - fontFamily: icon.fontFamily, - color: background, - ), - ); - textPainter.layout(); - textPainter.paint(canvas, const Offset(10, 10)); - - canvas.drawRRect( - RRect.fromRectAndRadius( - const Rect.fromLTWH(70, 30, 170, 7), - const Radius.circular(5), - ), - Paint()..color = foreground, - ); - } - - @override - bool shouldRepaint(CustomPainter oldDelegate) { - return false; - } -} - -class ShimmerTrackTile extends StatelessWidget { - final bool noSliver; - const ShimmerTrackTile({super.key, this.noSliver = false}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - final shimmerTheme = ShimmerColorTheme( - shimmerBackgroundColor: isDark ? Colors.grey[700] : Colors.grey[200], - shimmerColor: isDark ? Colors.grey[800] : Colors.grey[300], - ); - - if (noSliver) { - return ListView.builder( - itemCount: 5, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.only(bottom: 8.0, left: 8, right: 8), - child: CustomPaint( - size: const Size(double.infinity, 60), - painter: ShimmerTrackTilePainter( - background: shimmerTheme.shimmerBackgroundColor ?? - theme.scaffoldBackgroundColor, - foreground: shimmerTheme.shimmerColor ?? theme.cardColor, - ), - ), - ); - }, - ); - } - - return SliverList( - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) => Padding( - padding: const EdgeInsets.only(bottom: 8.0, left: 8, right: 8), - child: CustomPaint( - size: const Size(double.infinity, 60), - painter: ShimmerTrackTilePainter( - background: shimmerTheme.shimmerBackgroundColor ?? - theme.scaffoldBackgroundColor, - foreground: shimmerTheme.shimmerColor ?? theme.cardColor, - ), - ), - ), - childCount: 5, - ), - ); - } -} diff --git a/lib/components/shared/themed_button_tab_bar.dart b/lib/components/shared/themed_button_tab_bar.dart index d38c3a19..d5798189 100644 --- a/lib/components/shared/themed_button_tab_bar.dart +++ b/lib/components/shared/themed_button_tab_bar.dart @@ -1,9 +1,7 @@ import 'package:buttons_tabbar/buttons_tabbar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:spotube/hooks/use_breakpoint_value.dart'; -import 'package:spotube/hooks/use_brightness_value.dart'; -import 'package:spotube/utils/platform.dart'; +import 'package:spotube/hooks/utils/use_brightness_value.dart'; class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { final List tabs; @@ -17,16 +15,8 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { Color.lerp(theme.colorScheme.primary, Colors.black, 0.7)!, ); - final breakpoint = useBreakpointValue( - xs: 85.0, - sm: 85.0, - md: 35.0, - others: 0.0, - ); - return Padding( - padding: EdgeInsets.only( - left: kIsMacOS ? breakpoint : 0, + padding: const EdgeInsets.only( top: 8, bottom: 8, ), diff --git a/lib/components/shared/track_table/track_collection_view/track_collection_heading.dart b/lib/components/shared/track_table/track_collection_view/track_collection_heading.dart deleted file mode 100644 index c82b8177..00000000 --- a/lib/components/shared/track_table/track_collection_view/track_collection_heading.dart +++ /dev/null @@ -1,219 +0,0 @@ -import 'dart:ui'; - -import 'package:fl_query/fl_query.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:palette_generator/palette_generator.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/album/album_card.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/playbutton_card.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; - -enum PlayButtonState { - playing, - notPlaying, - loading, -} - -class TrackCollectionHeading extends HookConsumerWidget { - final String title; - final String? description; - final String titleImage; - final List buttons; - final AlbumSimple? album; - final Query, T> tracksSnapshot; - final PlayButtonState playingState; - final void Function([Track? currentTrack]) onPlay; - final void Function([Track? currentTrack]) onShuffledPlay; - final PaletteColor? color; - - const TrackCollectionHeading({ - Key? key, - required this.title, - required this.titleImage, - required this.buttons, - required this.tracksSnapshot, - required this.playingState, - required this.onPlay, - required this.onShuffledPlay, - required this.color, - this.description, - this.album, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - - final cleanDescription = useDescription(description); - - return LayoutBuilder( - builder: (context, constrains) { - return DecoratedBox( - decoration: BoxDecoration( - image: DecorationImage( - image: UniversalImage.imageProvider(titleImage), - fit: BoxFit.cover, - ), - ), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.black45, - theme.colorScheme.surface, - ], - begin: const FractionalOffset(0, 0), - end: const FractionalOffset(0, 1), - tileMode: TileMode.clamp, - ), - ), - child: Material( - type: MaterialType.transparency, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - ), - child: SafeArea( - child: Flex( - direction: constrains.mdAndDown - ? Axis.vertical - : Axis.horizontal, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 200), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: UniversalImage( - path: titleImage, - placeholder: Assets.albumPlaceholder.path, - ), - ), - ), - const SizedBox(width: 10, height: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - Text( - title, - style: theme.textTheme.titleLarge!.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - if (album != null) - Text( - "${AlbumType.from(album?.albumType).formatted} • ${context.l10n.released} • ${DateTime.tryParse( - album?.releaseDate ?? "", - )?.year}", - style: theme.textTheme.titleMedium!.copyWith( - color: Colors.white, - fontWeight: FontWeight.normal, - ), - ), - if (cleanDescription != null) - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: constrains.mdAndDown ? 400 : 300, - ), - child: Text( - cleanDescription, - style: const TextStyle(color: Colors.white), - maxLines: 2, - overflow: TextOverflow.fade, - ), - ), - const SizedBox(height: 10), - IconTheme( - data: theme.iconTheme.copyWith( - color: Colors.white, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: buttons, - ), - ), - const SizedBox(height: 10), - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: constrains.mdAndDown ? 400 : 300, - ), - child: Row( - mainAxisSize: constrains.smAndUp - ? MainAxisSize.min - : MainAxisSize.min, - children: [ - Expanded( - child: FilledButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - ), - label: Text(context.l10n.shuffle), - icon: const Icon(SpotubeIcons.shuffle), - onPressed: tracksSnapshot.data == null || - playingState == - PlayButtonState.playing - ? null - : onShuffledPlay, - ), - ), - const SizedBox(width: 10), - Expanded( - child: FilledButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: color?.color, - foregroundColor: color?.bodyTextColor, - ), - onPressed: tracksSnapshot.data != null || - playingState == - PlayButtonState.loading - ? onPlay - : null, - icon: switch (playingState) { - PlayButtonState.playing => - const Icon(SpotubeIcons.pause), - PlayButtonState.notPlaying => - const Icon(SpotubeIcons.play), - PlayButtonState.loading => - const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: .7, - ), - ), - }, - label: Text( - playingState == PlayButtonState.playing - ? context.l10n.stop - : context.l10n.play, - ), - ), - ), - ], - ), - ), - ], - ) - ], - ), - ), - ), - ), - ), - ), - ); - }, - ); - } -} diff --git a/lib/components/shared/track_table/track_collection_view/track_collection_view.dart b/lib/components/shared/track_table/track_collection_view/track_collection_view.dart deleted file mode 100644 index b4a1314e..00000000 --- a/lib/components/shared/track_table/track_collection_view/track_collection_view.dart +++ /dev/null @@ -1,255 +0,0 @@ -import 'package:fl_query/fl_query.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/spotube_icons.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart'; -import 'package:spotube/components/shared/track_table/tracks_table_view.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_custom_status_bar_color.dart'; -import 'package:spotube/hooks/use_palette_color.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:flutter/material.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class TrackCollectionView extends HookConsumerWidget { - final logger = getLogger(TrackCollectionView); - final String id; - final String title; - final String? description; - final Query, T> tracksSnapshot; - final String titleImage; - final PlayButtonState playingState; - final void Function([Track? currentTrack]) onPlay; - final void Function([Track? currentTrack]) onShuffledPlay; - final void Function() onAddToQueue; - final void Function() onShare; - final Widget? heartBtn; - final AlbumSimple? album; - - final bool showShare; - final bool isOwned; - final bool bottomSpace; - - final String routePath; - TrackCollectionView({ - required this.title, - required this.id, - required this.tracksSnapshot, - required this.titleImage, - required this.playingState, - required this.onPlay, - required this.onShuffledPlay, - required this.onAddToQueue, - required this.onShare, - required this.routePath, - this.heartBtn, - this.album, - this.description, - this.showShare = true, - this.isOwned = false, - this.bottomSpace = false, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final auth = ref.watch(AuthenticationNotifier.provider); - - final color = usePaletteGenerator(titleImage).dominantColor; - - final List buttons = [ - if (showShare) - IconButton( - icon: const Icon(SpotubeIcons.share), - onPressed: onShare, - ), - if (heartBtn != null && auth != null) heartBtn!, - IconButton( - onPressed: playingState == PlayButtonState.playing - ? null - : tracksSnapshot.data != null - ? onAddToQueue - : null, - icon: const Icon( - SpotubeIcons.queueAdd, - ), - ), - ]; - - final controller = useScrollController(); - - final collapsed = useState(false); - - useCustomStatusBarColor( - Colors.transparent, - GoRouterState.of(context).matchedLocation == routePath, - ); - - useEffect(() { - listener() { - if (controller.position.pixels >= 390 && !collapsed.value) { - collapsed.value = true; - } else if (controller.position.pixels < 390 && collapsed.value) { - collapsed.value = false; - } - } - - controller.addListener(listener); - - return () => controller.removeListener(listener); - }, [collapsed.value]); - - return Scaffold( - appBar: kIsDesktop - ? const PageWindowTitleBar( - backgroundColor: Colors.transparent, - foregroundColor: Colors.white, - leadingWidth: 400, - leading: Align( - alignment: Alignment.centerLeft, - child: BackButton(color: Colors.white), - ), - ) - : null, - extendBodyBehindAppBar: kIsDesktop, - body: RefreshIndicator( - onRefresh: () async { - await tracksSnapshot.refresh(); - }, - child: CustomScrollView( - controller: controller, - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - SliverAppBar( - actions: [ - AnimatedScale( - duration: const Duration(milliseconds: 200), - scale: collapsed.value ? 1 : 0, - child: Row( - mainAxisSize: MainAxisSize.min, - children: buttons, - ), - ), - AnimatedScale( - duration: const Duration(milliseconds: 200), - scale: collapsed.value ? 1 : 0, - child: IconButton( - tooltip: context.l10n.shuffle, - icon: const Icon(SpotubeIcons.shuffle), - onPressed: playingState == PlayButtonState.playing - ? null - : onShuffledPlay, - ), - ), - AnimatedScale( - duration: const Duration(milliseconds: 200), - scale: collapsed.value ? 1 : 0, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - shape: const CircleBorder(), - backgroundColor: theme.colorScheme.inversePrimary, - ), - onPressed: tracksSnapshot.data != null ? onPlay : null, - child: switch (playingState) { - PlayButtonState.playing => - const Icon(SpotubeIcons.pause), - PlayButtonState.notPlaying => - const Icon(SpotubeIcons.play), - PlayButtonState.loading => const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: .7, - ), - ), - }, - ), - ), - ], - floating: false, - pinned: true, - expandedHeight: 400, - automaticallyImplyLeading: kIsMobile, - leading: - kIsMobile ? const BackButton(color: Colors.white) : null, - iconTheme: IconThemeData(color: color?.titleTextColor), - primary: true, - backgroundColor: color?.color.withOpacity(.8), - title: collapsed.value - ? Text( - title, - style: theme.textTheme.titleMedium!.copyWith( - color: color?.titleTextColor, - fontWeight: FontWeight.w600, - ), - ) - : null, - centerTitle: true, - flexibleSpace: FlexibleSpaceBar( - background: TrackCollectionHeading( - color: color, - title: title, - description: description, - titleImage: titleImage, - playingState: playingState, - onPlay: onPlay, - onShuffledPlay: onShuffledPlay, - tracksSnapshot: tracksSnapshot, - buttons: buttons, - album: album, - ), - ), - ), - HookBuilder( - builder: (context) { - if (tracksSnapshot.isLoading || !tracksSnapshot.hasData) { - return const ShimmerTrackTile(); - } else if (tracksSnapshot.hasError) { - return SliverToBoxAdapter( - child: Text( - context.l10n.error(tracksSnapshot.error ?? ""), - ), - ); - } - - return TracksTableView( - (tracksSnapshot.data ?? []).map( - (track) { - if (track is Track) { - return track; - } else { - return TypeConversionUtils.simpleTrack_X_Track( - track, - album!, - ); - } - }, - ).toList(), - onTrackPlayButtonPressed: onPlay, - playlistId: id, - userPlaylist: isOwned, - onFiltering: () { - // scroll the flexible space - // to allow more space for search results - controller.animateTo( - 330, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - ); - }, - ); - }, - ) - ], - ), - )); - } -} diff --git a/lib/components/shared/track_table/track_options.dart b/lib/components/shared/track_table/track_options.dart deleted file mode 100644 index a1bc3fef..00000000 --- a/lib/components/shared/track_table/track_options.dart +++ /dev/null @@ -1,323 +0,0 @@ -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:spotify/spotify.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/track_details_dialog.dart'; -import 'package:spotube/components/shared/heart_button.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/local_track.dart'; -import 'package:spotube/models/spotube_track.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -enum TrackOptionValue { - share, - addToPlaylist, - addToQueue, - removeFromPlaylist, - removeFromQueue, - blacklist, - delete, - playNext, - favorite, - details, - download, -} - -class TrackOptions extends HookConsumerWidget { - final Track track; - final bool userPlaylist; - final String? playlistId; - const TrackOptions({ - Key? key, - required this.track, - this.userPlaylist = false, - this.playlistId, - }) : super(key: key); - - void actionShare(BuildContext context, Track track) { - final data = "https://open.spotify.com/track/${track.id}"; - Clipboard.setData(ClipboardData(text: data)).then((_) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - context.l10n.copied_to_clipboard(data), - textAlign: TextAlign.center, - ), - ), - ); - }); - } - - void actionAddToPlaylist(BuildContext context, Track track) { - showDialog( - context: context, - builder: (context) => PlaylistAddTrackDialog( - tracks: [track], - ), - ); - } - - @override - Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playback = ref.watch(ProxyPlaylistNotifier.notifier); - final scaffoldMessenger = ScaffoldMessenger.of(context); - final auth = ref.watch(AuthenticationNotifier.provider); - ref.watch(downloadManagerProvider); - final downloadManager = ref.watch(downloadManagerProvider.notifier); - final blacklist = ref.watch(BlackListNotifier.provider); - - final favorites = useTrackToggleLike(track, ref); - - final isBlackListed = useMemoized( - () => blacklist.contains( - BlacklistedElement.track( - track.id!, - track.name!, - ), - ), - [blacklist, track], - ); - - final removingTrack = useState(null); - final removeTrack = useMutations.playlist.removeTrackOf( - ref, - playlistId ?? "", - ); - - final isInQueue = useMemoized(() { - if (playlist.activeTrack == null) return false; - return downloadManager.isActive(playlist.activeTrack!); - }, [ - playlist.activeTrack, - downloadManager, - ]); - - final progressNotifier = useMemoized(() { - final spotubeTrack = downloadManager.mapToSpotubeTrack(track); - if (spotubeTrack == null) return null; - return downloadManager.getProgressNotifier(spotubeTrack); - }); - - return ListTileTheme( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - child: AdaptivePopSheetList( - onSelected: (value) async { - switch (value) { - case TrackOptionValue.delete: - await File((track as LocalTrack).path).delete(); - ref.refresh(localTracksProvider); - break; - case TrackOptionValue.addToQueue: - await playback.addTrack(track); - if (context.mounted) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.added_track_to_queue(track.name!), - ), - ), - ); - } - break; - case TrackOptionValue.playNext: - playback.addTracksAtFirst([track]); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.track_will_play_next(track.name!), - ), - ), - ); - break; - case TrackOptionValue.removeFromQueue: - playback.removeTrack(track.id!); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.removed_track_from_queue( - track.name!, - ), - ), - ), - ); - break; - case TrackOptionValue.favorite: - favorites.toggleTrackLike.mutate(favorites.isLiked); - break; - case TrackOptionValue.addToPlaylist: - actionAddToPlaylist(context, track); - break; - case TrackOptionValue.removeFromPlaylist: - removingTrack.value = track.uri; - removeTrack.mutate(track.uri!); - break; - case TrackOptionValue.blacklist: - if (isBlackListed) { - ref.read(BlackListNotifier.provider.notifier).remove( - BlacklistedElement.track(track.id!, track.name!), - ); - } else { - ref.read(BlackListNotifier.provider.notifier).add( - BlacklistedElement.track(track.id!, track.name!), - ); - } - break; - case TrackOptionValue.share: - actionShare(context, track); - break; - case TrackOptionValue.details: - showDialog( - context: context, - builder: (context) => TrackDetailsDialog(track: track), - ); - break; - case TrackOptionValue.download: - await downloadManager.addToQueue(track); - break; - } - }, - icon: const Icon(SpotubeIcons.moreHorizontal), - headings: [ - ListTile( - dense: true, - leading: AspectRatio( - aspectRatio: 1, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString( - track.album!.images, - placeholder: ImagePlaceholder.albumArt), - fit: BoxFit.cover, - ), - ), - ), - title: Text( - track.name!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), - subtitle: Align( - alignment: Alignment.centerLeft, - child: TypeConversionUtils.artists_X_ClickableArtists( - track.artists!, - ), - ), - ), - ], - children: switch (track.runtimeType) { - LocalTrack => [ - PopSheetEntry( - value: TrackOptionValue.delete, - leading: const Icon(SpotubeIcons.trash), - title: Text(context.l10n.delete), - ) - ], - _ => [ - if (!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.addToPlaylist, - leading: const Icon(SpotubeIcons.playlistAdd), - title: Text(context.l10n.add_to_playlist), - ), - if (userPlaylist && auth != null) - PopSheetEntry( - value: TrackOptionValue.removeFromPlaylist, - leading: (removeTrack.isMutating || !removeTrack.hasData) && - removingTrack.value == track.uri - ? const CircularProgressIndicator() - : const Icon(SpotubeIcons.removeFilled), - title: Text(context.l10n.remove_from_playlist), - ), - PopSheetEntry( - value: TrackOptionValue.download, - enabled: !isInQueue, - leading: isInQueue - ? HookBuilder(builder: (context) { - final progress = useListenable(progressNotifier!); - return CircularProgressIndicator( - value: progress.value, - ); - }) - : const Icon(SpotubeIcons.download), - title: Text(context.l10n.download_track), - ), - PopSheetEntry( - value: TrackOptionValue.blacklist, - leading: const Icon(SpotubeIcons.playlistRemove), - iconColor: !isBlackListed ? Colors.red[400] : null, - textColor: !isBlackListed ? Colors.red[400] : null, - title: Text( - isBlackListed - ? context.l10n.remove_from_blacklist - : context.l10n.add_to_blacklist, - ), - ), - PopSheetEntry( - value: TrackOptionValue.share, - leading: const Icon(SpotubeIcons.share), - title: Text(context.l10n.share), - ), - PopSheetEntry( - value: TrackOptionValue.details, - leading: const Icon(SpotubeIcons.info), - title: Text(context.l10n.details), - ), - ] - }, - ), - ); - } -} diff --git a/lib/components/shared/track_table/track_tile.dart b/lib/components/shared/track_table/track_tile.dart deleted file mode 100644 index 7926f55a..00000000 --- a/lib/components/shared/track_table/track_tile.dart +++ /dev/null @@ -1,231 +0,0 @@ -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/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_table/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 VoidCallback? 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 isPlaying = track.id == playlist.activeTrack?.id; - - return LayoutBuilder(builder: (context, constrains) { - return HoverBuilder( - permanentState: isPlaying || constrains.smAndDown ? true : null, - builder: (context, isHovering) { - return ListTile( - selected: isPlaying, - onTap: onTap, - 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: 34, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Text( - '$index', - 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: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: !isHovering - ? const SizedBox.shrink() - : isPlaying && playlist.isFetching - ? const SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: Colors.white, - ), - ) - : isPlaying - ? Icon( - SpotubeIcons.pause, - color: theme.colorScheme.primary, - ) - : const Icon(SpotubeIcons.play), - ), - ), - ), - ), - ], - ), - ], - ), - title: Row( - children: [ - Expanded( - flex: 6, - child: Text( - track.name!, - 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(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - TrackOptions( - track: track, - playlistId: playlistId, - userPlaylist: userPlaylist, - ), - ], - ), - ); - }, - ); - }); - } -} diff --git a/lib/components/shared/track_table/tracks_table_view.dart b/lib/components/shared/track_table/tracks_table_view.dart deleted file mode 100644 index d412dd36..00000000 --- a/lib/components/shared/track_table/tracks_table_view.dart +++ /dev/null @@ -1,354 +0,0 @@ -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: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/expandable_search/expandable_search.dart'; -import 'package:spotube/components/shared/fallbacks/not_found.dart'; -import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; -import 'package:spotube/components/shared/track_table/track_tile.dart'; -import 'package:spotube/components/library/user_local_tracks.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; - -import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/utils/service_utils.dart'; - -final trackCollectionSortState = - StateProvider.family((ref, _) => SortBy.none); - -class TracksTableView extends HookConsumerWidget { - final void Function(Track currentTrack)? onTrackPlayButtonPressed; - final List tracks; - final bool userPlaylist; - final String? playlistId; - final bool isSliver; - - final Widget? heading; - - final VoidCallback? onFiltering; - - const TracksTableView( - this.tracks, { - Key? key, - this.onTrackPlayButtonPressed, - this.onFiltering, - this.userPlaylist = false, - this.playlistId, - this.heading, - this.isSliver = true, - }) : super(key: key); - - @override - Widget build(context, ref) { - final mediaQuery = MediaQuery.of(context); - - ref.watch(ProxyPlaylistNotifier.provider); - final playback = ref.watch(ProxyPlaylistNotifier.notifier); - ref.watch(downloadManagerProvider); - final downloader = ref.watch(downloadManagerProvider.notifier); - TextStyle tableHeadStyle = - const TextStyle(fontWeight: FontWeight.bold, fontSize: 16); - - final selected = useState>([]); - final showCheck = useState(false); - final sortBy = ref.watch(trackCollectionSortState(playlistId ?? '')); - - final isFiltering = useState(false); - - final searchController = useTextEditingController(); - final searchFocus = useFocusNode(); - - // this will trigger update on each change in searchController - useValueListenable(searchController); - - final filteredTracks = useMemoized(() { - if (searchController.text.isEmpty) { - return tracks; - } - return tracks - .map((e) => (weightedRatio(e.name!, searchController.text), e)) - .sorted((a, b) => b.$1.compareTo(a.$1)) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList(); - }, [tracks, searchController.text]); - - final sortedTracks = useMemoized( - () { - return ServiceUtils.sortTracks(filteredTracks, sortBy); - }, - [filteredTracks, sortBy], - ); - - final selectedTracks = useMemoized( - () => sortedTracks.where( - (track) => selected.value.contains(track.id), - ), - [sortedTracks], - ); - - final children = tracks.isEmpty - ? [const NotFound(vertical: true)] - : [ - if (heading != null) heading!, - LayoutBuilder(builder: (context, constrains) { - return Row( - children: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: ScaleTransition( - scale: animation, - child: child, - ), - ); - }, - child: showCheck.value - ? Checkbox( - value: selected.value.length == sortedTracks.length, - onChanged: (checked) { - if (!showCheck.value) showCheck.value = true; - if (checked == true) { - selected.value = - sortedTracks.map((s) => s.id!).toList(); - } else { - selected.value = []; - showCheck.value = false; - } - }, - ) - : constrains.mdAndUp - ? const SizedBox(width: 32) - : const SizedBox(width: 16), - ), - Expanded( - flex: 7, - child: Row( - children: [ - Text( - context.l10n.title, - style: tableHeadStyle, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - // used alignment of this table-head - if (constrains.mdAndUp) - Expanded( - flex: 3, - child: Row( - children: [ - Text( - context.l10n.album, - overflow: TextOverflow.ellipsis, - style: tableHeadStyle, - ), - ], - ), - ), - SortTracksDropdown( - value: sortBy, - onChanged: (value) { - ref - .read(trackCollectionSortState(playlistId ?? '') - .notifier) - .state = value; - }, - ), - ExpandableSearchButton( - isFiltering: isFiltering, - searchFocus: searchFocus, - onPressed: (value) { - if (isFiltering.value) { - onFiltering?.call(); - } - }, - ), - AdaptivePopSheetList( - tooltip: context.l10n.more_actions, - headings: [ - Text( - context.l10n.more_actions, - style: tableHeadStyle, - ), - ], - onSelected: (action) async { - switch (action) { - case "download": - { - final confirmed = await showDialog( - context: context, - builder: (context) { - return const ConfirmDownloadDialog(); - }, - ); - if (confirmed != true) return; - await downloader - .batchAddToQueue(selectedTracks.toList()); - if (context.mounted) { - selected.value = []; - showCheck.value = false; - } - break; - } - case "add-to-playlist": - { - await showDialog( - context: context, - builder: (context) { - return PlaylistAddTrackDialog( - tracks: selectedTracks.toList(), - ); - }, - ); - break; - } - case "play-next": - { - playback.addTracksAtFirst(selectedTracks); - if (playlistId != null) { - playback.addCollection(playlistId!); - } - selected.value = []; - showCheck.value = false; - break; - } - case "add-to-queue": - { - playback.addTracks(selectedTracks); - if (playlistId != null) { - playback.addCollection(playlistId!); - } - selected.value = []; - showCheck.value = false; - break; - } - default: - } - }, - icon: const Icon(SpotubeIcons.moreVertical), - children: [ - PopSheetEntry( - value: "download", - leading: const Icon(SpotubeIcons.download), - enabled: selectedTracks.isNotEmpty, - title: Text( - context.l10n.download_count(selectedTracks.length), - ), - ), - if (!userPlaylist) - PopSheetEntry( - value: "add-to-playlist", - leading: const Icon(SpotubeIcons.playlistAdd), - enabled: selectedTracks.isNotEmpty, - title: Text( - context.l10n - .add_count_to_playlist(selectedTracks.length), - ), - ), - PopSheetEntry( - enabled: selectedTracks.isNotEmpty, - value: "add-to-queue", - leading: const Icon(SpotubeIcons.queueAdd), - title: Text( - context.l10n - .add_count_to_queue(selectedTracks.length), - ), - ), - PopSheetEntry( - enabled: selectedTracks.isNotEmpty, - value: "play-next", - leading: const Icon(SpotubeIcons.lightning), - title: Text( - context.l10n.play_count_next(selectedTracks.length), - ), - ), - ], - ), - const SizedBox(width: 10), - ], - ); - }), - ExpandableSearchField( - isFiltering: isFiltering, - searchController: searchController, - searchFocus: searchFocus, - ), - ...sortedTracks.mapIndexed((i, track) { - return TrackTile( - index: i, - track: track, - selected: selected.value.contains(track.id), - userPlaylist: userPlaylist, - playlistId: playlistId, - onTap: () { - if (showCheck.value) { - final alreadyChecked = selected.value.contains(track.id); - if (alreadyChecked) { - selected.value = - selected.value.where((id) => id != track.id).toList(); - } else { - selected.value = [...selected.value, track.id!]; - } - } else { - final isBlackListed = ref.read( - BlackListNotifier.provider.select( - (blacklist) => blacklist.contains( - BlacklistedElement.track(track.id!, track.name!), - ), - ), - ); - if (!isBlackListed) { - onTrackPlayButtonPressed?.call(track); - } - } - }, - onLongPress: () { - if (showCheck.value) return; - showCheck.value = true; - selected.value = [...selected.value, track.id!]; - }, - onChanged: !showCheck.value - ? null - : (value) { - if (value == null) return; - if (value) { - selected.value = [...selected.value, track.id!]; - } else { - selected.value = selected.value - .where((id) => id != track.id) - .toList(); - } - }, - ); - }), - // extra space for mobile devices where keyboard takes half of the screen - if (isFiltering.value) - SizedBox( - height: mediaQuery.size.height * .75, //75% of the screen - ), - ]; - - if (isSliver) { - return SliverSafeArea( - top: false, - sliver: SliverList(delegate: SliverChildListDelegate(children)), - ); - } - return SafeArea( - child: ListView(children: children), - ); - } -} diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart new file mode 100644 index 00000000..419f61a4 --- /dev/null +++ b/lib/components/shared/track_tile/track_options.dart @@ -0,0 +1,442 @@ +import 'dart:io'; + +import 'package:fl_query/fl_query.dart'; +import 'package:flutter/material.dart' hide Page; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/library/user_local_tracks.dart'; +import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; +import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; +import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; +import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; +import 'package:spotube/components/shared/heart_button.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/blacklist_provider.dart'; +import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/services/mutations/mutations.dart'; +import 'package:spotube/services/queries/search.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; + +enum TrackOptionValue { + album, + share, + addToPlaylist, + addToQueue, + removeFromPlaylist, + removeFromQueue, + blacklist, + delete, + playNext, + favorite, + details, + download, + startRadio, +} + +class TrackOptions extends HookConsumerWidget { + final Track track; + final bool userPlaylist; + final String? playlistId; + final ObjectRef?>? showMenuCbRef; + final Widget? icon; + const TrackOptions({ + Key? key, + required this.track, + this.showMenuCbRef, + this.userPlaylist = false, + this.playlistId, + this.icon, + }) : super(key: key); + + void actionShare(BuildContext context, Track track) { + final data = "https://open.spotify.com/track/${track.id}"; + Clipboard.setData(ClipboardData(text: data)).then((_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + width: 300, + behavior: SnackBarBehavior.floating, + content: Text( + context.l10n.copied_to_clipboard(data), + textAlign: TextAlign.center, + ), + ), + ); + }); + } + + void actionAddToPlaylist( + BuildContext context, + Track track, + ) { + showDialog( + context: context, + builder: (context) => PlaylistAddTrackDialog( + tracks: [track], + openFromPlaylist: playlistId, + ), + ); + } + + void actionStartRadio( + BuildContext context, + WidgetRef ref, + Track track, + ) async { + final playback = ref.read(ProxyPlaylistNotifier.notifier); + final playlist = ref.read(ProxyPlaylistNotifier.provider); + final spotify = ref.read(spotifyProvider); + final query = "${track.name} Radio"; + final pages = await QueryClient.of(context) + .fetchInfiniteQueryJob, dynamic, int, SearchParams>( + job: SearchQueries.queryJob(query), + args: ( + spotify: spotify, + searchType: SearchType.playlist, + query: query, + ), + ) ?? + []; + + final radios = pages + .expand((e) => e.items?.toList() ?? []) + .toList() + .cast(); + + final artists = track.artists!.map((e) => e.name); + + final radio = radios.firstWhere( + (e) { + final validPlaylists = + artists.where((a) => e.description!.contains(a!)); + return e.name == "${track.name} Radio" && + (validPlaylists.length >= 2 || + validPlaylists.length == artists.length) && + e.owner?.displayName == "Spotify"; + }, + orElse: () => radios.first, + ); + + bool replaceQueue = false; + + if (context.mounted && playlist.tracks.isNotEmpty) { + replaceQueue = await showPromptDialog( + context: context, + title: context.l10n.how_to_start_radio, + message: context.l10n.replace_queue_question, + okText: context.l10n.replace, + cancelText: context.l10n.add_to_queue, + ); + } + + if (replaceQueue || playlist.tracks.isEmpty) { + await playback.stop(); + await playback.load([track], autoPlay: true); + + // we don't have to add those tracks as useEndlessPlayback will do it for us + return; + } else { + await playback.addTrack(track); + } + + final tracks = + await spotify.playlists.getTracksByPlaylistId(radio.id!).all(); + + await playback.addTracks( + tracks.toList() + ..removeWhere((e) { + final isDuplicate = playlist.tracks.any((t) => t.id == e.id); + return e.id == track.id || isDuplicate; + }), + ); + } + + @override + Widget build(BuildContext context, ref) { + final scaffoldMessenger = ScaffoldMessenger.of(context); + final mediaQuery = MediaQuery.of(context); + final router = GoRouter.of(context); + + final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playback = ref.watch(ProxyPlaylistNotifier.notifier); + final auth = ref.watch(AuthenticationNotifier.provider); + ref.watch(downloadManagerProvider); + final downloadManager = ref.watch(downloadManagerProvider.notifier); + final blacklist = ref.watch(BlackListNotifier.provider); + + final favorites = useTrackToggleLike(track, ref); + + final isBlackListed = useMemoized( + () => blacklist.contains( + BlacklistedElement.track( + track.id!, + track.name!, + ), + ), + [blacklist, track], + ); + + final removingTrack = useState(null); + final removeTrack = useMutations.playlist.removeTrackOf( + ref, + playlistId ?? "", + ); + + final isInQueue = useMemoized(() { + if (playlist.activeTrack == null) return false; + return downloadManager.isActive(playlist.activeTrack!); + }, [ + playlist.activeTrack, + downloadManager, + ]); + + final progressNotifier = useMemoized(() { + final spotubeTrack = downloadManager.mapToSourcedTrack(track); + if (spotubeTrack == null) return null; + return downloadManager.getProgressNotifier(spotubeTrack); + }); + + final adaptivePopSheetList = AdaptivePopSheetList( + onSelected: (value) async { + switch (value) { + case TrackOptionValue.album: + await router.push( + '/album/${track.album!.id}', + extra: track.album!, + ); + break; + case TrackOptionValue.delete: + await File((track as LocalTrack).path).delete(); + ref.refresh(localTracksProvider); + break; + case TrackOptionValue.addToQueue: + await playback.addTrack(track); + if (context.mounted) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.l10n.added_track_to_queue(track.name!), + ), + ), + ); + } + break; + case TrackOptionValue.playNext: + playback.addTracksAtFirst([track]); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.l10n.track_will_play_next(track.name!), + ), + ), + ); + break; + case TrackOptionValue.removeFromQueue: + playback.removeTrack(track.id!); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.l10n.removed_track_from_queue( + track.name!, + ), + ), + ), + ); + break; + case TrackOptionValue.favorite: + favorites.toggleTrackLike.mutate(favorites.isLiked); + break; + case TrackOptionValue.addToPlaylist: + actionAddToPlaylist(context, track); + break; + case TrackOptionValue.removeFromPlaylist: + removingTrack.value = track.uri; + removeTrack.mutate(track.uri!); + break; + case TrackOptionValue.blacklist: + if (isBlackListed) { + ref.read(BlackListNotifier.provider.notifier).remove( + BlacklistedElement.track(track.id!, track.name!), + ); + } else { + ref.read(BlackListNotifier.provider.notifier).add( + BlacklistedElement.track(track.id!, track.name!), + ); + } + break; + case TrackOptionValue.share: + actionShare(context, track); + break; + case TrackOptionValue.details: + showDialog( + context: context, + builder: (context) => TrackDetailsDialog(track: track), + ); + break; + case TrackOptionValue.download: + await downloadManager.addToQueue(track); + break; + case TrackOptionValue.startRadio: + actionStartRadio(context, ref, track); + break; + } + }, + icon: icon ?? const Icon(SpotubeIcons.moreHorizontal), + headings: [ + ListTile( + dense: true, + leading: AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: UniversalImage( + path: TypeConversionUtils.image_X_UrlString(track.album!.images, + placeholder: ImagePlaceholder.albumArt), + fit: BoxFit.cover, + ), + ), + ), + title: Text( + track.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + subtitle: Align( + alignment: Alignment.centerLeft, + child: TypeConversionUtils.artists_X_ClickableArtists( + track.artists!, + ), + ), + ), + ], + children: switch (track.runtimeType) { + LocalTrack => [ + PopSheetEntry( + value: TrackOptionValue.delete, + leading: const Icon(SpotubeIcons.trash), + title: Text(context.l10n.delete), + ) + ], + _ => [ + if (mediaQuery.smAndDown) + PopSheetEntry( + value: TrackOptionValue.album, + leading: const Icon(SpotubeIcons.album), + title: Text(context.l10n.go_to_album), + subtitle: Text(track.album!.name!), + ), + if (!playlist.containsTrack(track)) ...[ + PopSheetEntry( + value: TrackOptionValue.addToQueue, + leading: const Icon(SpotubeIcons.queueAdd), + title: Text(context.l10n.add_to_queue), + ), + PopSheetEntry( + value: TrackOptionValue.playNext, + leading: const Icon(SpotubeIcons.lightning), + title: Text(context.l10n.play_next), + ), + ] else + PopSheetEntry( + value: TrackOptionValue.removeFromQueue, + enabled: playlist.activeTrack?.id != track.id, + leading: const Icon(SpotubeIcons.queueRemove), + title: Text(context.l10n.remove_from_queue), + ), + if (favorites.me.hasData) + PopSheetEntry( + value: TrackOptionValue.favorite, + leading: favorites.isLiked + ? const Icon( + SpotubeIcons.heartFilled, + color: Colors.pink, + ) + : const Icon(SpotubeIcons.heart), + title: Text( + favorites.isLiked + ? context.l10n.remove_from_favorites + : context.l10n.save_as_favorite, + ), + ), + if (auth != null) ...[ + PopSheetEntry( + value: TrackOptionValue.startRadio, + leading: const Icon(SpotubeIcons.radio), + title: Text(context.l10n.start_a_radio), + ), + PopSheetEntry( + value: TrackOptionValue.addToPlaylist, + leading: const Icon(SpotubeIcons.playlistAdd), + title: Text(context.l10n.add_to_playlist), + ), + ], + if (userPlaylist && auth != null) + PopSheetEntry( + value: TrackOptionValue.removeFromPlaylist, + leading: (removeTrack.isMutating || !removeTrack.hasData) && + removingTrack.value == track.uri + ? const CircularProgressIndicator() + : const Icon(SpotubeIcons.removeFilled), + title: Text(context.l10n.remove_from_playlist), + ), + PopSheetEntry( + value: TrackOptionValue.download, + enabled: !isInQueue, + leading: isInQueue + ? HookBuilder(builder: (context) { + final progress = useListenable(progressNotifier!); + return CircularProgressIndicator( + value: progress.value, + ); + }) + : const Icon(SpotubeIcons.download), + title: Text(context.l10n.download_track), + ), + PopSheetEntry( + value: TrackOptionValue.blacklist, + leading: const Icon(SpotubeIcons.playlistRemove), + iconColor: !isBlackListed ? Colors.red[400] : null, + textColor: !isBlackListed ? Colors.red[400] : null, + title: Text( + isBlackListed + ? context.l10n.remove_from_blacklist + : context.l10n.add_to_blacklist, + ), + ), + PopSheetEntry( + value: TrackOptionValue.share, + leading: const Icon(SpotubeIcons.share), + title: Text(context.l10n.share), + ), + PopSheetEntry( + value: TrackOptionValue.details, + leading: const Icon(SpotubeIcons.info), + title: Text(context.l10n.details), + ), + ] + }, + ); + + //! This is the most ANTI pattern I've ever done, but it works + showMenuCbRef?.value = (relativeRect) { + adaptivePopSheetList.showPopupMenu(context, relativeRect); + }; + + return ListTileTheme( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + child: adaptivePopSheetList, + ); + } +} diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart new file mode 100644 index 00000000..d268c783 --- /dev/null +++ b/lib/components/shared/track_tile/track_tile.dart @@ -0,0 +1,270 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/hover_builder.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/link_text.dart'; +import 'package:spotube/components/shared/track_tile/track_options.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/duration.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/blacklist_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; + +class TrackTile extends HookConsumerWidget { + /// [index] will not be shown if null + final int? index; + final Track track; + final bool selected; + final ValueChanged? onChanged; + final Future Function()? onTap; + final VoidCallback? onLongPress; + final bool userPlaylist; + final String? playlistId; + + final List? leadingActions; + + const TrackTile({ + Key? key, + this.index, + required this.track, + this.selected = false, + this.onTap, + this.onLongPress, + this.onChanged, + this.userPlaylist = false, + this.playlistId, + this.leadingActions, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final theme = Theme.of(context); + + final blacklist = ref.watch(BlackListNotifier.provider); + + final isBlackListed = useMemoized( + () => blacklist.contains( + BlacklistedElement.track( + track.id!, + track.name!, + ), + ), + [blacklist, track], + ); + + final showOptionCbRef = useRef?>(null); + + final isPlaying = track.id == playlist.activeTrack?.id; + + final isLoading = useState(false); + + final isSelected = isPlaying || isLoading.value; + + return LayoutBuilder(builder: (context, constrains) { + return Listener( + onPointerDown: (event) { + if (event.buttons != kSecondaryMouseButton) return; + showOptionCbRef.value?.call( + RelativeRect.fromLTRB( + event.position.dx, + event.position.dy, + constrains.maxWidth - event.position.dx, + constrains.maxHeight - event.position.dy, + ), + ); + }, + child: HoverBuilder( + permanentState: isSelected || constrains.smAndDown ? true : null, + builder: (context, isHovering) { + return ListTile( + selected: isSelected, + onTap: () async { + try { + isLoading.value = true; + await onTap?.call(); + } finally { + if (context.mounted) { + isLoading.value = false; + } + } + }, + onLongPress: onLongPress, + enabled: !isBlackListed, + contentPadding: EdgeInsets.zero, + tileColor: + isBlackListed ? theme.colorScheme.errorContainer : null, + horizontalTitleGap: 12, + leadingAndTrailingTextStyle: theme.textTheme.bodyMedium, + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...?leadingActions, + if (index != null && onChanged == null && constrains.mdAndUp) + SizedBox( + width: 50, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + '${(index ?? 0) + 1}', + maxLines: 1, + style: theme.textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ), + ) + else if (constrains.smAndDown) + const SizedBox(width: 16), + if (onChanged != null) + Checkbox( + value: selected, + onChanged: onChanged, + ), + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: AspectRatio( + aspectRatio: 1, + child: UniversalImage( + path: TypeConversionUtils.image_X_UrlString( + track.album?.images, + placeholder: ImagePlaceholder.albumArt, + ), + fit: BoxFit.cover, + ), + ), + ), + Positioned.fill( + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: isHovering + ? Colors.black.withOpacity(0.4) + : Colors.transparent, + ), + ), + ), + Positioned.fill( + child: Center( + child: IconTheme( + data: theme.iconTheme + .copyWith(size: 26, color: Colors.white), + child: Skeleton.ignore( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: (isPlaying && playlist.isFetching) || + isLoading.value + ? const SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: Colors.white, + ), + ) + : isPlaying + ? Icon( + SpotubeIcons.pause, + color: theme.colorScheme.primary, + ) + : !isHovering + ? const SizedBox.shrink() + : const Icon(SpotubeIcons.play), + ), + ), + ), + ), + ), + ], + ), + ], + ), + title: Row( + children: [ + Expanded( + flex: 6, + child: LinkText( + track.name!, + "/track/${track.id}", + push: true, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (constrains.mdAndUp) ...[ + const SizedBox(width: 8), + Expanded( + flex: 4, + child: switch (track.runtimeType) { + LocalTrack => Text( + track.album!.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + _ => Align( + alignment: Alignment.centerLeft, + child: LinkText( + track.album!.name!, + "/album/${track.album?.id}", + extra: track.album, + push: true, + overflow: TextOverflow.ellipsis, + ), + ) + }, + ), + ], + ], + ), + subtitle: Align( + alignment: Alignment.centerLeft, + child: track is LocalTrack + ? Text( + TypeConversionUtils.artists_X_String( + track.artists ?? [], + ), + ) + : ClipRect( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 40), + child: TypeConversionUtils.artists_X_ClickableArtists( + track.artists ?? [], + ), + ), + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 8), + Text( + Duration(milliseconds: track.durationMs ?? 0) + .toHumanReadableString(padZero: false), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + TrackOptions( + track: track, + playlistId: playlistId, + userPlaylist: userPlaylist, + showMenuCbRef: showOptionCbRef, + ), + ], + ), + ); + }, + ), + ); + }); + } +} diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body.dart b/lib/components/shared/tracks_view/sections/body/track_view_body.dart new file mode 100644 index 00000000..33c8fa82 --- /dev/null +++ b/lib/components/shared/tracks_view/sections/body/track_view_body.dart @@ -0,0 +1,147 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/fake.dart'; +import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; +import 'package:spotube/components/shared/track_tile/track_tile.dart'; +import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart'; +import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/utils/service_utils.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; + +class TrackViewBodySection extends HookConsumerWidget { + const TrackViewBodySection({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final props = InheritedTrackView.of(context); + final trackViewState = ref.watch(trackViewProvider(props.tracks)); + + final searchController = useTextEditingController(); + final searchFocus = useFocusNode(); + + useValueListenable(searchController); + final searchQuery = searchController.text; + + final isFiltering = useState(false); + + final uniqTracks = useMemoized(() { + final trackIds = props.tracks.map((e) => e.id).toSet(); + return props.tracks.where((e) => trackIds.remove(e.id)).toList(); + }, [props.tracks]); + + final tracks = useMemoized(() { + List filteredTracks; + if (searchQuery.isEmpty) { + filteredTracks = uniqTracks; + } else { + filteredTracks = uniqTracks + .map((e) => (weightedRatio(e.name!, searchQuery), e)) + .sorted((a, b) => b.$1.compareTo(a.$1)) + .where((e) => e.$1 > 50) + .map((e) => e.$2) + .toList(); + } + return ServiceUtils.sortTracks(filteredTracks, trackViewState.sortBy); + }, [trackViewState.sortBy, searchQuery, uniqTracks]); + + final isUserPlaylist = useIsUserPlaylist(ref, props.collectionId); + + final isActive = playlist.collections.contains(props.collectionId); + + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: TrackViewBodyHeaders( + isFiltering: isFiltering, + searchFocus: searchFocus, + ), + ), + const SliverGap(8), + SliverToBoxAdapter( + child: ExpandableSearchField( + isFiltering: isFiltering.value, + onChangeFiltering: (value) { + isFiltering.value = value; + }, + searchController: searchController, + searchFocus: searchFocus, + ), + ), + SliverSafeArea( + top: false, + sliver: SliverInfiniteList( + itemCount: tracks.length, + onFetchData: props.pagination.onFetchMore, + isLoading: props.pagination.isLoading, + hasReachedMax: !props.pagination.hasNextPage, + loadingBuilder: (context) => Skeletonizer( + enabled: true, + child: TrackTile( + track: FakeData.track, + index: 0, + ), + ), + emptyBuilder: (context) => Skeletonizer( + enabled: true, + child: Column( + children: List.generate( + 10, + (index) => TrackTile(track: FakeData.track, index: index), + ), + ), + ), + itemBuilder: (context, index) { + final track = tracks[index]; + return TrackTile( + track: track, + index: index, + selected: trackViewState.selectedTrackIds.contains(track.id!), + playlistId: props.collectionId, + userPlaylist: isUserPlaylist, + onChanged: !trackViewState.isSelecting + ? null + : (value) { + trackViewState.toggleTrackSelection(track.id!); + }, + onLongPress: () { + trackViewState.selectTrack(track.id!); + HapticFeedback.selectionClick(); + }, + onTap: () async { + if (trackViewState.isSelecting) { + trackViewState.toggleTrackSelection(track.id!); + return; + } + + if (isActive || playlist.tracks.contains(track)) { + await playlistNotifier.jumpToTrack(track); + } else { + final tracks = await props.pagination.onFetchAll(); + await playlistNotifier.load( + tracks, + initialIndex: index, + autoPlay: true, + ); + playlistNotifier.addCollection(props.collectionId); + } + }, + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart b/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart new file mode 100644 index 00000000..7e4522a0 --- /dev/null +++ b/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; +import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; +import 'package:spotube/components/shared/tracks_view/sections/body/track_view_options.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; + +class TrackViewBodyHeaders extends HookConsumerWidget { + final ValueNotifier isFiltering; + final FocusNode searchFocus; + + const TrackViewBodyHeaders({ + Key? key, + required this.isFiltering, + required this.searchFocus, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:textTheme) = Theme.of(context); + final props = InheritedTrackView.of(context); + final trackViewState = ref.watch(trackViewProvider(props.tracks)); + return LayoutBuilder( + builder: (context, constrains) { + return Row( + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: animation, + child: child, + ), + ); + }, + child: Checkbox( + value: trackViewState.hasSelectedAll, + onChanged: (checked) { + if (checked == true) { + trackViewState.selectAll(); + } else { + trackViewState.deselectAll(); + } + }, + ), + ), + Expanded( + flex: 7, + child: Row( + children: [ + Text( + context.l10n.title, + style: textTheme.bodyLarge, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // used alignment of this table-head + if (constrains.mdAndUp) + Expanded( + flex: 3, + child: Row( + children: [ + Text( + context.l10n.album, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyLarge, + ), + ], + ), + ), + SortTracksDropdown( + value: trackViewState.sortBy, + onChanged: (value) { + trackViewState.sort(value); + }, + ), + ExpandableSearchButton( + isFiltering: isFiltering.value, + searchFocus: searchFocus, + onPressed: (value) { + isFiltering.value = value; + if (value) { + searchFocus.requestFocus(); + } else { + searchFocus.unfocus(); + } + }, + ), + const TrackViewBodyOptions(), + ], + ); + }, + ); + } +} diff --git a/lib/components/shared/tracks_view/sections/body/track_view_options.dart b/lib/components/shared/tracks_view/sections/body/track_view_options.dart new file mode 100644 index 00000000..583c9107 --- /dev/null +++ b/lib/components/shared/tracks_view/sections/body/track_view_options.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; +import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart'; +import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + +class TrackViewBodyOptions extends HookConsumerWidget { + const TrackViewBodyOptions({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final props = InheritedTrackView.of(context); + final ThemeData(:textTheme) = Theme.of(context); + + ref.watch(downloadManagerProvider); + final downloader = ref.watch(downloadManagerProvider.notifier); + final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final audioSource = + ref.watch(userPreferencesProvider.select((s) => s.audioSource)); + + final trackViewState = ref.watch(trackViewProvider(props.tracks)); + final selectedTracks = trackViewState.selectedTracks; + + return AdaptivePopSheetList( + tooltip: context.l10n.more_actions, + headings: [ + Text( + context.l10n.more_actions, + style: textTheme.bodyLarge, + ), + ], + onSelected: (action) async { + switch (action) { + case "download": + { + final confirmed = audioSource == AudioSource.piped || + await showDialog( + context: context, + builder: (context) { + return const ConfirmDownloadDialog(); + }, + ); + if (confirmed != true) return; + await downloader.batchAddToQueue(selectedTracks); + trackViewState.deselectAll(); + break; + } + case "add-to-playlist": + { + if (context.mounted) { + await showDialog( + context: context, + builder: (context) { + return PlaylistAddTrackDialog( + openFromPlaylist: props.collectionId, + tracks: selectedTracks.toList(), + ); + }, + ); + } + break; + } + case "play-next": + { + playlistNotifier.addTracksAtFirst(selectedTracks); + playlistNotifier.addCollection(props.collectionId); + trackViewState.deselectAll(); + break; + } + case "add-to-queue": + { + playlistNotifier.addTracks(selectedTracks); + playlistNotifier.addCollection(props.collectionId); + trackViewState.deselectAll(); + break; + } + default: + } + }, + icon: const Icon(SpotubeIcons.moreVertical), + children: [ + PopSheetEntry( + value: "download", + leading: const Icon(SpotubeIcons.download), + enabled: selectedTracks.isNotEmpty, + title: Text( + context.l10n.download_count(selectedTracks.length), + ), + ), + PopSheetEntry( + value: "add-to-playlist", + leading: const Icon(SpotubeIcons.playlistAdd), + enabled: selectedTracks.isNotEmpty, + title: Text( + context.l10n.add_count_to_playlist(selectedTracks.length), + ), + ), + PopSheetEntry( + enabled: selectedTracks.isNotEmpty, + value: "add-to-queue", + leading: const Icon(SpotubeIcons.queueAdd), + title: Text( + context.l10n.add_count_to_queue(selectedTracks.length), + ), + ), + PopSheetEntry( + enabled: selectedTracks.isNotEmpty, + value: "play-next", + leading: const Icon(SpotubeIcons.lightning), + title: Text( + context.l10n.play_count_next(selectedTracks.length), + ), + ), + ], + ); + } +} diff --git a/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart b/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart new file mode 100644 index 00000000..ca3c6706 --- /dev/null +++ b/lib/components/shared/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/services/queries/queries.dart'; + +bool useIsUserPlaylist(WidgetRef ref, String playlistId) { + final userPlaylistsQuery = useQueries.playlist.ofMineAll(ref); + final me = useQueries.user.me(ref); + + return useMemoized( + () => + userPlaylistsQuery.data?.any((e) => + e.id == playlistId && + me.data != null && + e.owner?.id == me.data?.id) ?? + false, + [userPlaylistsQuery.data, playlistId, me.data], + ); +} diff --git a/lib/components/shared/tracks_view/sections/header/flexible_header.dart b/lib/components/shared/tracks_view/sections/header/flexible_header.dart new file mode 100644 index 00000000..19241dc6 --- /dev/null +++ b/lib/components/shared/tracks_view/sections/header/flexible_header.dart @@ -0,0 +1,166 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/playbutton_card.dart'; +import 'package:spotube/components/shared/tracks_view/sections/header/header_actions.dart'; +import 'package:spotube/components/shared/tracks_view/sections/header/header_buttons.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:gap/gap.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/hooks/utils/use_palette_color.dart'; + +class TrackViewFlexHeader extends HookConsumerWidget { + const TrackViewFlexHeader({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final props = InheritedTrackView.of(context); + final ThemeData(:colorScheme, :textTheme, :iconTheme) = Theme.of(context); + final defaultTextStyle = DefaultTextStyle.of(context); + final mediaQuery = MediaQuery.of(context); + + final description = useDescription(props.description); + + final palette = usePaletteColor(props.image, ref); + + return IconTheme( + data: iconTheme.copyWith(color: palette.bodyTextColor), + child: SliverLayoutBuilder( + builder: (context, constrains) { + final isExpanded = constrains.scrollOffset < 350; + + final headingStyle = (mediaQuery.mdAndDown + ? textTheme.headlineSmall + : textTheme.headlineMedium) + ?.copyWith( + color: palette.bodyTextColor, + ); + return SliverAppBar( + iconTheme: iconTheme.copyWith( + color: palette.bodyTextColor, + size: 16, + ), + actions: isExpanded + ? [] + : [ + const TrackViewHeaderActions(), + TrackViewHeaderButtons(compact: true, color: palette), + ], + floating: false, + pinned: true, + expandedHeight: 450, + automaticallyImplyLeading: DesktopTools.platform.isMobile, + backgroundColor: palette.color, + title: isExpanded ? null : Text(props.title, style: headingStyle), + flexibleSpace: FlexibleSpaceBar( + background: Container( + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + image: DecorationImage( + image: UniversalImage.imageProvider(props.image), + fit: BoxFit.cover, + ), + ), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.black45, + colorScheme.surface, + ], + begin: const FractionalOffset(0, 0), + end: const FractionalOffset(0, 1), + tileMode: TileMode.clamp, + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: mediaQuery.mdAndDown + ? mediaQuery.size.width + : 800, + ), + child: Flex( + direction: mediaQuery.mdAndDown + ? Axis.vertical + : Axis.horizontal, + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: UniversalImage( + path: props.image, + width: 200, + height: 200, + placeholder: Assets.albumPlaceholder.path, + ), + ), + const Gap(20), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: mediaQuery.mdAndDown + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + Text( + props.title, + style: headingStyle, + textAlign: mediaQuery.mdAndDown + ? TextAlign.center + : TextAlign.start, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 10), + if (description != null && + description.isNotEmpty) + Text( + description, + style: + defaultTextStyle.style.copyWith( + color: palette.bodyTextColor, + ), + textAlign: mediaQuery.mdAndDown + ? TextAlign.center + : TextAlign.start, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const Gap(10), + const TrackViewHeaderActions(), + const Gap(10), + TrackViewHeaderButtons(color: palette), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/components/shared/tracks_view/sections/header/header_actions.dart b/lib/components/shared/tracks_view/sections/header/header_actions.dart new file mode 100644 index 00000000..b050c199 --- /dev/null +++ b/lib/components/shared/tracks_view/sections/header/header_actions.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/playlist/playlist_create_dialog.dart'; +import 'package:spotube/components/shared/heart_button.dart'; +import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; + +class TrackViewHeaderActions extends HookConsumerWidget { + const TrackViewHeaderActions({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final props = InheritedTrackView.of(context); + + final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + + final isActive = playlist.collections.contains(props.collectionId); + + final isUserPlaylist = useIsUserPlaylist(ref, props.collectionId); + + final scaffoldMessenger = ScaffoldMessenger.of(context); + + final auth = ref.watch(AuthenticationNotifier.provider); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: context.l10n.share, + icon: const Icon(SpotubeIcons.share), + onPressed: () async { + await Clipboard.setData( + ClipboardData(text: props.shareUrl), + ); + + scaffoldMessenger.showSnackBar( + SnackBar( + width: 300, + behavior: SnackBarBehavior.floating, + content: Text( + "Copied ${props.shareUrl} to clipboard", + textAlign: TextAlign.center, + ), + ), + ); + }, + ), + IconButton( + icon: const Icon(SpotubeIcons.queueAdd), + tooltip: context.l10n.add_to_queue, + onPressed: isActive || props.tracks.isEmpty + ? null + : () async { + final tracks = await props.pagination.onFetchAll(); + await playlistNotifier.addTracks(tracks); + playlistNotifier.addCollection(props.collectionId); + }, + ), + if (props.onHeart != null && auth != null) + HeartButton( + isLiked: props.isLiked, + icon: isUserPlaylist ? SpotubeIcons.trash : null, + tooltip: props.isLiked + ? context.l10n.remove_from_favorites + : context.l10n.save_as_favorite, + onPressed: () { + props.onHeart?.call(); + if (isUserPlaylist) { + context.pop(); + } + }, + ), + if (isUserPlaylist) + IconButton( + icon: const Icon(SpotubeIcons.edit), + onPressed: () { + showDialog( + context: context, + builder: (context) { + return PlaylistCreateDialog( + playlistId: props.collectionId, + trackIds: props.tracks.map((e) => e.id!).toList(), + ); + }, + ); + }, + ), + ], + ); + } +} diff --git a/lib/components/shared/tracks_view/sections/header/header_buttons.dart b/lib/components/shared/tracks_view/sections/header/header_buttons.dart new file mode 100644 index 00000000..bae47f12 --- /dev/null +++ b/lib/components/shared/tracks_view/sections/header/header_buttons.dart @@ -0,0 +1,137 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:palette_generator/palette_generator.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; + +class TrackViewHeaderButtons extends HookConsumerWidget { + final PaletteColor color; + final bool compact; + const TrackViewHeaderButtons({ + Key? key, + required this.color, + this.compact = false, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final props = InheritedTrackView.of(context); + final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + + final isActive = playlist.collections.contains(props.collectionId); + + final isLoading = useState(false); + + const progressIndicator = Center( + child: SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: .8), + ), + ); + + void onShuffle() async { + try { + isLoading.value = true; + + final allTracks = await props.pagination.onFetchAll(); + + await playlistNotifier.load( + allTracks, + autoPlay: true, + initialIndex: Random().nextInt(allTracks.length), + ); + await audioPlayer.setShuffle(true); + playlistNotifier.addCollection(props.collectionId); + } finally { + isLoading.value = false; + } + } + + void onPlay() async { + try { + isLoading.value = true; + + final allTracks = await props.pagination.onFetchAll(); + + await playlistNotifier.load(allTracks, autoPlay: true); + playlistNotifier.addCollection(props.collectionId); + } finally { + isLoading.value = false; + } + } + + if (compact) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isActive && !isLoading.value) + IconButton( + icon: const Icon(SpotubeIcons.shuffle), + onPressed: props.tracks.isEmpty ? null : onShuffle, + ), + const Gap(10), + IconButton.filledTonal( + icon: isActive + ? const Icon(SpotubeIcons.pause) + : isLoading.value + ? progressIndicator + : const Icon(SpotubeIcons.play), + onPressed: isActive || props.tracks.isEmpty || isLoading.value + ? null + : onPlay, + ), + const Gap(10), + ], + ); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: isActive || isLoading.value ? 0 : 1, + child: AnimatedSize( + duration: const Duration(milliseconds: 300), + child: SizedBox.square( + dimension: isActive || isLoading.value ? 0 : null, + child: FilledButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + minimumSize: const Size(150, 40)), + label: Text(context.l10n.shuffle), + icon: const Icon(SpotubeIcons.shuffle), + onPressed: props.tracks.isEmpty ? null : onShuffle, + ), + ), + ), + ), + const Gap(10), + FilledButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: color.color, + foregroundColor: color.bodyTextColor, + minimumSize: const Size(150, 40)), + onPressed: isActive || props.tracks.isEmpty || isLoading.value + ? null + : onPlay, + icon: isActive + ? const Icon(SpotubeIcons.pause) + : isLoading.value + ? progressIndicator + : const Icon(SpotubeIcons.play), + label: Text(context.l10n.play), + ), + ], + ); + } +} diff --git a/lib/components/shared/tracks_view/track_view.dart b/lib/components/shared/tracks_view/track_view.dart new file mode 100644 index 00000000..a1a2d48b --- /dev/null +++ b/lib/components/shared/tracks_view/track_view.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sliver_tools/sliver_tools.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/shared/tracks_view/sections/header/flexible_header.dart'; +import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; + +class TrackView extends HookConsumerWidget { + const TrackView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final props = InheritedTrackView.of(context); + + return Scaffold( + appBar: DesktopTools.platform.isDesktop + ? const PageWindowTitleBar( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + leadingWidth: 400, + leading: Align( + alignment: Alignment.centerLeft, + child: BackButton(color: Colors.white), + ), + ) + : null, + extendBodyBehindAppBar: true, + body: RefreshIndicator( + onRefresh: props.pagination.onRefresh, + child: const CustomScrollView( + slivers: [ + TrackViewFlexHeader(), + SliverAnimatedSwitcher( + duration: Duration(milliseconds: 500), + child: TrackViewBodySection(), + ), + ], + ), + ), + ); + } +} diff --git a/lib/components/shared/tracks_view/track_view_props.dart b/lib/components/shared/tracks_view/track_view_props.dart new file mode 100644 index 00000000..1c6c7647 --- /dev/null +++ b/lib/components/shared/tracks_view/track_view_props.dart @@ -0,0 +1,107 @@ +import 'package:fl_query/fl_query.dart'; +import 'package:flutter/material.dart' hide Page; +import 'package:spotify/spotify.dart'; + +class PaginationProps { + final bool hasNextPage; + final bool isLoading; + final VoidCallback onFetchMore; + final Future Function() onRefresh; + final Future> Function() onFetchAll; + + const PaginationProps({ + required this.hasNextPage, + required this.isLoading, + required this.onFetchMore, + required this.onFetchAll, + required this.onRefresh, + }); + + factory PaginationProps.fromQuery( + InfiniteQuery, dynamic, int> query, { + required Future> Function() onFetchAll, + }) { + return PaginationProps( + hasNextPage: query.hasNextPage, + isLoading: query.isLoadingNextPage, + onFetchMore: query.fetchNext, + onFetchAll: onFetchAll, + onRefresh: query.refreshAll, + ); + } + + @override + operator ==(Object other) { + return other is PaginationProps && + other.hasNextPage == hasNextPage && + other.isLoading == isLoading && + other.onFetchMore == onFetchMore && + other.onFetchAll == onFetchAll && + other.onRefresh == onRefresh; + } + + @override + int get hashCode => + super.hashCode ^ + hasNextPage.hashCode ^ + isLoading.hashCode ^ + onFetchMore.hashCode ^ + onFetchAll.hashCode ^ + onRefresh.hashCode; +} + +class InheritedTrackView extends InheritedWidget { + final String collectionId; + final String title; + final String? description; + final String image; + final String routePath; + final List tracks; + final PaginationProps pagination; + final bool isLiked; + final String shareUrl; + + // events + final VoidCallback? onHeart; // if null heart button will hidden + + const InheritedTrackView({ + super.key, + required super.child, + required this.collectionId, + required this.title, + this.description, + required this.image, + required this.tracks, + required this.pagination, + required this.routePath, + required this.shareUrl, + this.isLiked = false, + this.onHeart, + }); + + @override + bool updateShouldNotify(InheritedTrackView oldWidget) { + return oldWidget.title != title || + oldWidget.description != description || + oldWidget.image != image || + oldWidget.tracks != tracks || + oldWidget.pagination != pagination || + oldWidget.isLiked != isLiked || + oldWidget.onHeart != onHeart || + oldWidget.shareUrl != shareUrl || + oldWidget.routePath != routePath || + oldWidget.collectionId != collectionId || + oldWidget.child != child; + } + + static InheritedTrackView of(BuildContext context) { + final widget = + context.dependOnInheritedWidgetOfExactType(); + if (widget == null) { + throw Exception( + 'InheritedTrackView not found. Make sure to wrap [TrackView] with [InheritedTrackView]', + ); + } + return widget; + } +} diff --git a/lib/components/shared/tracks_view/track_view_provider.dart b/lib/components/shared/tracks_view/track_view_provider.dart new file mode 100644 index 00000000..14dc1136 --- /dev/null +++ b/lib/components/shared/tracks_view/track_view_provider.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/library/user_local_tracks.dart'; + +class TrackViewNotifier extends ChangeNotifier { + List tracks; + List selectedTrackIds; + SortBy sortBy; + String? searchQuery; + + TrackViewNotifier( + this.tracks, { + this.selectedTrackIds = const [], + this.sortBy = SortBy.none, + this.searchQuery, + }); + + bool get isSelecting => selectedTrackIds.isNotEmpty; + + bool get hasSelectedAll => + selectedTrackIds.length == tracks.length && tracks.isNotEmpty; + + List get selectedTracks => + tracks.where((e) => selectedTrackIds.contains(e.id)).toList(); + + void selectTrack(String trackId) { + selectedTrackIds = [...selectedTrackIds, trackId]; + notifyListeners(); + } + + void unselectTrack(String trackId) { + selectedTrackIds = selectedTrackIds.where((e) => e != trackId).toList(); + notifyListeners(); + } + + void toggleTrackSelection(String trackId) { + if (selectedTrackIds.contains(trackId)) { + unselectTrack(trackId); + } else { + selectTrack(trackId); + } + } + + void selectAll() { + selectedTrackIds = tracks.map((e) => e.id!).toList(); + notifyListeners(); + } + + void deselectAll() { + selectedTrackIds = []; + notifyListeners(); + } + + void sort(SortBy sortBy) { + this.sortBy = sortBy; + notifyListeners(); + } +} + +final trackViewProvider = ChangeNotifierProvider.autoDispose + .family>((ref, tracks) { + return TrackViewNotifier(tracks); +}); diff --git a/lib/extensions/album_simple.dart b/lib/extensions/album_simple.dart index a717bf88..00db4dca 100644 --- a/lib/extensions/album_simple.dart +++ b/lib/extensions/album_simple.dart @@ -3,7 +3,7 @@ import 'package:spotify/spotify.dart'; extension AlbumJson on AlbumSimple { Map toJson() { return { - "albumType": albumType, + "albumType": albumType?.name, "id": id, "name": name, "images": images diff --git a/lib/extensions/color.dart b/lib/extensions/color.dart new file mode 100644 index 00000000..68cd8ef7 --- /dev/null +++ b/lib/extensions/color.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +extension ColorAlterer on Color { + Color darken(double amount) { + assert(amount >= 0 && amount <= 1); + final hsl = HSLColor.fromColor(this); + final hslDark = hsl.withLightness((hsl.lightness - amount).clamp(0.0, 1.0)); + return hslDark.toColor(); + } + + Color lighten(double amount) { + assert(amount >= 0 && amount <= 1); + final hsl = HSLColor.fromColor(this); + final hslLight = + hsl.withLightness((hsl.lightness + amount).clamp(0.0, 1.0)); + return hslLight.toColor(); + } + + bool isLight() { + final luminance = computeLuminance(); + return luminance > 0.5; + } + + bool isDark() { + final luminance = computeLuminance(); + return luminance <= 0.5; + } +} diff --git a/lib/extensions/constrains.dart b/lib/extensions/constrains.dart index 85c84ca9..1177f5ac 100644 --- a/lib/extensions/constrains.dart +++ b/lib/extensions/constrains.dart @@ -1,3 +1,4 @@ +import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; // ignore: constant_identifier_names @@ -9,6 +10,29 @@ const Breakpoints = ( xl: 1280.0, ); +extension SliverBreakpoints on SliverConstraints { + bool get isXs => crossAxisExtent <= Breakpoints.xs; + bool get isSm => + crossAxisExtent > Breakpoints.xs && crossAxisExtent <= Breakpoints.sm; + bool get isMd => + crossAxisExtent > Breakpoints.sm && crossAxisExtent <= Breakpoints.md; + bool get isLg => + crossAxisExtent > Breakpoints.md && crossAxisExtent <= Breakpoints.lg; + bool get isXl => + crossAxisExtent > Breakpoints.lg && crossAxisExtent <= Breakpoints.xl; + bool get is2Xl => crossAxisExtent > Breakpoints.xl; + + bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl; + bool get mdAndUp => isMd || isLg || isXl || is2Xl; + bool get lgAndUp => isLg || isXl || is2Xl; + bool get xlAndUp => isXl || is2Xl; + + bool get smAndDown => isXs || isSm; + bool get mdAndDown => isXs || isSm || isMd; + bool get lgAndDown => isXs || isSm || isMd || isLg; + bool get xlAndDown => isXs || isSm || isMd || isLg || isXl; +} + extension ContainerBreakpoints on BoxConstraints { bool get isXs => biggest.width <= Breakpoints.xs; bool get isSm => diff --git a/lib/extensions/duration.dart b/lib/extensions/duration.dart index 183fce5f..ff670b1a 100644 --- a/lib/extensions/duration.dart +++ b/lib/extensions/duration.dart @@ -1,10 +1,21 @@ import 'package:duration/locale.dart'; -import 'package:spotube/utils/primitive_utils.dart'; import 'package:duration/duration.dart'; extension DurationToHumanReadableString on Duration { - String toHumanReadableString() => - "${inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(inSeconds.remainder(60))}"; + String toHumanReadableString({padZero = true}) { + final mm = inMinutes + .remainder(60) + .toString() + .padLeft(2, !padZero && inHours == 0 ? '' : "0"); + final ss = inSeconds.remainder(60).toString().padLeft(2, "0"); + + if (inHours > 0) { + final hh = inHours.toString().padLeft(2, !padZero ? '' : "0"); + return "$hh:$mm:$ss"; + } + + return "$mm:$ss"; + } String format({ DurationTersity tersity = DurationTersity.second, @@ -26,3 +37,13 @@ extension DurationToHumanReadableString on Duration { abbreviated: abbreviated, ); } + +extension ParseDuration on Duration { + static Duration fromString(String duration) { + final parts = duration.split(':').reversed.toList(); + final seconds = int.parse(parts[0]); + final minutes = parts.length > 1 ? int.parse(parts[1]) : 0; + final hours = parts.length > 2 ? int.parse(parts[2]) : 0; + return Duration(hours: hours, minutes: minutes, seconds: seconds); + } +} diff --git a/lib/extensions/infinite_query.dart b/lib/extensions/infinite_query.dart new file mode 100644 index 00000000..2181ab3c --- /dev/null +++ b/lib/extensions/infinite_query.dart @@ -0,0 +1,34 @@ +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 c9d502b0..6ecf6cf6 100644 --- a/lib/extensions/list.dart +++ b/lib/extensions/list.dart @@ -1,4 +1,7 @@ import 'package:collection/collection.dart'; +import 'package:spotube/models/logger.dart'; + +final logger = getLogger("List"); extension MultiSortListMap on List { /// [preference] - List of properties in which you want to sort the list @@ -18,7 +21,7 @@ extension MultiSortListMap on List { return this; } if (preference.length != criteria.length) { - print('Criteria length is not equal to preference'); + logger.d('Criteria length is not equal to preference'); return this; } @@ -66,7 +69,7 @@ extension MultiSortListTupleMap on List<(Map, V)> { return this; } if (preference.length != criteria.length) { - print('Criteria length is not equal to preference'); + logger.d('Criteria length is not equal to preference'); return this; } diff --git a/lib/extensions/string.dart b/lib/extensions/string.dart new file mode 100644 index 00000000..b7ab7514 --- /dev/null +++ b/lib/extensions/string.dart @@ -0,0 +1,11 @@ +import 'package:html_unescape/html_unescape.dart'; + +final htmlEscape = HtmlUnescape(); + +extension UnescapeHtml on String { + String unescapeHtml() => htmlEscape.convert(this); +} + +extension NullableUnescapeHtml on String? { + String? unescapeHtml() => this == null ? null : htmlEscape.convert(this!); +} diff --git a/lib/extensions/track.dart b/lib/extensions/track.dart index 19935e89..51498b33 100644 --- a/lib/extensions/track.dart +++ b/lib/extensions/track.dart @@ -4,26 +4,29 @@ import 'package:spotube/extensions/artist_simple.dart'; extension TrackJson on Track { Map toJson() { + return TrackJson.trackToJson(this); + } + + static Map trackToJson(Track track) { return { - "album": album?.toJson(), - "artists": artists?.map((artist) => artist.toJson()).toList(), - "availableMarkets": availableMarkets, - "discNumber": discNumber, - "duration": duration.toString(), - "durationMs": durationMs, - "explicit": explicit, - // "externalIds": externalIds, - // "externalUrls": externalUrls, - "href": href, - "id": id, - "isPlayable": isPlayable, - // "linkedFrom": linkedFrom, - "name": name, - "popularity": popularity, - "previewUrl": previewUrl, - "trackNumber": trackNumber, - "type": type, - "uri": uri, + "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, }; } } diff --git a/lib/generated_plugin_registrant.dart b/lib/generated_plugin_registrant.dart index dc0cbb0b..a25a1f5f 100644 --- a/lib/generated_plugin_registrant.dart +++ b/lib/generated_plugin_registrant.dart @@ -8,7 +8,6 @@ import 'package:audio_service_web/audio_service_web.dart'; import 'package:audio_session/audio_session_web.dart'; -import 'package:file_picker/_internal/file_picker_web.dart'; import 'package:shared_preferences_web/shared_preferences_web.dart'; import 'package:url_launcher_web/url_launcher_web.dart'; @@ -18,7 +17,6 @@ import 'package:flutter_web_plugins/flutter_web_plugins.dart'; void registerPlugins(Registrar registrar) { AudioServiceWeb.registerWith(registrar); AudioSessionWeb.registerWith(registrar); - FilePickerWeb.registerWith(registrar); SharedPreferencesPlugin.registerWith(registrar); UrlLauncherPlugin.registerWith(registrar); registrar.registerMessageHandler(); diff --git a/lib/hooks/configurators/use_close_behavior.dart b/lib/hooks/configurators/use_close_behavior.dart new file mode 100644 index 00000000..05c03fff --- /dev/null +++ b/lib/hooks/configurators/use_close_behavior.dart @@ -0,0 +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/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); + }; + +void useCloseBehavior(WidgetRef ref) { + useWindowListener( + onWindowClose: () async { + final preferences = ref.read(userPreferencesProvider); + if (preferences.closeBehavior == CloseBehavior.minimizeToTray) { + await DesktopTools.window.hide(); + closeNotification?.show(); + } else { + exit(0); + } + }, + ); +} diff --git a/lib/hooks/configurators/use_deep_linking.dart b/lib/hooks/configurators/use_deep_linking.dart new file mode 100644 index 00000000..3b7ec3f3 --- /dev/null +++ b/lib/hooks/configurators/use_deep_linking.dart @@ -0,0 +1,110 @@ +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'; + +final appLinks = AppLinks(); +final linkStream = appLinks.allStringLinkStream.asBroadcastStream(); + +void useDeepLinking(WidgetRef ref) { + // single instance no worries + final spotify = ref.watch(spotifyProvider); + final queryClient = useQueryClient(); + + useEffect(() { + void uriListener(List files) async { + for (final file in files) { + if (file.type != SharedMediaType.URL) continue; + final url = Uri.parse(file.value!); + if (url.pathSegments.length != 2) continue; + + switch (url.pathSegments.first) { + case "album": + router.push( + "/album/${url.pathSegments.last}", + extra: await queryClient.fetchQuery( + "album/${url.pathSegments.last}", + () => spotify.albums.get(url.pathSegments.last), + ), + ); + break; + case "artist": + router.push("/artist/${url.pathSegments.last}"); + break; + case "playlist": + router.push( + "/playlist/${url.pathSegments.last}", + extra: await queryClient.fetchQuery( + "playlist/${url.pathSegments.last}", + () => spotify.playlists.get(url.pathSegments.last), + ), + ); + break; + case "track": + router.push( + "/track/${url.pathSegments.last}", + ); + break; + default: + break; + } + } + } + + StreamSubscription? mediaStream; + + if (DesktopTools.platform.isMobile) { + FlutterSharingIntent.instance.getInitialSharing().then(uriListener); + + mediaStream = + FlutterSharingIntent.instance.getMediaStream().listen(uriListener); + } + + final subscription = linkStream.listen((uri) async { + 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; + } + }); + + return () { + mediaStream?.cancel(); + subscription.cancel(); + }; + }, [spotify, queryClient]); +} diff --git a/lib/hooks/use_disable_battery_optimizations.dart b/lib/hooks/configurators/use_disable_battery_optimizations.dart similarity index 94% rename from lib/hooks/use_disable_battery_optimizations.dart rename to lib/hooks/configurators/use_disable_battery_optimizations.dart index cf1ad0c1..c1155d19 100644 --- a/lib/hooks/use_disable_battery_optimizations.dart +++ b/lib/hooks/configurators/use_disable_battery_optimizations.dart @@ -1,10 +1,10 @@ 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/use_async_effect.dart'; +import 'package:spotube/hooks/utils/use_async_effect.dart'; bool _asked = false; -void useDisableBatterOptimizations() { +void useDisableBatteryOptimizations() { useAsyncEffect(() async { if (!DesktopTools.platform.isAndroid || _asked) return; final localStorage = await SharedPreferences.getInstance(); diff --git a/lib/hooks/configurators/use_endless_playback.dart b/lib/hooks/configurators/use_endless_playback.dart new file mode 100644 index 00000000..f5d11829 --- /dev/null +++ b/lib/hooks/configurators/use_endless_playback.dart @@ -0,0 +1,103 @@ +import 'package:catcher_2/catcher_2.dart'; +import 'package:fl_query_hooks/fl_query_hooks.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/provider/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 spotify = ref.watch(spotifyProvider); + final endlessPlayback = + ref.watch(userPreferencesProvider.select((s) => s.endlessPlayback)); + + final queryClient = useQueryClient(); + + useEffect( + () { + if (!endlessPlayback || auth == null) return null; + + void listener(int index) async { + try { + final playlist = ref.read(ProxyPlaylistNotifier.provider); + 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 radios = pages + .expand((e) => e.items?.toList() ?? []) + .toList() + .cast(); + + final artists = track.artists!.map((e) => e.name); + + final radio = radios.firstWhere( + (e) { + final validPlaylists = + artists.where((a) => e.description!.contains(a!)); + return e.name == "${track.name} Radio" && + (validPlaylists.length >= 2 || + validPlaylists.length == artists.length) && + e.owner?.displayName != "Spotify"; + }, + orElse: () => radios.first, + ); + + final tracks = + await spotify.playlists.getTracksByPlaylistId(radio.id!).all(); + + await playback.addTracks( + tracks.toList() + ..removeWhere((e) { + final playlist = ref.read(ProxyPlaylistNotifier.provider); + final isDuplicate = playlist.tracks.any((t) => t.id == e.id); + return e.id == track.id || isDuplicate; + }), + ); + } catch (e, stack) { + Catcher2.reportCheckedError(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 && + audioPlayer.isPlaying) { + listener(playlist.active!); + } + + final subscription = + audioPlayer.currentIndexChangedStream.listen(listener); + + return subscription.cancel; + }, + [ + spotify, + playback, + queryClient, + playlist.tracks, + endlessPlayback, + auth, + ], + ); +} diff --git a/lib/hooks/configurators/use_get_storage_perms.dart b/lib/hooks/configurators/use_get_storage_perms.dart new file mode 100644 index 00000000..3fcb369b --- /dev/null +++ b/lib/hooks/configurators/use_get_storage_perms.dart @@ -0,0 +1,38 @@ +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'; + +void useGetStoragePermissions(WidgetRef ref) { + final isMounted = useIsMounted(); + + useAsyncEffect( + () async { + if (!DesktopTools.platform.isMobile) return; + + final androidInfo = await DeviceInfoPlugin().androidInfo; + + final hasNoStoragePerm = androidInfo.version.sdkInt < 33 && + !await Permission.storage.isGranted && + !await Permission.storage.isLimited; + + final hasNoAudioPerm = androidInfo.version.sdkInt >= 33 && + !await Permission.audio.isGranted && + !await Permission.audio.isLimited; + + if (hasNoStoragePerm) { + await Permission.storage.request(); + if (isMounted()) ref.refresh(localTracksProvider); + } + if (hasNoAudioPerm) { + await Permission.audio.request(); + if (isMounted()) ref.refresh(localTracksProvider); + } + }, + null, + [], + ); +} diff --git a/lib/hooks/use_init_sys_tray.dart b/lib/hooks/configurators/use_init_sys_tray.dart similarity index 95% rename from lib/hooks/use_init_sys_tray.dart rename to lib/hooks/configurators/use_init_sys_tray.dart index e9aa05b6..8080bea6 100644 --- a/lib/hooks/use_init_sys_tray.dart +++ b/lib/hooks/configurators/use_init_sys_tray.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -5,7 +7,7 @@ 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_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; void useInitSysTray(WidgetRef ref) { final context = useContext(); @@ -23,7 +25,7 @@ void useInitSysTray(WidgetRef ref) { } final enabled = !playlist.isFetching; systemTray.value = await DesktopTools.createSystemTrayMenu( - title: DesktopTools.platform.isLinux ? "" : "Spotube", + title: DesktopTools.platform.isWindows ? "Spotube" : "", iconPath: "assets/spotube-logo.png", windowsIconPath: "assets/spotube-logo.ico", items: [ @@ -70,7 +72,7 @@ void useInitSysTray(WidgetRef ref) { label: "Quit", name: "quit", onClicked: (item) async { - await DesktopTools.window.close(); + exit(0); }, ), ], diff --git a/lib/hooks/use_update_checker.dart b/lib/hooks/configurators/use_update_checker.dart similarity index 95% rename from lib/hooks/use_update_checker.dart rename to lib/hooks/configurators/use_update_checker.dart index 33df5397..1a6a5be5 100644 --- a/lib/hooks/use_update_checker.dart +++ b/lib/hooks/configurators/use_update_checker.dart @@ -8,8 +8,8 @@ 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/use_package_info.dart'; -import 'package:spotube/provider/user_preferences_provider.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'; diff --git a/lib/hooks/configurators/use_window_listener.dart b/lib/hooks/configurators/use_window_listener.dart new file mode 100644 index 00000000..b91ad413 --- /dev/null +++ b/lib/hooks/configurators/use_window_listener.dart @@ -0,0 +1,197 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +class CallbackWindowListener implements WindowListener { + final VoidCallback? _onWindowClose; + final VoidCallback? _onWindowFocus; + final VoidCallback? _onWindowBlur; + final VoidCallback? _onWindowMaximize; + final VoidCallback? _onWindowUnmaximize; + final VoidCallback? _onWindowMinimize; + final VoidCallback? _onWindowRestore; + final VoidCallback? _onWindowResize; + final VoidCallback? _onWindowResized; + final VoidCallback? _onWindowMove; + final VoidCallback? _onWindowMoved; + final VoidCallback? _onWindowEnterFullScreen; + final VoidCallback? _onWindowLeaveFullScreen; + final VoidCallback? _onWindowDocked; + final VoidCallback? _onWindowUndocked; + final VoidCallback? _onWindowEvent; + + const CallbackWindowListener({ + VoidCallback? onWindowClose, + VoidCallback? onWindowFocus, + VoidCallback? onWindowBlur, + VoidCallback? onWindowMaximize, + VoidCallback? onWindowUnmaximize, + VoidCallback? onWindowMinimize, + VoidCallback? onWindowRestore, + VoidCallback? onWindowResize, + VoidCallback? onWindowResized, + VoidCallback? onWindowMove, + VoidCallback? onWindowMoved, + VoidCallback? onWindowEnterFullScreen, + VoidCallback? onWindowLeaveFullScreen, + VoidCallback? onWindowDocked, + VoidCallback? onWindowUndocked, + VoidCallback? onWindowEvent, + }) : _onWindowClose = onWindowClose, + _onWindowFocus = onWindowFocus, + _onWindowBlur = onWindowBlur, + _onWindowMaximize = onWindowMaximize, + _onWindowUnmaximize = onWindowUnmaximize, + _onWindowMinimize = onWindowMinimize, + _onWindowRestore = onWindowRestore, + _onWindowResize = onWindowResize, + _onWindowResized = onWindowResized, + _onWindowMove = onWindowMove, + _onWindowMoved = onWindowMoved, + _onWindowEnterFullScreen = onWindowEnterFullScreen, + _onWindowLeaveFullScreen = onWindowLeaveFullScreen, + _onWindowDocked = onWindowDocked, + _onWindowUndocked = onWindowUndocked, + _onWindowEvent = onWindowEvent; + + @override + void onWindowBlur() { + return _onWindowBlur?.call(); + } + + @override + void onWindowClose() { + return _onWindowClose?.call(); + } + + @override + void onWindowDocked() { + return _onWindowDocked?.call(); + } + + @override + void onWindowEnterFullScreen() { + return _onWindowEnterFullScreen?.call(); + } + + @override + void onWindowEvent(String eventName) { + return _onWindowEvent?.call(); + } + + @override + void onWindowFocus() { + return _onWindowFocus?.call(); + } + + @override + void onWindowLeaveFullScreen() { + return _onWindowLeaveFullScreen?.call(); + } + + @override + void onWindowMaximize() { + return _onWindowMaximize?.call(); + } + + @override + void onWindowMinimize() { + return _onWindowMinimize?.call(); + } + + @override + void onWindowMove() { + return _onWindowMove?.call(); + } + + @override + void onWindowMoved() { + return _onWindowMoved?.call(); + } + + @override + void onWindowResize() { + return _onWindowResize?.call(); + } + + @override + void onWindowResized() { + return _onWindowResized?.call(); + } + + @override + void onWindowRestore() { + return _onWindowRestore?.call(); + } + + @override + void onWindowUndocked() { + return _onWindowUndocked?.call(); + } + + @override + void onWindowUnmaximize() { + return _onWindowUnmaximize?.call(); + } +} + +void useWindowListener({ + VoidCallback? onWindowClose, + VoidCallback? onWindowFocus, + VoidCallback? onWindowBlur, + VoidCallback? onWindowMaximize, + VoidCallback? onWindowUnmaximize, + VoidCallback? onWindowMinimize, + VoidCallback? onWindowRestore, + VoidCallback? onWindowResize, + VoidCallback? onWindowResized, + VoidCallback? onWindowMove, + VoidCallback? onWindowMoved, + VoidCallback? onWindowEnterFullScreen, + VoidCallback? onWindowLeaveFullScreen, + VoidCallback? onWindowDocked, + VoidCallback? onWindowUndocked, + VoidCallback? onWindowEvent, +}) { + useEffect(() { + final listener = CallbackWindowListener( + onWindowClose: onWindowClose, + onWindowFocus: onWindowFocus, + onWindowBlur: onWindowBlur, + onWindowMaximize: onWindowMaximize, + onWindowUnmaximize: onWindowUnmaximize, + onWindowMinimize: onWindowMinimize, + onWindowRestore: onWindowRestore, + onWindowResize: onWindowResize, + onWindowResized: onWindowResized, + onWindowMove: onWindowMove, + onWindowMoved: onWindowMoved, + onWindowEnterFullScreen: onWindowEnterFullScreen, + onWindowLeaveFullScreen: onWindowLeaveFullScreen, + onWindowDocked: onWindowDocked, + onWindowUndocked: onWindowUndocked, + onWindowEvent: onWindowEvent, + ); + DesktopTools.window.addListener(listener); + return () { + DesktopTools.window.removeListener(listener); + }; + }, [ + onWindowClose, + onWindowFocus, + onWindowBlur, + onWindowMaximize, + onWindowUnmaximize, + onWindowMinimize, + onWindowRestore, + onWindowResize, + onWindowResized, + onWindowMove, + onWindowMoved, + onWindowEnterFullScreen, + onWindowLeaveFullScreen, + onWindowDocked, + onWindowUndocked, + onWindowEvent, + ]); +} diff --git a/lib/hooks/use_auto_scroll_controller.dart b/lib/hooks/controllers/use_auto_scroll_controller.dart similarity index 100% rename from lib/hooks/use_auto_scroll_controller.dart rename to lib/hooks/controllers/use_auto_scroll_controller.dart diff --git a/lib/hooks/use_package_info.dart b/lib/hooks/controllers/use_package_info.dart similarity index 100% rename from lib/hooks/use_package_info.dart rename to lib/hooks/controllers/use_package_info.dart diff --git a/lib/hooks/use_sidebarx_controller.dart b/lib/hooks/controllers/use_sidebarx_controller.dart similarity index 100% rename from lib/hooks/use_sidebarx_controller.dart rename to lib/hooks/controllers/use_sidebarx_controller.dart diff --git a/lib/hooks/use_spotify_infinite_query.dart b/lib/hooks/spotify/use_spotify_infinite_query.dart similarity index 100% rename from lib/hooks/use_spotify_infinite_query.dart rename to lib/hooks/spotify/use_spotify_infinite_query.dart diff --git a/lib/hooks/use_spotify_mutation.dart b/lib/hooks/spotify/use_spotify_mutation.dart similarity index 100% rename from lib/hooks/use_spotify_mutation.dart rename to lib/hooks/spotify/use_spotify_mutation.dart diff --git a/lib/hooks/use_spotify_query.dart b/lib/hooks/spotify/use_spotify_query.dart similarity index 100% rename from lib/hooks/use_spotify_query.dart rename to lib/hooks/spotify/use_spotify_query.dart diff --git a/lib/hooks/use_custom_status_bar_color.dart b/lib/hooks/use_custom_status_bar_color.dart deleted file mode 100644 index 92f845cf..00000000 --- a/lib/hooks/use_custom_status_bar_color.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -void useCustomStatusBarColor( - Color color, - bool isCurrentRoute, { - bool noSetBGColor = false, -}) { - final context = useContext(); - final backgroundColor = Theme.of(context).scaffoldBackgroundColor; - resetStatusbar() => SystemChrome.setSystemUIOverlayStyle( - SystemUiOverlayStyle( - statusBarColor: backgroundColor, // status bar color - statusBarIconBrightness: backgroundColor.computeLuminance() > 0.179 - ? Brightness.dark - : Brightness.light, - ), - ); - - final statusBarColor = SystemChrome.latestStyle?.statusBarColor; - - useEffect(() { - if (isCurrentRoute && statusBarColor != color) { - SystemChrome.setSystemUIOverlayStyle( - SystemUiOverlayStyle( - statusBarColor: - noSetBGColor ? Colors.transparent : color, // status bar color - statusBarIconBrightness: color.computeLuminance() > 0.179 - ? Brightness.dark - : Brightness.light, - ), - ); - } else if (!isCurrentRoute && statusBarColor == color) { - resetStatusbar(); - } - return; - }, [color, isCurrentRoute, statusBarColor]); - - useEffect(() { - return resetStatusbar; - }, []); -} diff --git a/lib/hooks/use_is_current_route.dart b/lib/hooks/use_is_current_route.dart deleted file mode 100644 index b7b6490a..00000000 --- a/lib/hooks/use_is_current_route.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; - -bool? useIsCurrentRoute([String matcher = "/"]) { - final isCurrentRoute = useState(null); - final context = useContext(); - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((timer) { - final isCurrent = GoRouterState.of(context).matchedLocation == matcher; - if (isCurrent != isCurrentRoute.value) { - isCurrentRoute.value = isCurrent; - } - }); - return null; - }); - return isCurrentRoute.value; -} diff --git a/lib/hooks/use_shared_preferences.dart b/lib/hooks/use_shared_preferences.dart deleted file mode 100644 index 922beaa6..00000000 --- a/lib/hooks/use_shared_preferences.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -SharedPreferences? useSharedPreferences() { - final future = useMemoized(SharedPreferences.getInstance); - final snapshot = useFuture(future, initialData: null); - - return snapshot.data; -} diff --git a/lib/hooks/use_async_effect.dart b/lib/hooks/utils/use_async_effect.dart similarity index 100% rename from lib/hooks/use_async_effect.dart rename to lib/hooks/utils/use_async_effect.dart diff --git a/lib/hooks/use_breakpoint_value.dart b/lib/hooks/utils/use_breakpoint_value.dart similarity index 100% rename from lib/hooks/use_breakpoint_value.dart rename to lib/hooks/utils/use_breakpoint_value.dart diff --git a/lib/hooks/use_brightness_value.dart b/lib/hooks/utils/use_brightness_value.dart similarity index 100% rename from lib/hooks/use_brightness_value.dart rename to lib/hooks/utils/use_brightness_value.dart diff --git a/lib/hooks/utils/use_custom_status_bar_color.dart b/lib/hooks/utils/use_custom_status_bar_color.dart new file mode 100644 index 00000000..d1266fe2 --- /dev/null +++ b/lib/hooks/utils/use_custom_status_bar_color.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +void useCustomStatusBarColor( + Color color, + bool isCurrentRoute, { + bool noSetBGColor = false, + bool? automaticSystemUiAdjustment, +}) { + final context = useContext(); + final backgroundColor = Theme.of(context).scaffoldBackgroundColor; + resetStatusbar() => SystemChrome.setSystemUIOverlayStyle( + SystemUiOverlayStyle( + statusBarColor: backgroundColor, // status bar color + statusBarIconBrightness: backgroundColor.computeLuminance() > 0.179 + ? Brightness.dark + : Brightness.light, + ), + ); + + final statusBarColor = SystemChrome.latestStyle?.statusBarColor; + + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (automaticSystemUiAdjustment != null) { + WidgetsBinding.instance.renderView.automaticSystemUiAdjustment = + automaticSystemUiAdjustment; + } + if (isCurrentRoute && statusBarColor != color) { + final isLight = color.computeLuminance() > 0.179; + SystemChrome.setSystemUIOverlayStyle( + SystemUiOverlayStyle( + statusBarColor: + noSetBGColor ? Colors.transparent : color, // status bar color + statusBarIconBrightness: + isLight ? Brightness.dark : Brightness.light, + ), + ); + } else if (!isCurrentRoute && statusBarColor == color) { + resetStatusbar(); + } + }); + return () { + if (automaticSystemUiAdjustment != null) { + WidgetsBinding.instance.renderView.automaticSystemUiAdjustment = false; + } + }; + }, [color, isCurrentRoute, statusBarColor]); + + useEffect(() { + return resetStatusbar; + }, []); +} diff --git a/lib/hooks/use_debounce.dart b/lib/hooks/utils/use_debounce.dart similarity index 100% rename from lib/hooks/use_debounce.dart rename to lib/hooks/utils/use_debounce.dart diff --git a/lib/hooks/use_force_update.dart b/lib/hooks/utils/use_force_update.dart similarity index 100% rename from lib/hooks/use_force_update.dart rename to lib/hooks/utils/use_force_update.dart diff --git a/lib/hooks/use_palette_color.dart b/lib/hooks/utils/use_palette_color.dart similarity index 100% rename from lib/hooks/use_palette_color.dart rename to lib/hooks/utils/use_palette_color.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb new file mode 100644 index 00000000..eebede99 --- /dev/null +++ b/lib/l10n/app_ar.arb @@ -0,0 +1,290 @@ +{ + "guest": "ضيف", + "browse": "تصفح", + "search": "بحث", + "library": "مكتبة", + "lyrics": "كلمات", + "settings": "إعدادات", + "genre_categories_filter": "تصفية الفئات أو الأنواع...", + "genre": "النوع", + "personalized": "شخصية", + "featured": "متميز", + "new_releases": "الإصدارات الجديدة", + "songs": "أغاني", + "playing_track": "تشغيل {track}", + "queue_clear_alert": "سيؤدي هذا إلى مسح قائمة الانتظار الحالية. {track_length} ستتم إزالة المقطوعات\nهل تريد الإستمرار؟", + "load_more": "تحميل المزيد", + "playlists": "قوائم التشغيل", + "artists": "فنانون", + "albums": "ألبومات", + "tracks": "مقطوعات", + "downloads": "تنزيلات", + "filter_playlists": "تصفية قوائم التشغيل الخاصة بك...", + "liked_tracks": "المقطوعات التي أعجبتك", + "liked_tracks_description": "جميع المقطوعات التي أعجبتك", + "create_playlist": "إنشاء قائمة التشغيل", + "create_a_playlist": "إنشاء قائمة تشغيل", + "update_playlist": "تحديث قائمة التشغيل", + "create": "إنشاء", + "cancel": "إلغاء", + "update": "تحديث", + "playlist_name": "اسم قائمة التشغيل", + "name_of_playlist": "اسم قائمة التشغيل", + "description": "وصف", + "public": "عام", + "collaborative": "تعاوني", + "search_local_tracks": "بحث عن مقطوعات محلية", + "play": "تشغيل", + "delete": "حذف", + "none": "لا شيء", + "sort_a_z": "الترتيب من A-Z", + "sort_z_a": "الترتيب من Z-A", + "sort_artist": "الترتيب حسب الفنان", + "sort_album": "فرز حسب الألبوم", + "sort_tracks": "ترتيب المقطوعات", + "currently_downloading": "يتم التنزيل ({tracks_length})", + "cancel_all": "إلغاء الكل", + "filter_artist": "تصفية الفنانين...", + "followers": "{followers} متابعون", + "add_artist_to_blacklist": "إضافة فنان إلى القائمة السوداء", + "top_tracks": "أهم المقطوعات الصوتية", + "fans_also_like": "المعجبون يحبون أيضاً", + "loading": "جارٍ التحميل", + "artist": "فنان", + "blacklisted": "في القائمة السوداء", + "following": "يتابع", + "follow": "تابع", + "artist_url_copied": "تم نسخ عنوان URL للفنان إلى الحافظة", + "added_to_queue": "تم إضافة المقطوعات إلى قائمة الإنتظار {tracks}", + "filter_albums": "تصفية الألبومات...", + "synced": "تم المزامنة", + "plain": "سهل", + "shuffle": "خلط", + "search_tracks": "يحث عن مقطوعات", + "released": "تم الإصدار", + "error": "خطأ {error}", + "title": "عنوان", + "time": "وقت", + "more_actions": "المزيد من الإجراءات", + "download_count": "تنزيل ({count})", + "add_count_to_playlist": "إضافة ({count}) إلى قائمة التشغيل", + "add_count_to_queue": "إضافة ({count}) إلى قائمة الإنتظار", + "play_count_next": "تشغيل ({count}) التالي", + "album": "ألبوم", + "copied_to_clipboard": "تم النسخ {data} إلى الحافظة", + "add_to_following_playlists": "إضافة {track} إلى قوائم التشغيل التالية", + "add": "إضافة", + "added_track_to_queue": "تم الإضافة {track} إلى قائمة الإنتظار", + "add_to_queue": "إضافة إلى قائمة التشغيل", + "track_will_play_next": "{track} سيتم تشغيل التالي", + "play_next": "تشغيل التالي", + "removed_track_from_queue": "تم الإزالة {track} من قائمة الإنتظار", + "remove_from_queue": "إزالة من قائمة الإنتظار", + "remove_from_favorites": "إزالة من المفضلة", + "save_as_favorite": "حفظ كمفضل", + "add_to_playlist": "إضافة إلى قائمة التشغيل", + "remove_from_playlist": "إزالة من قائمة التشغيل", + "add_to_blacklist": "إضافة إلى القائمة السوداء", + "remove_from_blacklist": "إزالة من القائمة السوداء", + "share": "مشاكرة", + "mini_player": "مشغل مصغر", + "slide_to_seek": "قم بالتمرير للبحث للأمام أو للخلف", + "shuffle_playlist": "قائمة تشغيل عشوائية", + "unshuffle_playlist": "إلغاء ترتيب قائمة التشغيل", + "previous_track": "المقطوعة السابقة", + "next_track": "مقطوعة جديدة", + "pause_playback": "إيقاف التشغيل مؤقتًا", + "resume_playback": "استئناف التشغيل", + "loop_track": "تشغيل المقطوعة بشكل لا نهائي", + "repeat_playlist": "تكرار قائمة التشغيل", + "queue": "قائمة الإنتظار", + "alternative_track_sources": "مصادر مقطوعات بديلة", + "download_track": "تنزيل المقطوعة", + "tracks_in_queue": "{tracks} المقطوعات في قائمة الإنتظار", + "clear_all": "مسح الكل", + "show_hide_ui_on_hover": "إظهار/إخفاء واجهة المستخدم عند التمرير", + "always_on_top": "دائما في القمة", + "exit_mini_player": "خروج من المشغل المصغر", + "download_location": "تنزيل الموقع", + "account": "حساب", + "login_with_spotify": "تسجيل الدخول بواسطة حساب Spotify", + "connect_with_spotify": "توصيل بـSpotify", + "logout": "تسجيل الخروج", + "logout_of_this_account": "تسجيل الخروج من هذا الحساب", + "language_region": "اللغة والمنطقة", + "language": "لغة", + "system_default": "لغة النظام الإفتراضية", + "market_place_region": "منطقة السوق", + "recommendation_country": "بلد التوصية", + "appearance": "مظهر", + "layout_mode": "وضع التخطيط", + "override_layout_settings": "تجاوز إعدادات وضع التخطيط سريع الاستجابة", + "adaptive": "متكيف", + "compact": "مدمج", + "extended": "ممتد", + "theme": "مظهر", + "dark": "داكن", + "light": "ساطعt", + "system": "حسب النظام", + "accent_color": "لون تمييز", + "sync_album_color": "مزامنة لون الألبوم", + "sync_album_color_description": "يستخدم اللون السائد لصورة الألبوم باعتباره لون التمييز", + "playback": "التشغيل", + "audio_quality": "جودة الصوت", + "high": "مرتفعة", + "low": "منخفضة", + "pre_download_play": "التحميل المسبق والتشغيل", + "pre_download_play_description": "بدلاً من دفق الصوت، قم بتنزيل وحدات البايت وتشغيلها بدلاً من ذلك (موصى به لمستخدمي Bandwidth)", + "skip_non_music": "تخطي المقاطع غير الموسيقية (SponsorBlock)", + "blacklist_description": "المقطوعات والفنانون المدرجون في القائمة السوداء", + "wait_for_download_to_finish": "يرجى الانتظار حتى انتهاء التنزيل الحالي", + "desktop": "سطح المكتب", + "close_behavior": "إغلاق التصرف", + "close": "إغلاق", + "minimize_to_tray": "تصغير إلى الدرج", + "show_tray_icon": "إظهار أيقونات درج النظام", + "about": "حول", + "u_love_spotube": "نحن نعلم أنك تحب Spotube", + "check_for_updates": "تحقق من وجود تحديثات", + "about_spotube": "حول Spotube", + "blacklist": "قائمة سوداء", + "please_sponsor": "يرجى دعم/التبرع", + "spotube_description": "Spotube، عميل Spotify خفيف الوزن ومتعدد المنصات ومجاني للجميع", + "version": "إصدار", + "build_number": "رقم البنية", + "founder": "الموئسس", + "repository": "المستودع", + "bug_issues": "أخطاء+مشاكل", + "made_with": "صُنع باستخدام ❤️ في بنغلاديش🇧🇩", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "الترخيص", + "add_spotify_credentials": "أضف بيانات Spotify الخاصة بك للبدء", + "credentials_will_not_be_shared_disclaimer": "لا تقلق، لن يتم جمع أي من بيانات الخاصة بك أو مشاركتها مع أي شخص", + "know_how_to_login": "لا تعرف كيف تفعل هذا؟", + "follow_step_by_step_guide": "اتبع الدليل خطوة بخطوة", + "spotify_cookie": "Spotify {name} كوكيز", + "cookie_name_cookie": "{name} كوكيز", + "fill_in_all_fields": "يرجى تعبئة جميع الحقول", + "submit": "إرسال", + "exit": "خروج", + "previous": "السابق", + "next": "التالي", + "done": "تم", + "step_1": "الخطوة 1", + "first_go_to": "أولا، اذهب إلى", + "login_if_not_logged_in": "وتسجيل الدخول/الاشتراك إذا لم تقم بتسجيل الدخول", + "step_2": "الخطوة 2", + "step_2_steps": "1. بمجرد تسجيل الدخول، اضغط على F12 أو انقر بزر الماوس الأيمن > فحص لفتح أدوات تطوير المتصفح.\n2. ثم انتقل إلى علامة التبويب \"التطبيقات\" (Chrome وEdge وBrave وما إلى ذلك.) أو علامة التبويب \"التخزين\" (Firefox وPalemoon وما إلى ذلك..)\n3. انتقل إلى قسم \"ملفات تعريف الارتباط\" ثم القسم الفرعي \"https://accounts.spotify.com\"", + "step_3": "الخطوة 3", + "success_emoji": "نجاح 🥳", + "success_message": "لقد قمت الآن بتسجيل الدخول بنجاح باستخدام حساب Spotify الخاص بك. عمل جيد يا صديقي!", + "step_4": "الخطوة 4", + "something_went_wrong": "هناك خطأ ما", + "piped_instance": "مثيل خادم Piped", + "piped_description": "مثيل خادم Piped الذي سيتم استخدامه لمطابقة المقطوعة", + "piped_warning": "البعض منهم قد لا يعمل بشكل جيد. لذلك استخدمه على مسؤوليتك", + "generate_playlist": "إنشاء قائمة التشغيل", + "track_exists": "المقطوعة {track} بالفعل موجودة", + "replace_downloaded_tracks": "استبدل جميع المقطوعات التي تم تنزيلها", + "skip_download_tracks": "تخطي تنزيل كافة المقطوعات التي تم تنزيلها", + "do_you_want_to_replace": "هل تريد استبدال المقطوعة الحالية؟", + "replace": "إستبدال", + "skip": "تخطي", + "select_up_to_count_type": "إختر ما يصل إلى {count} {type}", + "select_genres": "حدد الأنواع", + "add_genres": "أضف الأنواع", + "country": "دولة", + "number_of_tracks_generate": "عدد المسارات المقطوعات المراد توليدها", + "acousticness": "صوتية", + "danceability": "قدرة على الرقص", + "energy": "طاقة", + "instrumentalness": "نفعية", + "liveness": "حيوية", + "loudness": "بريق", + "speechiness": "كلام", + "valence": "تكافؤ", + "popularity": "شعبية", + "key": "مفتاح", + "duration": "مدة (s)", + "tempo": "Tempo (BPM)", + "mode": "Mode", + "time_signature": "توقيع الوقت", + "short": "قصير", + "medium": "متوسط", + "long": "طويل", + "min": "أدنى", + "max": "أقصى", + "target": "هدف", + "moderate": "معتدل", + "deselect_all": "الغاء تحديد الكل", + "select_all": "اختر الكل", + "are_you_sure": "هل أنت متأكد؟", + "generating_playlist": "جارٍ إنشاء قائمة التشغيل المخصصة...", + "selected_count_tracks": "مقطوعات {count} مختارة", + "download_warning": "إذا قمت بتنزيل جميع المقاطع الصوتية بكميات كبيرة، فمن الواضح أنك تقوم بقرصنة الموسيقى وتسبب الضرر للمجتمع الإبداعي للموسيقى. أتمنى أن تكون على علم بهذا. حاول دائمًا احترام ودعم العمل الجاد للفنان", + "download_ip_ban_warning": "بالمناسبة، يمكن أن يتم حظر عنوان IP الخاص بك على YouTube بسبب طلبات التنزيل الزائدة عن المعتاد. يعني حظر IP أنه لا يمكنك استخدام YouTube (حتى إذا قمت بتسجيل الدخول) لمدة تتراوح بين شهرين إلى ثلاثة أشهر على الأقل من جهاز IP هذا. ولا يتحمل Spotube أي مسؤولية إذا حدث هذا على الإطلاق", + "by_clicking_accept_terms": "بالنقر على \"قبول\"، فإنك توافق على الشروط التالية:", + "download_agreement_1": "أعلم أنني أقوم بقرصنة الموسيقى. انا سيئ", + "download_agreement_2": "سأدعم الفنان أينما أستطيع، وأنا أفعل هذا فقط لأنني لا أملك المال لشراء أعمالهم الفنية", + "download_agreement_3": "أدرك تمامًا أنه يمكن حظر عنوان IP الخاص بي على YouTube ولا أحمل Spotube أو مالكيه/مساهميه المسؤولية عن أي حوادث ناجمة عن الإجراء الحالي الخاص بي", + "decline": "رفض", + "accept": "قبول", + "details": "تفاصيل", + "youtube": "YouTube", + "channel": "قناة", + "likes": "إعجابات", + "dislikes": "عدم الإعجابات", + "views": "مشاهدات", + "streamUrl": "عنوان URL البث", + "stop": "إيقاف", + "sort_newest": "الترتيب حسب الأقدم", + "sort_oldest": "الترتيب حسب الأقدم", + "sleep_timer": "مؤقت النوم", + "mins": "{minutes} دقائق", + "hours": "{hours} ساعات", + "hour": "{hours} ساعة", + "custom_hours": "ساعات مخصصة", + "logs": "سجلات", + "developers": "المطورون", + "not_logged_in": "لم تقم بتسجيل الدخول", + "search_mode": "وضع البحث", + "audio_source": "مصدر الصوت", + "ok": "حسسناً", + "failed_to_encrypt": "فشل في التشفير", + "encryption_failed_warning": "يستخدم Spotube التشفير لتخزين بياناتك بشكل آمن. لكنها فشلت في القيام بذلك. لذلك سيعود الأمر إلى التخزين غير الآمن\nإذا كنت تستخدم Linux، فيرجى التأكد من تثبيت أي خدمة سرية (gnome-keyring، kde-wallet، keepassxc، إلخ)", + "querying_info": "جارٍ الاستعلام عن معلومات...", + "piped_api_down": "Piped API معطلة", + "piped_down_error_instructions": "المثيل الموجه {pipedInstance} معطل حاليًا\n\nيمكنك إما تغيير المثيل أو تغيير 'نوع API' إلى YouTube API الرسمي\n\nتأكد من إعادة تشغيل التطبيق بعد التغيير", + "you_are_offline": "أنت غير متصل حالياً", + "connection_restored": "تمت استعادة اتصالك بالإنترنت", + "use_system_title_bar": "استخدم شريط عنوان النظام", + "crunching_results": "تدمير النتائج", + "search_to_get_results": "إبحث للحصول على النتائج", + "use_amoled_mode": "استخدم وضع AMOLED", + "pitch_dark_theme": "موضوع دارت الأسود الفحمي", + "normalize_audio": "تطبيع الصوت", + "change_cover": "تغيير الغلاف", + "add_cover": "إضافة غلاف", + "restore_defaults": "استعادة الإعدادات الافتراضية", + "download_music_codec": "تنزيل ترميز الموسيقى", + "streaming_music_codec": "ترميز الموسيقى بالتدفق", + "login_with_lastfm": "تسجيل الدخول باستخدام Last.fm", + "connect": "اتصال", + "disconnect_lastfm": "قطع الاتصال بـ Last.fm", + "disconnect": "قطع الاتصال", + "username": "اسم المستخدم", + "password": "كلمة المرور", + "login": "تسجيل الدخول", + "login_with_your_lastfm": "تسجيل الدخول باستخدام حساب Last.fm الخاص بك", + "scrobble_to_lastfm": "تسجيل الاستماع على Last.fm", + "go_to_album": "الانتقال إلى الألبوم", + "discord_rich_presence": "وجود ديسكورد الغني", + "browse_all": "تصفح الكل", + "genres": "الأنواع الموسيقية", + "explore_genres": "استكشاف الأنواع", + "step_3_steps": "انسخ قيمة الكوكي \"sp_dc\"", + "step_4_steps": "الصق قيمة \"sp_dc\" المنسوخة", + "friends": "أصدقاء", + "no_lyrics_available": "عذرًا، تعذر العثور على كلمات الأغنية لهذه العنصر" +} \ No newline at end of file diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 98e165aa..2711f8d2 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -136,7 +136,6 @@ "skip_non_music": "গানের নন-মিউজিক সেগমেন্ট এড়িয়ে যান (SponsorBlock)", "blacklist_description": "কালো তালিকাভুক্ত গানের ট্র্যাক এবং শিল্পী", "wait_for_download_to_finish": "ডাউনলোড শেষ হওয়ার জন্য অপেক্ষা করুন", - "download_lyrics": "গানের সাথে লিরিক্স ডাউনলোড করুন", "desktop": "ডেস্কটপ", "close_behavior": "বন্ধ করার প্রক্রিয়া", "close": "বন্ধ করুন", @@ -176,11 +175,9 @@ "step_2": "ধাপ 2", "step_2_steps": "১. একবার আপনি লগ ইন করলে, ব্রাউজার ডেভটুল খুলতে F12 বা মাউসের রাইট ক্লিক > \"Inspect to open Browser DevTools\" টিপুন।\n২. তারপর \"Application\" ট্যাবে যান (Chrome, Edge, Brave etc..) অথবা \"Storage\" Tab (Firefox, Palemoon etc..)\n৩. \"Cookies \" বিভাগে যান তারপর \"https://accounts.spotify.com\" উপবিভাগে যান", "step_3": "ধাপ 3", - "step_3_steps": "\"sp_dc\" এবং \"sp_key\" (অথবা sp_gaid) কুকিজের মান কপি করুন", "success_emoji": "আমরা সফল🥳", "success_message": "এখন আপনি সফলভাবে আপনার Spotify অ্যাকাউন্ট দিয়ে লগ ইন করেছেন। সাধুভাত আপনাকে", "step_4": "ধাপ 4", - "step_4_steps": "কপি করা \"sp_dc\" এবং \"sp_key\" (অথবা sp_gaid) এর মান সংশ্লিষ্ট ফিল্ডে পেস্ট করুন", "something_went_wrong": "কিছু ভুল হয়েছে", "piped_instance": "Piped সার্ভার এড্রেস", "piped_description": "গান ম্যাচ করার জন্য ব্যবহৃত পাইপড সার্ভার", @@ -250,9 +247,44 @@ "developers": "ডেভেলপার", "not_logged_in": "আপনি লগইন করা নেই", "search_mode": "অনুসন্ধান মোড", - "youtube_api_type": "API প্রকার", + "audio_source": "অডিও উৎস", "ok": "ঠিক আছে", "failed_to_encrypt": "এনক্রিপ্ট করা ব্যর্থ হয়েছে", "encryption_failed_warning": "Spotube আপনার তথ্যগুলি নিরাপদভাবে স্টোর করতে এনক্রিপশন ব্যবহার করে। কিন্তু এটি ব্যর্থ হয়েছে। তাই এটি অনিরাপদ স্টোরে ফলফল হবে\nযদি আপনি Linux ব্যবহার করেন, তবে দয়া করে নিশ্চিত হউন যে আপনার কোনও সিক্রেট-সার্ভিস gnome-keyring, kde-wallet, keepassxc ইত্যাদি ইনস্টল করা আছে", - "querying_info": "তথ্য অনুসন্ধান করা হচ্ছে" + "querying_info": "তথ্য অনুসন্ধান করা হচ্ছে", + "piped_api_down": "পাইপড API ডাউন আছে", + "piped_down_error_instructions": "বর্তমানে পাইপড ইনস্ট্যান্স {pipedInstance} ডাউন আছে\n\nইনস্ট্যান্স পরিবর্তন করুন অথবা 'API টাইপ' পরিবর্তন করুন অফিসিয়াল ইউটিউব API হতে\n\nপরিবর্তনের পরে অ্যাপটি পুনরায় চালানোর নিশ্চিত করুন", + "you_are_offline": "আপনি বর্তমানে অফলাইন", + "connection_restored": "আপনার ইন্টারনেট সংযোগ পুনরুদ্ধার হয়েছে", + "use_system_title_bar": "সিস্টেম শিরোনাম বার ব্যবহার করুন", + "update_playlist": "প্লেলিস্ট আপডেট করুন", + "update": "আপডেট", + "crunching_results": "ফলাফল বিশ্লেষণ করা হচ্ছে...", + "search_to_get_results": "ফলাফল পেতে খোঁজ করুন", + "use_amoled_mode": "AMOLED মোড ব্যবহার করুন", + "pitch_dark_theme": "পিচ ব্ল্যাক ডার্ট থিম", + "normalize_audio": "অডিও স্তরমান করুন", + "change_cover": "কভার পরিবর্তন করুন", + "add_cover": "কভার যোগ করুন", + "restore_defaults": "ডিফল্ট সেটিংস পুনরুদ্ধার করুন", + "download_music_codec": "সঙ্গীত কোডেক ডাউনলোড করুন", + "streaming_music_codec": "স্ট্রিমিং সঙ্গীত কোডেক", + "login_with_lastfm": "Last.fm দিয়ে লগইন করুন", + "connect": "সংযোগ করুন", + "disconnect_lastfm": "Last.fm সংযোগ বিচ্ছিন্ন করুন", + "disconnect": "সংযোগ বিচ্ছিন্ন করুন", + "username": "ব্যবহারকারীর নাম", + "password": "পাসওয়ার্ড", + "login": "লগইন", + "login_with_your_lastfm": "আপনার Last.fm অ্যাকাউন্ট দিয়ে লগইন করুন", + "scrobble_to_lastfm": "Last.fm এ স্ক্রবল করুন", + "go_to_album": "الانتقال إلى الألبوم", + "discord_rich_presence": "وجود ديسكورد الغني", + "browse_all": "تصفح الكل", + "genres": "الأنواع الموسيقية", + "explore_genres": "استكشاف الأنواع", + "step_3_steps": "কুকি \"sp_dc\" এর মানটি কপি করুন", + "step_4_steps": "কপি করা \"sp_dc\" মানটি পেস্ট করুন", + "friends": "বন্ধু", + "no_lyrics_available": "দুঃখিত, এই ট্র্যাকের জন্য কথা খুঁজে পাওয়া গেলনা" } \ No newline at end of file diff --git a/lib/l10n/app_ca.arb b/lib/l10n/app_ca.arb index 11413af2..f46cfae4 100644 --- a/lib/l10n/app_ca.arb +++ b/lib/l10n/app_ca.arb @@ -136,7 +136,6 @@ "skip_non_music": "Ometre segments que no son música (SponsorBlock)", "blacklist_description": "Cançons i artistes de la llista negra", "wait_for_download_to_finish": "Si us plau, esperi que acabi la descàrrega actual", - "download_lyrics": "Descarregar lletres amb les cançons", "desktop": "Escriptori", "close_behavior": "Comportament al tancar", "close": "Tancar", @@ -176,11 +175,9 @@ "step_2": "Pas 2", "step_2_steps": "1. Una vegada que hagi iniciat sessió, premi F12 o faci clic dret amb el ratolí > Inspeccionar per obrir les eines de desenvolulpador del navegador.\n2. Després vagi a la pestanya \"Application\" (Chrome, Edge, Brave, etc.) o \"Storage\" (Firefox, Palemoon, etc.)\n3. Vagi a la secció \"Cookies\" i després a la subsecció \"https://accounts.spotify.com\"", "step_3": "Pas 3", - "step_3_steps": "Copiï els valors de les Cookies \"sp_dc\" i \"sp_key\" (o sp_gaid)", "success_emoji": "Èxit! 🥳", "success_message": "Ara has iniciat sessió amb èxit al teu compte de Spotify. Bona feina!", "step_4": "Pas 4", - "step_4_steps": "Enganxi els valors coppiats de \"sp_dc\" i \"sp_key\" (o sp_gaid) en els camps respectius", "something_went_wrong": "Quelcom ha sortit malament", "piped_instance": "Instància del servidor Piped", "piped_description": "La instància del servidor Piped a utilitzar per la coincidència de cançons", @@ -250,8 +247,44 @@ "developers": "Desenvolupadors", "not_logged_in": "No ha iniciat sesió", "search_mode": "Mode de cerca", - "youtube_api_type": "Tipus d'API de YouTube", + "audio_source": "Font d'àudio", "ok": "OK", "failed_to_encrypt": "Error al xifrar", - "encryption_failed_warning": "Spotube utilitza el xifrado per emmagatzemar les seves dades de forma segura. Però ha fallat. Per tant, tornarà a un emmagatzament no segur\nSi estè utilizant Linux, asseguri's de tenir instal·lats els serveis secrets com gnome-keyring, kde-wallet i keepassxc" + "encryption_failed_warning": "Spotube utilitza el xifrado per emmagatzemar les seves dades de forma segura. Però ha fallat. Per tant, tornarà a un emmagatzament no segur\nSi estè utilizant Linux, asseguri's de tenir instal·lats els serveis secrets com gnome-keyring, kde-wallet i keepassxc", + "piped_api_down": "La API de Piped no està operativa", + "piped_down_error_instructions": "La instància de Piped {pipedInstance} no està operativa en aquest moment\n\nCanvieu la instància o canvieu el 'Tipus d'API' a l'API oficial de YouTube\n\nAssegureu-vos de reiniciar l'aplicació després del canvi", + "you_are_offline": "Actualment no teniu connexió a internet", + "connection_restored": "S'ha restablert la connexió a internet", + "use_system_title_bar": "Utilitza la barra de títol del sistema", + "querying_info": "Consultant informació...", + "update_playlist": "Actualitzar la llista de reproducció", + "update": "Actualitzar", + "crunching_results": "Processant resultats...", + "search_to_get_results": "Cerca per obtenir resultats", + "use_amoled_mode": "Utilitza el mode AMOLED", + "pitch_dark_theme": "Tema de dart negre intens", + "normalize_audio": "Normalitza l'àudio", + "change_cover": "Canvia la coberta", + "add_cover": "Afegeix una coberta", + "restore_defaults": "Restaura els valors per defecte", + "download_music_codec": "Descarrega el codec de música", + "streaming_music_codec": "Codec de música en streaming", + "login_with_lastfm": "Inicia la sessió amb Last.fm", + "connect": "Connecta", + "disconnect_lastfm": "Desconnecta de Last.fm", + "disconnect": "Desconnecta", + "username": "Nom d'usuari", + "password": "Contrasenya", + "login": "Inicia la sessió", + "login_with_your_lastfm": "Inicia la sessió amb el teu compte de Last.fm", + "scrobble_to_lastfm": "Scrobble a Last.fm", + "go_to_album": "Anar a l'àlbum", + "discord_rich_presence": "Presència rica de Discord", + "browse_all": "Navega per tot", + "genres": "Gèneres", + "explore_genres": "Explora els gèneres", + "step_3_steps": "Copia el valor de la cookie \"sp_dc\"", + "step_4_steps": "Pega el valor copiado de \"sp_dc\"", + "friends": "Amics", + "no_lyrics_available": "Ho sentim, no es poden trobar les lletres d'aquesta pista" } \ No newline at end of file diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d84333ec..ebaa0329 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -136,7 +136,6 @@ "skip_non_music": "Überspringe Nicht-Musik-Segmente (SponsorBlock)", "blacklist_description": "Gesperrte Titel und Künstler", "wait_for_download_to_finish": "Bitte warten Sie, bis der aktuelle Download abgeschlossen ist", - "download_lyrics": "Songtexte zusammen mit den Tracks herunterladen", "desktop": "Desktop", "close_behavior": "Verhalten beim Schließen", "close": "Schließen", @@ -176,11 +175,9 @@ "step_2": "Schritt 2", "step_2_steps": "1. Wenn du angemeldet bist, drücke F12 oder klicke mit der rechten Maustaste > Inspektion, um die Browser-Entwicklertools zu öffnen.\n2. Gehe dann zum \"Anwendungs\"-Tab (Chrome, Edge, Brave usw.) oder zum \"Storage\"-Tab (Firefox, Palemoon usw.)\n3. Gehe zum Abschnitt \"Cookies\" und dann zum Unterabschnitt \"https://accounts.spotify.com\"", "step_3": "Schritt 3", - "step_3_steps": "Kopiere die Werte der Cookies \"sp_dc\" und \"sp_key\" (oder sp_gaid)", "success_emoji": "Erfolg🥳", "success_message": "Jetzt bist du erfolgreich mit deinem Spotify-Konto angemeldet. Gut gemacht, Kumpel!", "step_4": "Schritt 4", - "step_4_steps": "Füge die kopierten Werte von \"sp_dc\" und \"sp_key\" (oder sp_gaid) in die entsprechenden Felder ein", "something_went_wrong": "Etwas ist schiefgelaufen", "piped_instance": "Piped-Serverinstanz", "piped_description": "Die Piped-Serverinstanz, die zur Titelzuordnung verwendet werden soll", @@ -250,9 +247,44 @@ "developers": "Entwickler", "not_logged_in": "Sie sind nicht angemeldet", "search_mode": "Suchmodus", - "youtube_api_type": "API-Typ", + "audio_source": "Audioquelle", "ok": "OK", "failed_to_encrypt": "Verschlüsselung fehlgeschlagen", "encryption_failed_warning": "Spotube verwendet Verschlüsselung, um Ihre Daten sicher zu speichern. Dies ist jedoch fehlgeschlagen. Daher wird es auf unsichere Speicherung zurückgreifen\nWenn Sie Linux verwenden, stellen Sie bitte sicher, dass Sie Secret-Services wie gnome-keyring, kde-wallet und keepassxc installiert haben", - "querying_info": "Abfrageinformationen..." + "querying_info": "Abfrageinformationen...", + "piped_api_down": "Die Piped API ist ausgefallen", + "piped_down_error_instructions": "Die Piped-Instanz {pipedInstance} ist derzeit nicht verfügbar\n\nEntweder ändern Sie die Instanz oder wechseln Sie den 'API-Typ' zur offiziellen YouTube API\n\nStellen Sie sicher, dass Sie die App nach der Änderung neu starten", + "you_are_offline": "Sie sind derzeit offline", + "connection_restored": "Ihre Internetverbindung wurde wiederhergestellt", + "use_system_title_bar": "System-Titelleiste verwenden", + "update_playlist": "Wiedergabeliste aktualisieren", + "update": "Aktualisieren", + "crunching_results": "Ergebnisse werden verarbeitet...", + "search_to_get_results": "Suche, um Ergebnisse zu erhalten", + "use_amoled_mode": "AMOLED-Modus verwenden", + "pitch_dark_theme": "Pitch Black Dart Theme", + "normalize_audio": "Audio normalisieren", + "change_cover": "Cover ändern", + "add_cover": "Cover hinzufügen", + "restore_defaults": "Standardeinstellungen wiederherstellen", + "download_music_codec": "Musik-Codec herunterladen", + "streaming_music_codec": "Streaming-Musik-Codec", + "login_with_lastfm": "Mit Last.fm anmelden", + "connect": "Verbinden", + "disconnect_lastfm": "Last.fm trennen", + "disconnect": "Trennen", + "username": "Benutzername", + "password": "Passwort", + "login": "Anmelden", + "login_with_your_lastfm": "Mit Ihrem Last.fm-Konto anmelden", + "scrobble_to_lastfm": "Auf Last.fm scrobbeln", + "go_to_album": "Zum Album gehen", + "discord_rich_presence": "Discord Rich Presence", + "browse_all": "Alles durchsuchen", + "genres": "Genres", + "explore_genres": "Genres erkunden", + "step_3_steps": "Kopiere den Wert des Cookies \"sp_dc\"", + "step_4_steps": "Füge den kopierten Wert von \"sp_dc\" ein", + "friends": "Freunde", + "no_lyrics_available": "Entschuldigung, Texte für diesen Track konnten nicht gefunden werden" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d1d723bd..0628cc43 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -24,8 +24,10 @@ "liked_tracks_description": "All your liked tracks", "create_playlist": "Create Playlist", "create_a_playlist": "Create a playlist", + "update_playlist": "Update playlist", "create": "Create", "cancel": "Cancel", + "update": "Update", "playlist_name": "Playlist Name", "name_of_playlist": "Name of the playlist", "description": "Description", @@ -136,7 +138,6 @@ "skip_non_music": "Skip non-music segments (SponsorBlock)", "blacklist_description": "Blacklisted tracks and artists", "wait_for_download_to_finish": "Please wait for the current download to finish", - "download_lyrics": "Download lyrics along with tracks", "desktop": "Desktop", "close_behavior": "Close Behavior", "close": "Close", @@ -176,11 +177,11 @@ "step_2": "Step 2", "step_2_steps": "1. Once you're logged in, press F12 or Mouse Right Click > Inspect to Open the Browser devtools.\n2. Then go the \"Application\" Tab (Chrome, Edge, Brave etc..) or \"Storage\" Tab (Firefox, Palemoon etc..)\n3. Go to the \"Cookies\" section then the \"https://accounts.spotify.com\" subsection", "step_3": "Step 3", - "step_3_steps": "Copy the values of \"sp_dc\" and \"sp_key\" (or sp_gaid) Cookies", + "step_3_steps": "Copy the value of \"sp_dc\" Cookie", "success_emoji": "Success🥳", - "success_message": "Now you're successfully Logged In with your Spotify account. Good Job, mate!", + "success_message": "Now you've successfully Logged in with your Spotify account. Good Job, mate!", "step_4": "Step 4", - "step_4_steps": "Paste the copied \"sp_dc\" and \"sp_key\" (or sp_gaid) values in the respective fields", + "step_4_steps": "Paste the copied \"sp_dc\" value", "something_went_wrong": "Something went wrong", "piped_instance": "Piped Server Instance", "piped_description": "The Piped server instance to use for track matching", @@ -250,11 +251,44 @@ "developers": "Developers", "not_logged_in": "You're not logged in", "search_mode": "Search Mode", - "youtube_api_type": "API Type", + "audio_source": "Audio Source", "ok": "Ok", "failed_to_encrypt": "Failed to encrypt", "encryption_failed_warning": "Spotube uses encryption to securely store your data. But failed to do so. So it'll fallback to insecure storage\nIf you're using linux, please make sure you've any secret-service (gnome-keyring, kde-wallet, keepassxc etc) installed", "querying_info": "Querying info...", "piped_api_down": "Piped API is down", - "piped_down_error_instructions": "The Piped instance {pipedInstance} is currently down\n\nEither change the instance or change the 'API type' to official YouTube API\n\nMake sure to restart the app after change" + "piped_down_error_instructions": "The Piped instance {pipedInstance} is currently down\n\nEither change the instance or change the 'API type' to official YouTube API\n\nMake sure to restart the app after change", + "you_are_offline": "You are currently offline", + "connection_restored": "Your internet connection was restored", + "use_system_title_bar": "Use system title bar", + "crunching_results": "Crunching results...", + "search_to_get_results": "Search to get results", + "use_amoled_mode": "Pitch black dark theme", + "pitch_dark_theme": "AMOLED Mode", + "normalize_audio": "Normalize audio", + "change_cover": "Change cover", + "add_cover": "Add cover", + "restore_defaults": "Restore defaults", + "download_music_codec": "Download music codec", + "streaming_music_codec": "Streaming music codec", + "login_with_lastfm": "Login with Last.fm", + "connect": "Connect", + "disconnect_lastfm": "Disconnect Last.fm", + "disconnect": "Disconnect", + "username": "Username", + "password": "Password", + "login": "Login", + "login_with_your_lastfm": "Login with your Last.fm account", + "scrobble_to_lastfm": "Scrobble to Last.fm", + "go_to_album": "Go to Album", + "discord_rich_presence": "Discord Rich Presence", + "browse_all": "Browse All", + "genres": "Genres", + "explore_genres": "Explore Genres", + "friends": "Friends", + "no_lyrics_available": "Sorry, unable find lyrics for this track", + "start_a_radio": "Start a Radio", + "how_to_start_radio": "How do you want to start the radio?", + "replace_queue_question": "Do you want to replace the current queue or append to it?", + "endless_playback": "Endless Playback" } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 101ba1b8..476056cb 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -136,7 +136,6 @@ "skip_non_music": "Omitir segmentos que no son música (SponsorBlock)", "blacklist_description": "Canciones y artistas en la lista negra", "wait_for_download_to_finish": "Por favor, espera a que termine la descarga actual", - "download_lyrics": "Descargar letras junto con las canciones", "desktop": "Escritorio", "close_behavior": "Comportamiento al cerrar", "close": "Cerrar", @@ -176,11 +175,9 @@ "step_2": "Paso 2", "step_2_steps": "1. Una vez que hayas iniciado sesión, presiona F12 o haz clic derecho con el ratón > Inspeccionar para abrir las herramientas de desarrollo del navegador.\n2. Luego ve a la pestaña \"Application\" (Chrome, Edge, Brave, etc.) o \"Storage\" (Firefox, Palemoon, etc.)\n3. Ve a la sección \"Cookies\" y luego la subsección \"https://accounts.spotify.com\"", "step_3": "Paso 3", - "step_3_steps": "Copia los valores de las Cookies \"sp_dc\" y \"sp_key\" (o sp_gaid)", "success_emoji": "¡Éxito! 🥳", "success_message": "Ahora has iniciado sesión con éxito en tu cuenta de Spotify. ¡Buen trabajo!", "step_4": "Paso 4", - "step_4_steps": "Pega los valores copiados de \"sp_dc\" y \"sp_key\" (o sp_gaid) en los campos respectivos", "something_went_wrong": "Algo salió mal", "piped_instance": "Instancia del servidor Piped", "piped_description": "La instancia del servidor Piped a utilizar para la coincidencia de pistas", @@ -250,9 +247,44 @@ "developers": "Desarrolladores", "not_logged_in": "No has iniciado sesión", "search_mode": "Modo de búsqueda", - "youtube_api_type": "Tipo de API de YouTube", + "audio_source": "Fuente de audio", "ok": "OK", "failed_to_encrypt": "Error al cifrar", "encryption_failed_warning": "Spotube utiliza el cifrado para almacenar sus datos de forma segura. Pero ha fallado. Por lo tanto, volverá a un almacenamiento no seguro\nSi está utilizando Linux, asegúrese de tener instalados servicios secretos como gnome-keyring, kde-wallet y keepassxc", - "querying_info": "Consultando información..." + "querying_info": "Consultando información...", + "piped_api_down": "La API de Piped no está disponible", + "piped_down_error_instructions": "La instancia de Piped {pipedInstance} no está funcionando en este momento\n\nCambie la instancia o cambie el 'Tipo de API' a la API oficial de YouTube\n\nAsegúrese de reiniciar la aplicación después del cambio", + "you_are_offline": "Actualmente estás sin conexión", + "connection_restored": "Se ha restablecido tu conexión a internet", + "use_system_title_bar": "Usar la barra de título del sistema", + "update_playlist": "Actualizar lista de reproducción", + "update": "Actualizar", + "crunching_results": "Procesando resultados...", + "search_to_get_results": "Buscar para obtener resultados", + "use_amoled_mode": "Usar modo AMOLED", + "pitch_dark_theme": "Tema oscuro de dart", + "normalize_audio": "Normalizar audio", + "change_cover": "Cambiar portada", + "add_cover": "Agregar portada", + "restore_defaults": "Restaurar valores predeterminados", + "download_music_codec": "Descargar códec de música", + "streaming_music_codec": "Códec de música en streaming", + "login_with_lastfm": "Iniciar sesión con Last.fm", + "connect": "Conectar", + "disconnect_lastfm": "Desconectar de Last.fm", + "disconnect": "Desconectar", + "username": "Nombre de usuario", + "password": "Contraseña", + "login": "Iniciar sesión", + "login_with_your_lastfm": "Iniciar sesión con tu cuenta de Last.fm", + "scrobble_to_lastfm": "Scrobble a Last.fm", + "go_to_album": "Ir al álbum", + "discord_rich_presence": "Presencia rica en Discord", + "browse_all": "Explorar todo", + "genres": "Géneros", + "explore_genres": "Explorar géneros", + "step_3_steps": "Copia el valor de la cookie \"sp_dc\"", + "step_4_steps": "Pega el valor copiado de \"sp_dc\"", + "friends": "Amigos", + "no_lyrics_available": "Lo siento, no se pueden encontrar las letras de esta pista" } \ No newline at end of file diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb new file mode 100644 index 00000000..3a2bcb4b --- /dev/null +++ b/lib/l10n/app_fa.arb @@ -0,0 +1,290 @@ +{ + "guest": "مهمان", + "browse": "مرور", + "search": "جستجو", + "library": "مجموعه", + "lyrics": "متن", + "settings": "تنظیمات", + "genre_categories_filter": "دسته ها یا ژانر ها را فیلتر کنید", + "genre": "ژانر", + "personalized": " شخصی سازی شده", + "featured": "ویژه", + "new_releases": "آخرین انتشارات", + "songs": "آهنگ ها", + "playing_track": "درحال پخش {track}", + "queue_clear_alert": "با این کار صف فعلی پاک می شود. {track_length} آهنگ از صف حذف میشود\n؟آیا ادامه میدهید", + "load_more": "بارگذاری بیشتر", + "playlists": "لیست های پخش", + "artists": "هنرمندان", + "albums": "آلبوم ها", + "tracks": "آهنگ ها", + "downloads": "بارگیری شده ها", + "filter_playlists": "لیست پخش خود را فیلتر کنید...", + "liked_tracks": "آهنگ های مورد علاقه", + "liked_tracks_description": "همه آهنگ های دوست داشتنی شما", + "create_playlist": "ساخت لیست پخش", + "create_a_playlist": "ساخت لیست پخش", + "update_playlist": "بروز کردن لیست پخش", + "create": "ساختن", + "cancel": "لغو", + "update": "بروز رسانی", + "playlist_name": "نام لیست پخش", + "name_of_playlist": "نام لیست پخش", + "description": "توضیحات", + "public": "عمومی", + "collaborative": "مبتنی بر همکاری", + "search_local_tracks": "جستجوی آهنگ های محلی...", + "play": "پخش", + "delete": "حذف", + "none": "هیچ کدام", + "sort_a_z": "مرتب سازی بر اساس حروف الفبا", + "sort_z_a": "مرتب سازی برعکس حروف الفبا", + "sort_artist": "مرتب سازی بر اساس هنرمند", + "sort_album": "مرتب سازی بر اساس آلبوم", + "sort_tracks": "مرتب سازی آهنگ ها", + "currently_downloading": "در حال بارگیری ({tracks_length})", + "cancel_all": "لغو همه", + "filter_artist": "فیلتر کردن هنرمند...", + "followers": "{followers} دنبال کننده", + "add_artist_to_blacklist": "اضافه کردن هنرمند به لیست سیاه", + "top_tracks": "بهترین آهنگ ها", + "fans_also_like": "طرفداران هم دوست داشتند", + "loading": "بارگزاری...", + "artist": "هنرمند", + "blacklisted": "در لیست سیاه قرار گرفته است", + "following": "دنبال کننده", + "follow": "دنبال کردن", + "artist_url_copied": "لینک هنرمند در کلیپ بورد کپی شد", + "added_to_queue": "تعداد {tracks} آهنگ به صف اضافه شد", + "filter_albums": "فیلتر کردن آلبوم...", + "synced": "همگام سازی شد", + "plain": "ساده", + "shuffle": "تصادفی", + "search_tracks": "جستجوی آهنگ ها...", + "released": "منتشر شده", + "error": "خطا {error}", + "title": "عنوان", + "time": "زمان", + "more_actions": "اقدامات بیشتر", + "download_count": "دانلود ({count})", + "add_count_to_playlist": "اضافه کردن ({count}) به لیست پخش", + "add_count_to_queue": "اضافه کردن ({count}) به صف", + "play_count_next": "پخش ({count}) بعدی", + "album": "آلبوم", + "copied_to_clipboard": "{data} در کلیپ بورد کپی شد", + "add_to_following_playlists": "اضافه کردن {track} به لیست پخش زیر", + "add": "اضافه کردن", + "added_track_to_queue": "{track} به لیست پخش اضافه شد", + "add_to_queue": "اضافه کردن به صف", + "track_will_play_next": "{track} پخش خواهد شد", + "play_next": "پخش آهنگ بعدی", + "removed_track_from_queue": "{track} از لیست پخش حذف شد", + "remove_from_queue": "از لیست پخش حذف شد", + "remove_from_favorites": "از علاقمندی ها حدف شد", + "save_as_favorite": "ذخیره به عنوان علاقمندی ها", + "add_to_playlist": "به لیست پخش اضافه کردن", + "remove_from_playlist": "از لیست پخش حذف کردن", + "add_to_blacklist": "به لیست سیاه اضافه کردن", + "remove_from_blacklist": "از لیست سیاه حذف کردن", + "share": "اشتراک گذاری", + "mini_player": "پخش کننده ", + "slide_to_seek": "برای جستجو عقب یا جلو بکشید", + "shuffle_playlist": "پخش تصادفی", + "unshuffle_playlist": "خاموش کردن پخش تصادفی", + "previous_track": "آهنگ قبلی", + "next_track": "آهنگ بعدی", + "pause_playback": "توقف آهنگ", + "resume_playback": "ادامه آهنگ", + "loop_track": "تکرار آهنگ", + "repeat_playlist": "تکرار لیست پخش", + "queue": "صف", + "alternative_track_sources": " منبع آهنگ را جاگزین کردن ", + "download_track": "بارگیری آهنگ", + "tracks_in_queue": "{tracks} آهنگ در صف", + "clear_all": "همه را حدف کن", + "show_hide_ui_on_hover": "نمایش/پنهان رابط کاربری در حالت شناور", + "always_on_top": "همیشه روشن", + "exit_mini_player": "از پخش کننده خارج شوید", + "download_location": "محل بارگیری", + "account": "حساب کاربری", + "login_with_spotify": "با حساب اسپوتیفای خود وارد شوید", + "connect_with_spotify": "متصل شدن به اسپوتیفای", + "logout": "خارج شدن", + "logout_of_this_account": "از حساب کاربری خارج شوید", + "language_region": "زبان و منطقه ", + "language": "زبان ", + "system_default": "پیش فرض سیستم", + "market_place_region": "منطقه", + "recommendation_country": "کشور های پیشنهادی", + "appearance": "ظاهر", + "layout_mode": "حالت چیدمان", + "override_layout_settings": "تنطیمات حالت واکنشگرای چیدمان را لغو کن", + "adaptive": "قابل تطبیق", + "compact": "فشرده", + "extended": "گسترده", + "theme": "تم", + "dark": "تاریک", + "light": "روشن", + "system": "سیستم", + "accent_color": "رنگ تاکیدی", + "sync_album_color": "هنگام سازی رنگ البوم", + "sync_album_color_description": "از رنگ البوم هنرمند به عنوان رنگ تاکیدی استفاده میکند", + "playback": "پخش", + "audio_quality": "کیفیت صدا", + "high": "زیاد", + "low": "کم", + "pre_download_play": "دانلود و پخش کنید", + "pre_download_play_description": "به جای پخش جریانی صدا، بایت ها را دانلود کنید و به جای آن پخش کنید (برای کاربران با پهنای باند بالاتر توصیه می شود)", + "skip_non_music": "رد شدن از پخش های غیر موسیقی (SponsorBlock)", + "blacklist_description": "آهنگ ها و هنرمند های در لیست سیاه", + "wait_for_download_to_finish": "لطفا صبر کنید تا دانلود آهنگ جاری تمام شود", + "desktop": "میز کار", + "close_behavior": "رفتار نزدیک", + "close": "بستن", + "minimize_to_tray": "پتجره را کوچک کنید", + "show_tray_icon": "نماد را نمایش بده", + "about": "درباره", + "u_love_spotube": "دوست داریدSpotubeما میدانیم شما ", + "check_for_updates": "بروزرسانی را بررسی کنید", + "about_spotube": "Spotube درباره", + "blacklist": "لیست سیاه", + "please_sponsor": "لطفا کمک/حمایت کنید", + "spotube_description": "یک برنامه سبک و مولتی پلتفرم و رایگان برای همه استSpotube", + "version": "نسخه", + "build_number": "شماره ساخت", + "founder": "بنیانگذار", + "repository": "مخزن", + "bug_issues": "اشکال+مسایل", + "made_with": "🇧🇩ساخته شده با ❤️ در بنگلادش", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "مجوز", + "add_spotify_credentials": "برای شروع اعتبار اسپوتیفای خود را اضافه کنید", + "credentials_will_not_be_shared_disclaimer": "نگران نباشید هیچ کدوما از اعتبارات شما جمع اوری نمیشود یا با کسی اشتراک گزاشته نمیشود", + "know_how_to_login": "نمیدانی چگونه این کار را انجام بدهی؟", + "follow_step_by_step_guide": "راهنما را گام به گام دنبال کنید", + "spotify_cookie": "Spotify {name} کوکی", + "cookie_name_cookie": "{name} کوکی", + "fill_in_all_fields": "لطفا تمام فلید ها را پر کنید", + "submit": "ثبت", + "exit": "خروج", + "previous": "قبلی", + "next": "بعدی ", + "done": "اتمام", + "step_1": "گام 1", + "first_go_to": "اول برو داخل ", + "login_if_not_logged_in": "و اگر وارد نشده اید، وارد/ثبت نام کنید", + "step_2": "گام 2", + "step_2_steps": "1. پس از ورود به سیستم، F12 یا کلیک راست ماوس > Inspect را فشار دهید تا ابزارهای توسعه مرورگر باز شود..\n2. سپس به تب \"Application\" (Chrome, Edge, Brave etc..) یا \"Storage\" Tab (Firefox, Palemoon etc..)\n3. به قسمت \"Cookies\" و به پخش \"https://accounts.spotify.com\" بروید", + "step_3": "گام 3", + "success_emoji": "موفقیت🥳", + "success_message": "اکنون با موفقیت با حساب اسپوتیفای خود وارد شده اید", + "step_4": "مرحله 4", + "something_went_wrong": "اشتباهی رخ داده", + "piped_instance": "مشکل در ارتباط با سرور", + "piped_description": "مشکل در ارتباط با سرور در دریافت آهنگ ها", + "piped_warning": "برخی از آنها ممکن است خوب کارنکند.بنابراین با مسولیت خود استفاده کنید", + "generate_playlist": "ساخت لیست پخش", + "track_exists": "آهنگ {track} وجود دارد", + "replace_downloaded_tracks": "همه ی آهنگ های دانلود شده را جایگزین کنید", + "skip_download_tracks": "همه ی آهنگ های دانلود شده را رد کنید", + "do_you_want_to_replace": "ایا میخواهید آهنگ های موجود جایگزین کنید؟", + "replace": "جایگزین کردن", + "skip": "رد کردن", + "select_up_to_count_type": "انتخاب کنید تا {count} {type}", + "select_genres": "ژانر ها را انتخاب کنید", + "add_genres": "ژانر را اطافه کنید", + "country": "کشور", + "number_of_tracks_generate": "تعداد آهنگ های ساخته شده", + "acousticness": "آکوستیک", + "danceability": "رقصیدن", + "energy": "انرژی", + "instrumentalness": "بی کلام", + "liveness": "حس زندگی", + "loudness": "صدای بلند", + "speechiness": "دکلمه", + "valence": "ظرفیت", + "popularity": "محبوبیت", + "key": "کلید", + "duration": "مدت زمان (ثانیه)", + "tempo": "تمپو (BPM)", + "mode": "حالت", + "time_signature": "امضای زمان", + "short": "کوتاه", + "medium": "متوسط", + "long": "بلند", + "min": "حداقل", + "max": "حداکثر", + "target": "هدف", + "moderate": "حد وسط", + "deselect_all": "همه را لغو انتخاب کنید", + "select_all": "همه را انتخاب کنید", + "are_you_sure": "ایا مطمعن هستید؟", + "generating_playlist": " درحال ایجاد لیست پخش سفارشی شما", + "selected_count_tracks": "آهنگ انتخاب شده {count}", + "download_warning": "اگر همه ی آهنگ ها را به صورت انبو دانلود کنید به وضوح در حال دزدی موسقی هستید و در حال اسیب وارد کردن به جامه ی خلاق هنری می باشید .امیدوارم که از این موضوع اگاه باشید .همیشه سعی کنید به کار سخت هنرمند اخترام بگذارید.", + "download_ip_ban_warning": "راستی آی پی شما می تواند در یوتوب به دلیل درخواست های دانلود بیش از حد معمول مسدود شود. بلوک آی پی به این معنی است که شما نمی توانید از یوتوب (حتی اگر وارد سیستم شده باشید) حداقل 2-3 ماه از آن دستگاه آی پی استفاده کنید. و Spotube هیچ مسئولیتی در صورت وقوع این اتفاق ندارد", + "by_clicking_accept_terms": "با کلیک بر روی قبول با شرایط زیر موافقت می کنید:", + "download_agreement_1": "من میدانم در حال دزدی هستم .من بد هستم", + "download_agreement_2": "من هر کجا ک بتوانم از هنرمندان حمایت میکنم اما این کارا فقط به دلیل اینکه توانایی مالی ندارم انجام میدهم", + "download_agreement_3": "من کاملا میدانم که از طرف یوتوب بلاک میشم و این برنامه و مالکان را مسول این حادثه نمیدانم.", + "decline": "قبول نکردن", + "accept": "قبول", + "details": "جزئیات", + "youtube": "یوتیوب", + "channel": "کانال", + "likes": "دوست داشتن", + "dislikes": "دوست نداشتن", + "views": "بازدید", + "streamUrl": "لینک اثر", + "stop": "توقف", + "sort_newest": "مرتب سازی بر اساس جدید ترین اضافه شده", + "sort_oldest": "مرتب سازی بر اساس قدیمی ترین اضافه شده", + "sleep_timer": "زمان خواب", + "mins": "{minutes} دقیقه", + "hours": "{hours} ساعت", + "hour": "{hours} ساعت", + "custom_hours": "ساعت سفارشی", + "logs": "رسید خطا", + "developers": "توسعه دهنده ها", + "not_logged_in": "شما وارد نشده اید ", + "search_mode": "حالت جستجو", + "audio_source": "منبع صدا", + "ok": "باشد", + "failed_to_encrypt": "رمز گذاری نشده", + "encryption_failed_warning": "Spotube از رمزگذاری برای ذخیره ایمن داده های شما استفاده می کند. اما موفق به انجام این کار نشد. بنابراین به فضای ذخیره‌سازی ناامن تبدیل می‌شود\nاگر از لینوکس استفاده می‌کنید، لطفاً مطمئن شوید که سرویس مخفی (gnome-keyring، kde-wallet، keepassxc و غیره) را نصب کرده‌اید.", + "querying_info": "جستجو درباره ", + "piped_api_down": "ایراد در سرور", + "piped_down_error_instructions": "به دلیل مشکل {pipedInstance} ارتباط با سرور مقدور نیست\n\nنمونه را تغییر دهید یا «نوع API» را به API رسمی YouTube تغییر دهید\n\nحتماً پس از تغییر، برنامه را دوباره راه‌اندازی کنید", + "you_are_offline": "شما در حال حاضر افلاین هستید ", + "connection_restored": "اتصال به اینترنت شما بازیابی شد ", + "use_system_title_bar": "از نوار عنوان سیستم استفاده کنید ", + "crunching_results": "نتایج خرد کردن...", + "search_to_get_results": "جستجو کنید تا به نتیجه برسید", + "use_amoled_mode": "استفاده از حالت AMOLED", + "pitch_dark_theme": "تم تیره دارت", + "normalize_audio": "نرمال کردن صدا", + "change_cover": "تغییر جلد", + "add_cover": "افزودن جلد", + "restore_defaults": "بازیابی پیش فرض ها", + "download_music_codec": "دانلود کدک موسیقی", + "streaming_music_codec": "کدک موسیقی استریمینگ", + "login_with_lastfm": "ورود با Last.fm", + "connect": "اتصال", + "disconnect_lastfm": "قطع ارتباط با Last.fm", + "disconnect": "قطع ارتباط", + "username": "نام کاربری", + "password": "رمز عبور", + "login": "ورود", + "login_with_your_lastfm": "ورود با حساب کاربری Last.fm خود", + "scrobble_to_lastfm": "Scrobble به Last.fm", + "go_to_album": "رفتن به آلبوم", + "discord_rich_presence": "حضور غنی دیسکورد", + "browse_all": "مرور همه", + "genres": "ژانرها", + "explore_genres": "استکشاف ژانرها", + "step_3_steps": "مقدار کوکی \"sp_dc\" را کپی کنید", + "step_4_steps": "مقدار کپی شده \"sp_dc\" را الصاق کنید", + "friends": "دوستان", + "no_lyrics_available": "متاسفیم، قادر به یافتن متن این قطعه نیستیم" +} \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 302e4740..5c24d0fe 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -136,7 +136,6 @@ "skip_non_music": "Ignorer les segments non musicaux (SponsorBlock)", "blacklist_description": "Pistes et artistes en liste noire", "wait_for_download_to_finish": "Veuillez attendre la fin du téléchargement en cours", - "download_lyrics": "Télécharger les paroles avec les pistes", "desktop": "Bureau", "close_behavior": "Comportement de fermeture", "close": "Fermer", @@ -176,11 +175,9 @@ "step_2": "Étape 2", "step_2_steps": "1. Une fois connecté, appuyez sur F12 ou clic droit de la souris > Inspecter pour ouvrir les outils de développement du navigateur.\n2. Ensuite, allez dans l'onglet \"Application\" (Chrome, Edge, Brave, etc.) ou l'onglet \"Stockage\" (Firefox, Palemoon, etc.)\n3. Allez dans la section \"Cookies\", puis dans la sous-section \"https://accounts.spotify.com\"", "step_3": "Étape 3", - "step_3_steps": "Copiez les valeurs des cookies \"sp_dc\" et \"sp_key\" (ou sp_gaid)", "success_emoji": "Succès🥳", "success_message": "Vous êtes maintenant connecté avec succès à votre compte Spotify. Bon travail, mon ami!", "step_4": "Étape 4", - "step_4_steps": "Collez les valeurs copiées de \"sp_dc\" et \"sp_key\" (ou sp_gaid) dans les champs respectifs", "something_went_wrong": "Quelque chose s'est mal passé", "piped_instance": "Instance pipée", "piped_description": "L'instance de serveur Piped à utiliser pour la correspondance des pistes", @@ -250,9 +247,44 @@ "developers": "Développeurs", "not_logged_in": "Vous n'êtes pas connecté(e)", "search_mode": "Mode de recherche", - "youtube_api_type": "Type d'API", + "audio_source": "Source audio", "ok": "OK", "failed_to_encrypt": "Échec de la cryptage", "encryption_failed_warning": "Spotube utilise le cryptage pour stocker vos données en toute sécurité. Mais cela a échoué. Il basculera donc vers un stockage non sécurisé\nSi vous utilisez Linux, assurez-vous d'avoir installé des services secrets tels que gnome-keyring, kde-wallet et keepassxc", - "querying_info": "Interrogation des info..." + "querying_info": "Interrogation des info...", + "piped_api_down": "L'API Piped est hors service", + "piped_down_error_instructions": "L'instance Piped {pipedInstance} est actuellement indisponible\n\nChangez soit l'instance, soit le 'Type d'API' pour utiliser l'API officielle de YouTube\n\nN'oubliez pas de redémarrer l'application après la modification", + "you_are_offline": "Vous êtes actuellement hors ligne", + "connection_restored": "Votre connexion internet a été rétablie", + "use_system_title_bar": "Utiliser la barre de titre système", + "update_playlist": "Mettre à jour la playlist", + "update": "Mettre à jour", + "crunching_results": "Traitement des résultats...", + "search_to_get_results": "Recherche pour obtenir des résultats", + "use_amoled_mode": "Utiliser le mode AMOLED", + "pitch_dark_theme": "Thème Dart noir intense", + "normalize_audio": "Normaliser l'audio", + "change_cover": "Changer de couverture", + "add_cover": "Ajouter une couverture", + "restore_defaults": "Restaurer les valeurs par défaut", + "download_music_codec": "Télécharger le codec musical", + "streaming_music_codec": "Codec de musique en streaming", + "login_with_lastfm": "Se connecter avec Last.fm", + "connect": "Connecter", + "disconnect_lastfm": "Déconnecter de Last.fm", + "disconnect": "Déconnecter", + "username": "Nom d'utilisateur", + "password": "Mot de passe", + "login": "Se connecter", + "login_with_your_lastfm": "Se connecter avec votre compte Last.fm", + "scrobble_to_lastfm": "Scrobble à Last.fm", + "go_to_album": "Aller à l'album", + "discord_rich_presence": "Présence riche de Discord", + "browse_all": "Parcourir tout", + "genres": "Genres", + "explore_genres": "Explorer les genres", + "step_3_steps": "Copiez la valeur du cookie \"sp_dc\"", + "step_4_steps": "Collez la valeur copiée de \"sp_dc\"", + "friends": "Amis", + "no_lyrics_available": "Désolé, impossible de trouver les paroles de cette piste" } \ No newline at end of file diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index e60286f4..1cf62398 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -136,7 +136,6 @@ "skip_non_music": "गाने के अलावा सेगमेंट्स को छोड़ें (स्पॉन्सरब्लॉक)", "blacklist_description": "ब्लैकलिस्ट में शामिल ट्रैक और कलाकार", "wait_for_download_to_finish": "वर्तमान डाउनलोड समाप्त होने तक कृपया प्रतीक्षा करें", - "download_lyrics": "गानों के साथ लिरिक्स डाउनलोड करें", "desktop": "डेस्कटॉप", "close_behavior": "बंद करने का व्यवहार", "close": "बंद करें", @@ -176,11 +175,9 @@ "step_2": "2 चरण", "step_2_steps": "1. जब आप लॉगिन हो जाएँ, तो F12 दबाएं या माउस राइट क्लिक> निरीक्षण करें ताकि ब्राउज़र डेवटूल्स खुलें।\n2. फिर ब्राउज़र के \"एप्लिकेशन\" टैब (Chrome, Edge, Brave आदि) या \"स्टोरेज\" टैब (Firefox, Palemoon आदि) में जाएं\n3. \"कुकीज़\" अनुभाग में जाएं फिर \"https: //accounts.spotify.com\" उप-अनुभाग में जाएं", "step_3": "स्टेप 3", - "step_3_steps": "\"sp_dc\" और \"sp_key\" (या sp_gaid) कुकीज़ के मान कॉपी करें", "success_emoji": "सफलता🥳", "success_message": "अब आप अपने स्पॉटिफाई अकाउंट से सफलतापूर्वक लॉगइन हो गए हैं। अच्छा काम किया!", "step_4": "स्टेप 4", - "step_4_steps": "कॉपी की गई \"sp_dc\" और \"sp_key\" (या sp_gaid) मानों को संबंधित फील्ड में पेस्ट करें", "something_went_wrong": "कुछ गलत हो गया", "piped_instance": "पाइप्ड सर्वर", "piped_description": "पाइप किए गए सर्वर", @@ -250,9 +247,44 @@ "developers": "डेवलपर्स", "not_logged_in": "आप लॉग इन नहीं हैं", "search_mode": "खोज मोड", - "youtube_api_type": "API प्रकार", + "audio_source": "ऑडियो स्रोत", "ok": "ठीक है", "failed_to_encrypt": "एन्क्रिप्ट करने में विफल रहा", "encryption_failed_warning": "Spotube आपके डेटा को सुरक्षित रूप से स्टोर करने के लिए एन्क्रिप्शन का उपयोग करता है। लेकिन इसमें विफल रहा। इसलिए, यह असुरक्षित स्टोरेज पर फॉलबैक करेगा\nयदि आप Linux का उपयोग कर रहे हैं, तो कृपया सुनिश्चित करें कि आपके पास gnome-keyring, kde-wallet, keepassxc आदि जैसी कोई सीक्रेट-सर्विस इंस्टॉल की गई है", - "querying_info": "जानकारी प्राप्त करना" + "querying_info": "जानकारी प्राप्त करना", + "piped_api_down": "पाइप्ड एपीआई डाउन है", + "piped_down_error_instructions": "पाइप्ड इंस्टेंस {pipedInstance} वर्तमान में डाउन है\n\nइंस्टेंस बदलें या 'एपीआई प्रकार' को आधिकृत YouTube एपीआई में बदलें\n\nपरिवर्तन के बाद ऐप को फिर से चालने की सुनिश्चित करें", + "you_are_offline": "आप वर्तमान में ऑफ़लाइन हैं", + "connection_restored": "आपका इंटरनेट कनेक्शन बहाल हो गया है", + "use_system_title_bar": "सिस्टम शीर्षक पट्टी का उपयोग करें", + "update_playlist": "प्लेलिस्ट अपडेट करें", + "update": "अपडेट करें", + "crunching_results": "परिणाम को प्रसंस्कृत किया जा रहा है...", + "search_to_get_results": "परिणाम प्राप्त करने के लिए खोजें", + "use_amoled_mode": "AMOLED मोड का उपयोग करें", + "pitch_dark_theme": "पिच ब्लैक डार्ट थीम", + "normalize_audio": "ऑडियो को सामान्य करें", + "change_cover": "कवर बदलें", + "add_cover": "कवर जोड़ें", + "restore_defaults": "डिफ़ॉल्ट सेटिंग्स को बहाल करें", + "download_music_codec": "संगीत कोडेक डाउनलोड करें", + "streaming_music_codec": "स्ट्रीमिंग संगीत कोडेक", + "login_with_lastfm": "Last.fm से लॉगिन करें", + "connect": "कनेक्ट करें", + "disconnect_lastfm": "Last.fm से डिस्कनेक्ट करें", + "disconnect": "डिस्कनेक्ट करें", + "username": "उपयोगकर्ता नाम", + "password": "पासवर्ड", + "login": "लॉग इन करें", + "login_with_your_lastfm": "अपने Last.fm अकाउंट से लॉगिन करें", + "scrobble_to_lastfm": "Last.fm पर स्क्रॉबल करें", + "go_to_album": "एल्बम पर जाएं", + "discord_rich_presence": "डिस्कॉर्ड रिच प्रेजेंस", + "browse_all": "सभी को ब्राउज़ करें", + "genres": "शैलियाँ", + "explore_genres": "शैलियों का अन्वेषण करें", + "step_3_steps": "\"sp_dc\" कुकी का मूल्य कॉपी करें", + "step_4_steps": "कॉपी किए गए \"sp_dc\" मूल्य को पेस्ट करें", + "friends": "दोस्त", + "no_lyrics_available": "क्षमा करें, इस ट्रैक के लिए गाने नहीं मिल सके" } \ No newline at end of file diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb new file mode 100644 index 00000000..ec76b914 --- /dev/null +++ b/lib/l10n/app_it.arb @@ -0,0 +1,291 @@ +{ + "guest": "Ospite", + "browse": "Sfoglia", + "search": "Cerca", + "library": "Libreria", + "lyrics": "Testi", + "settings": "Impostazioni", + "genre_categories_filter": "Filtra categorie e generi...", + "genre": "Genere", + "personalized": "Personalizzato", + "featured": "In evidenza", + "new_releases": "Novità", + "songs": "Canzoni", + "playing_track": "Riproduzione {track}", + "queue_clear_alert": "Questo cancellerà la coda corrente. {track_length} tracce saranno rimosse\nVuoi continuare?", + "load_more": "Carica altro", + "playlists": "Playlist", + "artists": "Artisti", + "albums": "Album", + "tracks": "Tracce", + "downloads": "Downloads", + "filter_playlists": "Filtra le tue playlist...", + "liked_tracks": "Tracce piaciute", + "liked_tracks_description": "Tutte le tracce piaciute", + "create_playlist": "Crea Playlist", + "create_a_playlist": "Crea una playlist", + "update_playlist": "Aggiorna playlist", + "create": "Crea", + "cancel": "Annulla", + "update": "Aggiorna", + "playlist_name": "Nome Playlist", + "name_of_playlist": "Nome della playlist", + "description": "Descrizione", + "public": "Pubblico", + "collaborative": "Collaborativo", + "search_local_tracks": "Cerca tracce locali...", + "play": "Riproduci", + "delete": "Cancella", + "none": "Nessuno", + "sort_a_z": "Ordina dalla A-Z", + "sort_z_a": "Ordina dalla Z-A", + "sort_artist": "Ordina per Artista", + "sort_album": "Ordina per Album", + "sort_tracks": "Ordina tracce", + "currently_downloading": "Attualmente in Download ({tracks_length})", + "cancel_all": "Annulla Tutto", + "filter_artist": "Filtra artisti...", + "followers": "{followers} Seguaci", + "add_artist_to_blacklist": "Aggiungi artista alla lista nera", + "top_tracks": "Tracce Top", + "fans_also_like": "Ai fan piace anche", + "loading": "Caricamento...", + "artist": "Artista", + "blacklisted": "In lista nera", + "following": "Seguendo", + "follow": "Segui", + "artist_url_copied": "URL artista copiato negli appunti", + "added_to_queue": "Aggiunto {tracks} tracce alla coda", + "filter_albums": "Filtra album...", + "synced": "Sincronizzato", + "plain": "Semplice", + "shuffle": "Casuale", + "search_tracks": "Cerca tracce...", + "released": "Rilasciato", + "error": "Errore {error}", + "title": "Titolo", + "time": "Durata", + "more_actions": "Più azioni", + "download_count": "Scaricato ({count})", + "add_count_to_playlist": "Aggiungi ({count}) alla playlist", + "add_count_to_queue": "Aggiungi ({count}) alla Coda", + "play_count_next": "Riproduci ({count}) prossime", + "album": "Album", + "copied_to_clipboard": "Copiato {data} negli appunti", + "add_to_following_playlists": "Aggiungi {track} nelle seguenti Playlist", + "add": "Aggiungi", + "added_track_to_queue": "Aggiunto {track} alla coda", + "add_to_queue": "Aggiungi alla coda", + "track_will_play_next": "in seguito sarà riprodotta {track}", + "play_next": "Riproduci prossimo", + "removed_track_from_queue": "Rimosso {track} dalla coda", + "remove_from_queue": "Rimuovi dalla coda", + "remove_from_favorites": "Rimuovi dai preferiti", + "save_as_favorite": "Salva come preferito", + "add_to_playlist": "Aggiungi alla playlist", + "remove_from_playlist": "Rimuovi dalla playlist", + "add_to_blacklist": "Aggiungi alla blacklist", + "remove_from_blacklist": "Rimuovi dalla blacklist", + "share": "Condividi", + "mini_player": "Mini Riproduttore", + "slide_to_seek": "Scorri per cercare avanti o indietro", + "shuffle_playlist": "Playlist casuale", + "unshuffle_playlist": "Ordina playlist", + "previous_track": "Traccia precedente", + "next_track": "Traccia successiva", + "pause_playback": "Pausa Playback", + "resume_playback": "Riprendi Playback", + "loop_track": "Cicla traccia", + "repeat_playlist": "Ripeti playlist", + "queue": "Coda", + "alternative_track_sources": "Sorgenti traccia alternative", + "download_track": "Scarica traccia", + "tracks_in_queue": "{tracks} tracce in coda", + "clear_all": "Cancella tutto", + "show_hide_ui_on_hover": "Mostra/Nascondi UI al passaggio", + "always_on_top": "Sempre in cima", + "exit_mini_player": "Esci da Mini player", + "download_location": "Cartella di scarico", + "account": "Account", + "login_with_spotify": "Login con il tuo account Spotify", + "connect_with_spotify": "Connetti con Spotify", + "logout": "Esci", + "logout_of_this_account": "Esci da questo account", + "language_region": "Lingua & Regione", + "language": "Lingua", + "system_default": "Default sistema", + "market_place_region": "Regione del mercato", + "recommendation_country": "Paese Raccomandato", + "appearance": "Aspetto", + "layout_mode": "Modalità Layout", + "override_layout_settings": "Sovrascrivi le impostazioni del layout responsivo", + "adaptive": "Adattiva", + "compact": "Compatta", + "extended": "Estesa", + "theme": "Tema", + "dark": "Scuro", + "light": "Chiaro", + "system": "Sistema", + "accent_color": "Colore accento", + "sync_album_color": "Syncronizza colore album", + "sync_album_color_description": "Usa il colore dominante della copertina dell'album come colore accento", + "playback": "Riproduzione", + "audio_quality": "Qualità Audio", + "high": "Alta", + "low": "Bassa", + "pre_download_play": "Pre-scarica e riproduci", + "pre_download_play_description": "Anzi che effettuare lo stream dell'audio, scarica invece i byte e li riproduce (raccomandato per gli utenti con banda più alta)", + "skip_non_music": "Salta i segmenti non di musica (SponsorBlock)", + "blacklist_description": "Tracce e artisti in blacklist", + "wait_for_download_to_finish": "Prego attendere che lo scaricamento corrente finisca", + "desktop": "Desktop", + "close_behavior": "Comportamento Chiusura", + "close": "Chiudi", + "minimize_to_tray": "Minimizza in tray", + "show_tray_icon": "Mostra icona in tray di sistema", + "about": "A proposito di", + "u_love_spotube": "Sappiamo che ami Spotube", + "check_for_updates": "Controlla aggiornamenti", + "about_spotube": "A proposito di Spotube", + "blacklist": "Blacklist", + "please_sponsor": "Per favore sponsorizza/dona", + "spotube_description": "Spotube, un client spotify gratis per tutti, multipiattaforma e leggero", + "version": "Versione", + "build_number": "Numero Build", + "founder": "Fondatore", + "repository": "Repository", + "bug_issues": "Bug+Problemi", + "made_with": "Fatto con ❤️ in Bangladesh🇧🇩", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "Licenza", + "add_spotify_credentials": "Aggiungi le tue credenziali spotify per iniziare", + "credentials_will_not_be_shared_disclaimer": "Non ti preoccupare, le tue credenziali non saranno inviate o condivise con nessuno", + "know_how_to_login": "Non sai come farlo?", + "follow_step_by_step_guide": "Segui la guida passo-passo", + "spotify_cookie": "Cookie Spotify {name}", + "cookie_name_cookie": "Cookie {name}", + "fill_in_all_fields": "Inserire tutti i campi", + "submit": "Invia", + "exit": "Esci", + "previous": "Precedente", + "next": "Prossimo", + "done": "Finito", + "step_1": "Passo 1", + "first_go_to": "Prim, vai a", + "login_if_not_logged_in": "ed effettua il login o iscrizione se non sei già acceduto", + "step_2": "Passo 2", + "step_2_steps": "1. Quando sei acceduto premi F12 o premi il tasto destro del Mouse > Ispeziona per aprire gli strumenti di sviluppo del browser.\n2. Vai quindi nel tab \"Applicazione\" (Chrome, Edge, Brave etc..) o tab \"Archiviazione\" (Firefox, Palemoon etc..)\n3. Vai nella sezione \"Cookies\" quindi nella sezione \"https://accounts.spotify.com\"", + "step_3": "Passo 3", + "success_emoji": "Successo🥳", + "success_message": "Ora hai correttamente effettuato il login al tuo account Spotify. Bel lavoro, amico!", + "step_4": "Passo 4", + "something_went_wrong": "Qualcosa è andato storto", + "piped_instance": "Istanza Server Piped", + "piped_description": "L'istanza server Piped da usare per il match della tracccia", + "piped_warning": "Alcune di queste non funzioneranno benen. Usa quindi a tuo rischio", + "generate_playlist": "Genera Playlist", + "track_exists": "La traccia {track} esiste già", + "replace_downloaded_tracks": "Sostituisci tutte le tracce scaricate", + "skip_download_tracks": "Salta lo scaricamento di tutte le tracce scaricate", + "do_you_want_to_replace": "Vuoi sovrascrivere la traccia esistente??", + "replace": "Sovrascrivi", + "skip": "Salta", + "select_up_to_count_type": "Seleziona fino a {count} {type}", + "select_genres": "Seleziona Generi", + "add_genres": "Aggiungi Generi", + "country": "Paese", + "number_of_tracks_generate": "Nnumero di tracce da generare", + "acousticness": "Acustica", + "danceability": "Ballabilità", + "energy": "Energia", + "instrumentalness": "Strumentalità", + "liveness": "Vitalità", + "loudness": "Sonorità", + "speechiness": "Loquacità", + "valence": "Valenza", + "popularity": "Popolarità", + "key": "Chiave", + "duration": "Durata (s)", + "tempo": "Tempo (BPM)", + "mode": "Modo", + "time_signature": "Indicazione di tempo", + "short": "Corta", + "medium": "Media", + "long": "Lunga", + "min": "Min", + "max": "Max", + "target": "Obiettivo", + "moderate": "Moderato", + "deselect_all": "Deseleziona Tutto", + "select_all": "Seleziona Tutto", + "are_you_sure": "Sei certo?", + "generating_playlist": "Generazione delle tue playlist custom...", + "selected_count_tracks": "{count} tracce selezionate", + "download_warning": "Se scarichi tutte le Tracce in massa stai chiaramente piratando Musica e causando un danno alla società creativa della Musica. Spero che tu sia cosciente di questo. Cerca di rispettare e supportare sempre il duro lavoro degli Artisti", + "download_ip_ban_warning": "A proposito, il tuo IP può essere bloccato da YouTube per il numero di richieste di download eccessive rispetto la norma. Il blocco IP significa che non puoi usare YoutTube (anche hai effettuato l'accesso) per almeno 2-3 mesi dal dispositivo con questo IP. Spotube non ha responsabilità se questo dovesse accadere", + "by_clicking_accept_terms": "Cliccando su 'accetta' concordi con i seguenti termini:", + "download_agreement_1": "So che sto piratando Musica. Sono cattivo", + "download_agreement_2": "Supporterò l'Artista come potrò e sto facendo questo solo perchè non ho denaro per acquistare il suo prodotto dell'ingegno", + "download_agreement_3": "Sono completamente cosciente che il mio IP può essere bloccato da YouTube & non riterrò responsabili Spotube o i suoi autori/contributori per ogni inconveniente causato dalla mia azione corrente", + "decline": "Declino", + "accept": "Accetto", + "details": "Dettagli", + "youtube": "YouTube", + "channel": "Canale", + "likes": "Mi Piace", + "dislikes": "Non Mi Piace", + "views": "Viste", + "streamUrl": "URL dello streaming", + "stop": "Stop", + "sort_newest": "Ordina per nuovi aggiunti", + "sort_oldest": "Ordina per aggiunta più vecchia", + "sleep_timer": "Timer Dormire", + "mins": "{minutes} Minuti", + "hours": "{hours} Ore", + "hour": "{hours} Ora", + "custom_hours": "Orari Personalizzati", + "logs": "Log", + "developers": "Sviluppatori", + "not_logged_in": "Non hai effettuato l'accesso", + "search_mode": "Modalità Ricerca", + "youtube_api_type": "Tipo API", + "ok": "Ok", + "failed_to_encrypt": "Criptazione fallita", + "encryption_failed_warning": "Spotube usa la criptazione per memorizzare in modo sicuro i dati. Ma ha fallito a farlo. Passerà quindi in ripiego alla memorizzazione non siscura\nSe stai usando Linux assicurati di avere un servizio di segretezza installato (gnome-keyring, kde-wallet, keepassxc etc)", + "querying_info": "Richiesta informazioni...", + "piped_api_down": "Le Piped API non funzionano", + "piped_down_error_instructions": "L'istanza di Piped {pipedInstance} è correntemente offline\n\nCambia istanza o cambia 'Tipo API' alle API ufficiali YouTube\n\nAssicurati di riavviare l'app dopo il cambio", + "you_are_offline": "Sei correntemente offline", + "connection_restored": "Connessione ad internet ripristinata", + "use_system_title_bar": "Usa la barra del titolo di sistema", + "crunching_results": "Elaborazione risultati...", + "search_to_get_results": "Cerca per ottenere risultati", + "use_amoled_mode": "Usa modalità AMOLED", + "pitch_dark_theme": "Tema nero profondo", + "normalize_audio": "Normalizza audio", + "change_cover": "Cambia copertina", + "add_cover": "Aggiungi copertina", + "restore_defaults": "Ripristina default", + "download_music_codec": "Codec musicale scaricamento", + "streaming_music_codec": "Codec musicale streaming", + "login_with_lastfm": "Accesso a Last.fm", + "connect": "Connetti", + "disconnect_lastfm": "Disconnetti Last.fm", + "disconnect": "Disconnetti", + "username": "Nome utente", + "password": "Password", + "login": "Accesso", + "login_with_your_lastfm": "Accedi con il tuo account Last.fm", + "scrobble_to_lastfm": "Invia a Last.fm", + "audio_source": "Fonte audio", + "go_to_album": "Vai all'album", + "discord_rich_presence": "Presenza ricca di Discord", + "browse_all": "Esplora tutto", + "genres": "Generi", + "explore_genres": "Esplora generi", + "step_3_steps": "Copia il valore del cookie \"sp_dc\"", + "step_4_steps": "Incolla il valore copiato di \"sp_dc\"", + "friends": "Amici", + "no_lyrics_available": "Spiacente, impossibile trovare il testo di questa traccia" +} \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 9efd59ba..d16708d7 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -136,7 +136,6 @@ "skip_non_music": "音楽でない部分をスキップ (SponsorBlock)", "blacklist_description": "曲とアーティストのブラックリスト", "wait_for_download_to_finish": "現在のダウンロードが完了するまでお待ちください", - "download_lyrics": "曲と共に歌詞もダウンロード", "desktop": "デスクトップ", "close_behavior": "閉じた時の動作", "close": "閉じる", @@ -176,11 +175,9 @@ "step_2": "ステップ 2", "step_2_steps": "1. ログインしたら、F12を押すか、マウス右クリック > 調査(検証)でブラウザの開発者ツール (devtools) を開きます。\n2. アプリケーション (Application) タブ (Chrome, Edge, Brave など) またはストレージタブ (Firefox, Palemoon など)\n3. Cookies 欄を選択し、https://accounts.spotify.com の枝を選びます", "step_3": "ステップ 3", - "step_3_steps": "sp_dc と sp_key (または or sp_gaid) の値 (Value) をコピーします", "success_emoji": "成功🥳", "success_message": "アカウントへのログインに成功しました。よくできました!", "step_4": "ステップ 4", - "step_4_steps": "コピーした sp_dc と sp_key (または or sp_gaid) の値をそれぞれの入力欄に貼り付けます", "something_went_wrong": "何か誤りがあります", "piped_instance": "Piped サーバーのインスタンス", "piped_description": "曲の一致に使う Piped サーバーのインスタンス", @@ -250,9 +247,44 @@ "developers": "開発", "not_logged_in": "ログインしていません", "search_mode": "検索モード", - "youtube_api_type": "APIの種類", + "audio_source": "音声ソース", "ok": "分かりました", "failed_to_encrypt": "暗号化に失敗しました", "encryption_failed_warning": "Spotubeはデータを安全に保存するために暗号化を使用しています。しかし、失敗しました。したがって、安全でないストレージにフォールバックします\nLinuxを使用している場合は、gnome-keyring、kde-wallet、keepassxcなどのシークレットサービスがインストールされていることを確認してください", - "querying_info": "情報を取得中..." + "querying_info": "情報を取得中...", + "piped_api_down": "Piped APIがダウンしています", + "piped_down_error_instructions": "Pipedインスタンス{pipedInstance}は現在ダウンしています\n\nインスタンスを変更するか、'APIタイプ'を公式のYouTube APIに変更してください\n\n変更後にアプリを再起動してください", + "you_are_offline": "現在、オフラインです", + "connection_restored": "インターネット接続が復旧しました", + "use_system_title_bar": "システムタイトルバーを使用する", + "update_playlist": "プレイリストを更新", + "update": "更新", + "crunching_results": "結果を処理中...", + "search_to_get_results": "結果を取得するために検索", + "use_amoled_mode": "AMOLEDモードを使用する", + "pitch_dark_theme": "ピッチブラックダートテーマ", + "normalize_audio": "オーディオを正規化する", + "change_cover": "カバーを変更する", + "add_cover": "カバーを追加する", + "restore_defaults": "デフォルト値に戻す", + "download_music_codec": "音楽コーデックをダウンロードする", + "streaming_music_codec": "ストリーミング音楽コーデック", + "login_with_lastfm": "Last.fmでログインする", + "connect": "接続する", + "disconnect_lastfm": "Last.fmから切断する", + "disconnect": "切断する", + "username": "ユーザー名", + "password": "パスワード", + "login": "ログインする", + "login_with_your_lastfm": "あなたのLast.fmアカウントでログインする", + "scrobble_to_lastfm": "Last.fmにスクロブルする", + "go_to_album": "アルバムに移動", + "discord_rich_presence": "ディスコードリッチプレゼンス", + "browse_all": "すべてを閲覧", + "genres": "ジャンル", + "explore_genres": "ジャンルを探索", + "step_3_steps": "\"sp_dc\" Cookieの値をコピー", + "step_4_steps": "コピーした\"sp_dc\"の値を貼り付け", + "friends": "友達", + "no_lyrics_available": "申し訳ありませんが、このトラックの歌詞を見つけることができません" } \ No newline at end of file diff --git a/lib/l10n/app_ne.arb b/lib/l10n/app_ne.arb new file mode 100644 index 00000000..2d20fc9c --- /dev/null +++ b/lib/l10n/app_ne.arb @@ -0,0 +1,290 @@ +{ + "guest": "अतिथि", + "browse": "ब्राउज़ गर्नुहोस्", + "search": "खोजी गर्नुहोस्", + "library": "पुस्तकालय", + "lyrics": "गीतको शब्द", + "settings": "सेटिङ", + "genre_categories_filter": "शैली वा शैलीहरू फिल्टर गर्नुहोस्...", + "genre": "शैली", + "personalized": "व्यक्तिगत", + "featured": "विशेष", + "new_releases": "नयाँ रिलिज", + "songs": "गीतहरू", + "playing_track": "{track} बज्यो", + "queue_clear_alert": "यो हालको कतारलाई हटाउँछ। {track_length} ट्र्याकहरू हटाईन्छ\nके तपाईं जारी राख्न चाहनुहुन्छ?", + "load_more": "थप लोड गर्नुहोस्", + "playlists": "प्लेलिस्टहरू", + "artists": "कलाकारहरू", + "albums": "आल्बमहरू", + "tracks": "ट्र्याकहरू", + "downloads": "डाउनलोडहरू", + "filter_playlists": "तपाईंको प्लेलिस्टहरू फिल्टर गर्नुहोस्...", + "liked_tracks": "मन परेका ट्र्याकहरू", + "liked_tracks_description": "तपाईंको मन परेका सबै ट्र्याकहरू", + "create_playlist": "प्लेलिस्ट बनाउनुहोस्", + "create_a_playlist": "प्लेलिस्ट बनाउनुहोस्", + "update_playlist": "प्लेलिस्ट अपडेट गर्नुहोस्", + "create": "बनाउनुहोस्", + "cancel": "रद्द गर्नुहोस्", + "update": "अपडेट गर्नुहोस्", + "playlist_name": "प्लेलिस्टको नाम", + "name_of_playlist": "प्लेलिस्टको नाम", + "description": "विवरण", + "public": "सार्वजनिक", + "collaborative": "सहकारी", + "search_local_tracks": "स्थानीय ट्र्याकहरू खोजी गर्नुहोस्...", + "play": "बजाउनुहोस्", + "delete": "मेटाउनुहोस्", + "none": "कुनै पनि होइन", + "sort_a_z": "A-Zमा क्रमबद्ध गर्नुहोस्", + "sort_z_a": "Z-Aमा क्रमबद्ध गर्नुहोस्", + "sort_artist": "कलाकारबाट क्रमबद्ध गर्नुहोस्", + "sort_album": "आल्बमबाट क्रमबद्ध गर्नुहोस्", + "sort_tracks": "ट्र्याकहरूलाई क्रमबद्ध गर्नुहोस्", + "currently_downloading": "हाल डाउनलोड गर्दैछ ({tracks_length})", + "cancel_all": "सब रद्द गर्नुहोस्", + "filter_artist": "कलाकारहरूलाई फिल्टर गर्नुहोस्...", + "followers": "{followers} अनुयायीहरू", + "add_artist_to_blacklist": "कलाकारलाई कालोसूचीमा थप्नुहोस्", + "top_tracks": "शीर्ष ट्र्याकहरू", + "fans_also_like": "अनुयायीहरू पनि लाइक गर्छन्", + "loading": "लोड हुँदैछ...", + "artist": "कलाकार", + "blacklisted": "कालोसूचीमा", + "following": "फल्लो गर्दै", + "follow": "फल्लो गर्नुहोस्", + "artist_url_copied": "कलाकार URL क्लिपबोर्डमा प्रतिलिपि गरिएको छ", + "added_to_queue": "{tracks} ट्र्याकहरूलाई कतारमा थपिएको छ", + "filter_albums": "आल्बमहरूलाई फिल्टर गर्नुहोस्...", + "synced": "सिङ्क गरिएको", + "plain": "साधा", + "shuffle": "शफल", + "search_tracks": "ट्र्याकहरू खोजी गर्नुहोस्...", + "released": "रिलिज गरिएको", + "error": "त्रुटि {error}", + "title": "शीर्षक", + "time": "समय", + "more_actions": "थप कार्यहरू", + "download_count": "डाउनलोड ({count})", + "add_count_to_playlist": "प्लेलिस्टमा थप्नुहोस् ({count})", + "add_count_to_queue": "कतारमा थप्नुहोस् ({count})", + "play_count_next": "प्लेगरी गर्नुहोस् ({count})", + "album": "आल्बम", + "copied_to_clipboard": "{data} क्लिपबोर्डमा प्रतिलिपि गरिएको छ", + "add_to_following_playlists": "{track} लाई तलका प्लेलिस्टमा थप्नुहोस्", + "add": "थप्नुहोस्", + "added_track_to_queue": "{track} लाई कतारमा थपिएको छ", + "add_to_queue": "कतारमा थप्नुहोस्", + "track_will_play_next": "{track} अरूलाई पहिलोमा बज्नेछ", + "play_next": "पछिबजाउनुहोस्", + "removed_track_from_queue": "{track} लाई कतारबाट हटाइएको छ", + "remove_from_queue": "कतारबाट हटाउनुहोस्", + "remove_from_favorites": "पसन्दीदामा बाट हटाउनुहोस्", + "save_as_favorite": "पसन्दीदा बनाउनुहोस्", + "add_to_playlist": "प्लेलिस्टमा थप्नुहोस्", + "remove_from_playlist": "प्लेलिस्टबाट हटाउनुहोस्", + "add_to_blacklist": "कालोसूचीमा थप्नुहोस्", + "remove_from_blacklist": "कालोसूचीबाट हटाउनुहोस्", + "share": "साझा गर्नुहोस्", + "mini_player": "मिनि प्लेयर", + "slide_to_seek": "अगाडि वा पछाडि खोजी गर्नका लागि स्लाइड गर्नुहोस्", + "shuffle_playlist": "प्लेलिस्ट शफल गर्नुहोस्", + "unshuffle_playlist": "प्लेलिस्ट शफल नगर्नुहोस्", + "previous_track": "पूर्व ट्र्याक", + "next_track": "अरू ट्र्याक", + "pause_playback": "प्लेब्याक रोक्नुहोस्", + "resume_playback": "प्लेब्याक पुनः सुरु गर्नुहोस्", + "loop_track": "ट्र्याकलाई दोहोरोपट्टी बजाउनुहोस्", + "repeat_playlist": "प्लेलिस्ट पुनः बजाउनुहोस्", + "queue": "कतार", + "alternative_track_sources": "वैकल्पिक ट्र्याक स्रोतहरू", + "download_track": "ट्र्याक डाउनलोड गर्नुहोस्", + "tracks_in_queue": "कतारमा {tracks} ट्र्याकहरू", + "clear_all": "सब मेटाउनुहोस्", + "show_hide_ui_on_hover": "हवर गरेपछि UI देखाउनुहोस्/लुकाउनुहोस्", + "always_on_top": "सधैं टपमा राख्नुहोस्", + "exit_mini_player": "मिनि प्लेयर बाट बाहिर निस्कनुहोस्", + "download_location": "डाउनलोड स्थान", + "account": "खाता", + "login_with_spotify": "तपाईंको Spotify खातासँग लगइन गर्नुहोस्", + "connect_with_spotify": "Spotify सँग जडान गर्नुहोस्", + "logout": "बाहिर निस्कनुहोस्", + "logout_of_this_account": "यो खाताबाट बाहिर निस्कनुहोस्", + "language_region": "भाषा र क्षेत्र", + "language": "भाषा", + "system_default": "सिस्टम पूर्वनिर्धारित", + "market_place_region": "बजार स्थान", + "recommendation_country": "सिफारिस गरिएको देश", + "appearance": "दृष्टिकोण", + "layout_mode": "लेआउट मोड", + "override_layout_settings": "अनुकूलित प्रतिकृयात्मक लेआउट मोड सेटिङ्गहरू", + "adaptive": "अनुकूलित", + "compact": "संकुचित", + "extended": "बढाइएको", + "theme": "थिम", + "dark": "गाढा", + "light": "प्रकाश", + "system": "सिस्टम", + "accent_color": "एक्सेन्ट रङ्ग", + "sync_album_color": "एल्बम रङ्ग सिङ्क गर्नुहोस्", + "sync_album_color_description": "एल्बम कला को प्रमुख रङ्गलाई एक्सेन्ट रङ्गको रूपमा प्रयोग गर्दछ", + "playback": "प्लेब्याक", + "audio_quality": "आडियो गुणस्तर", + "high": "उच्च", + "low": "न्यून", + "pre_download_play": "पूर्व-डाउनलोड र प्ले गर्नुहोस्", + "pre_download_play_description": "आडियो स्ट्रिम गर्नु नगरी बाइटहरू डाउनलोड गरी बजाउँछ (उच्च ब्यान्डविथ उपयोगकर्ताहरूको लागि सिफारिस गरिएको)", + "skip_non_music": "गीतहरू बाहेक कुनै अनुष्ठान छोड्नुहोस् (स्पन्सरब्लक)", + "blacklist_description": "कालोसूची गीत र कलाकारहरू", + "wait_for_download_to_finish": "कृपया हालको डाउनलोड समाप्त हुन लागि पर्खनुहोस्", + "desktop": "डेस्कटप", + "close_behavior": "बन्द व्यवहार", + "close": "बन्द गर्नुहोस्", + "minimize_to_tray": "ट्रेमा कम गर्नुहोस्", + "show_tray_icon": "सिस्टम ट्रे आइकन देखाउनुहोस्", + "about": "बारेमा", + "u_love_spotube": "हामीले थाहा पारेका छौं तपाईंलाई Spotube मन पर्छ", + "check_for_updates": "अपडेटहरूको लागि जाँच गर्नुहोस्", + "about_spotube": "Spotube को बारेमा", + "blacklist": "कालोसूची", + "please_sponsor": "कृपया स्पन्सर/डोनेट गर्नुहोस्", + "spotube_description": "Spotube, एक हल्का, समृद्ध, स्वतन्त्र Spotify क्लाइयन", + "version": "संस्करण", + "build_number": "निर्माण नम्बर", + "founder": "संस्थापक", + "repository": "पुनरावलोकन स्थल", + "bug_issues": "त्रुटि + समस्याहरू", + "made_with": "❤️ 2021-2024 बाट बनाइएको", + "kingkor_roy_tirtho": "किङ्कोर राय तिर्थो", + "copyright": "© 2021-{current_year} किङ्कोर राय तिर्थो", + "license": "लाइसेन्स", + "add_spotify_credentials": "सुरु हुनका लागि तपाईंको स्पटिफाई क्रेडेन्शियल थप्नुहोस्", + "credentials_will_not_be_shared_disclaimer": "चिन्ता नगर्नुहोस्, तपाईंको कुनै पनि क्रेडेन्शियलहरूले कसैले संग्रह वा साझा गर्नेछैन", + "know_how_to_login": "कसरी लगिन गर्ने भन्ने थाहा छैन?", + "follow_step_by_step_guide": "चरणबद्ध मार्गदर्शनमा साथी बनाउनुहोस्", + "spotify_cookie": "Spotify {name} कुकी", + "cookie_name_cookie": "{name} कुकी", + "fill_in_all_fields": "कृपया सबै क्षेत्रहरू भर्नुहोस्", + "submit": "पेश गर्नुहोस्", + "exit": "बाहिर निस्कनुहोस्", + "previous": "पूर्ववत", + "next": "अरू", + "done": "गरिएको", + "step_1": "कदम 1", + "first_go_to": "पहिलो, जानुहोस्", + "login_if_not_logged_in": "र लगइन/साइनअप गर्नुहोस् जुन तपाईंले लगइन गरेनन्", + "step_2": "कदम 2", + "step_2_steps": "1. एकबार तपाईं लगइन गरे पछि, F12 थिच्नुहोस् वा माउस राइट क्लिक गर्नुहोस् > इन्स्पेक्ट गर्नुहोस् भने ब्राउजर डेभटुलहरू खुलाउनका लागि।\n2. तपाईंको \"एप्लिकेसन\" ट्याबमा जानुहोस् (Chrome, Edge, Brave इत्यादि) वा \"स्टोरेज\" ट्याबमा जानुहोस् (Firefox, Palemoon इत्यादि)\n3. तपाईंको इन्सेक्ट गरेको ब्राउजर डेभटुलहरूमा \"कुकीहरू\" खण्डमा जानुहोस् अनि \"https://accounts.spotify.com\" उपकोणमा जानुहोस्", + "step_3": "कदम 3", + "step_3_steps": "\"sp_dc\" र \"sp_key\" (वा sp_gaid) कुकीहरूको मानहरू प्रतिलिपि गर्नुहोस्", + "success_emoji": "सफलता 🥳", + "success_message": "हाम्रो सानो भाइ, अब तपाईं सफलतापूर्वक आफ्नो Spotify खातामा लगइन गरेका छौं। राम्रो काम गरेको!", + "step_4": "कदम 4", + "step_4_steps": "प्रतिलिपि गरेको \"sp_dc\" र \"sp_key\" (वा sp_gaid) मानहरूलाई आफ्नो ठाउँमा पेस्ट गर्नुहोस्", + "something_went_wrong": "केहि गल्ति भएको छ", + "piped_instance": "पाइपड सर्भर इन्स्ट्यान्स", + "piped_description": "गीत मिलाउको लागि प्रयोग गर्ने पाइपड सर्भर इन्स्ट्यान्स", + "piped_warning": "तिनीहरूमध्ये केहि ठिक गर्न सक्छ। यसलाई आफ्नो जोखिममा प्रयोग गर्नुहोस्", + "generate_playlist": "प्लेलिस्ट बनाउनुहोस्", + "track_exists": "ट्र्याक {track} पहिले नै छ", + "replace_downloaded_tracks": "सबै डाउनलोड गरिएका ट्र्याकहरूलाई परिवर्तन गर्नुहोस्", + "skip_download_tracks": "सबै डाउनलोड गरिएका ट्र्याकहरूलाई छोड्नुहोस्", + "do_you_want_to_replace": "के तपाईंले वर्तमान ट्र्याकलाई परिवर्तन गर्न चाहनुहुन्छ?", + "replace": "परिवर्तन गर्नुहोस्", + "skip": "छोड्नुहोस्", + "select_up_to_count_type": "{count} {type} सम्म चयन गर्नुहोस्", + "select_genres": "जनरहरू चयन गर्नुहोस्", + "add_genres": "जनरहरू थप्नुहोस्", + "country": "देश", + "number_of_tracks_generate": "बनाउनका लागि ट्र्याकहरूको संख्या", + "acousticness": "एकोस्टिकनेस", + "danceability": "नृत्यक्षमता", + "energy": "ऊर्जा", + "instrumentalness": "साजा रहेकोता", + "liveness": "प्राणिकता", + "loudness": "शोर", + "speechiness": "भाषण", + "valence": "मानसिक स्वभाव", + "popularity": "लोकप्रियता", + "key": "कुञ्जी", + "duration": "अवधि (सेकेण्ड)", + "tempo": "गति (बीपीएम)", + "mode": "मोड", + "time_signature": "समय हस्ताक्षर", + "short": "सानो", + "medium": "मध्यम", + "long": "लामो", + "min": "न्यून", + "max": "अधिक", + "target": "लक्ष्य", + "moderate": "मध्यस्थ", + "deselect_all": "सबै छान्नुहोस्", + "select_all": "सबै चयन गर्नुहोस्", + "are_you_sure": "के तपाईं सुनिश्चित हुनुहुन्छ?", + "generating_playlist": "तपाईंको विशेष प्लेलिस्ट बनाइएको छ...", + "selected_count_tracks": "{count} ट्र्याकहरू छन् चयन गरिएका", + "download_warning": "यदि तपाईं सबै ट्र्याकहरूलाई बल्कमा डाउनलोड गर्छनु हो भने तपाईं स्पष्ट रूपमा साङ्गीत चोरी गरिरहेका छन् र यो साङ्गीतको रचनात्मक समाजलाई क्षति पनि पुर्याउँछ। उमेराइएको छ कि तपाईं यसको बारेमा जागरूक छिनुहुन्छ। सधैं, कला गर्दै र कलाकारको कडा परम्परा समर्थन गर्दै आइन्छ।", + "download_ip_ban_warning": "बितिएका डाउनलोड अनुरोधहरूका कारण तपाईंको आइपीले YouTube मा ब्लक हुन सक्छ। आइपी ब्लक भनेको कम्तीमा 2-3 महिनासम्म तपाईं त्यस आइपी यन्त्रबाट YouTube प्रयोग गर्न सक्नुहुन्छ। र यदि यो हुँदैछ भने स्पट्यूबले यसलाई कसैले गरेको बारेमा कुनै दायित्व लिन्छैन।", + "by_clicking_accept_terms": "'स्वीकृत' गरेर तपाईं निम्नलिखित निर्वाचन गर्दैछिन्:", + "download_agreement_1": "म मन्ने छु कि म साङ्गीत चोरी गरिरहेको छु। म बुरो हुँ", + "download_agreement_2": "म कहिल्यै कहिल्यै तिनीहरूलाई समर्थन गर्नेछु र म यो तिनीहरूको कला किन्ने पैसा छैन भने मा मात्र यो गरेको छु", + "download_agreement_3": "म पूरा रूपमा जान्छु कि मेरो आइपी YouTube मा ब्लक हुन सक्छ र म मन्छेहरूले मेरो चासोबाट भएको कुनै दुर्घटनामा स्पट्यूब वा तिनीहरूको मालिकहरू/सहयोगीहरूलाई दायित्वी ठान्छुँभन्ने पूर्ण जानकारी छैन", + "decline": "अस्वीकृत", + "accept": "स्वीकृत", + "details": "विवरण", + "youtube": "YouTube", + "channel": "च्यानल", + "likes": "लाइकहरू", + "dislikes": "असुनुहरू", + "views": "हेरिएको", + "streamUrl": "स्ट्रिम यूआरएल", + "stop": "रोक्नुहोस्", + "sort_newest": "नयाँ थपिएकोमा क्रमबद्ध गर्नुहोस्", + "sort_oldest": "पुरानो थपिएकोमा क्रमबद्ध गर्नुहोस्", + "sleep_timer": "सुत्ने टाइमर", + "mins": "{minutes} मिनेटहरू", + "hours": "{hours} घण्टाहरू", + "hour": "{hours} घण्टा", + "custom_hours": "कस्टम घण्टाहरू", + "logs": "लगहरू", + "developers": "डेभेलपर्स", + "not_logged_in": "तपाईंले लगइन गरेका छैनौं", + "search_mode": "खोज मोड", + "audio_source": "अडियो स्रोत", + "ok": "ठिक छ", + "failed_to_encrypt": "एन्क्रिप्ट गर्न सकिएन", + "encryption_failed_warning": "स्पट्यूबले तपाईंको डेटा सुरक्षित रूपमा स्टोर गर्नका लागि एन्क्रिप्ट गर्न खोजेको छ। तर यसले गरेको छैन। यसले असुरक्षित स्टोरेजमा फल्लब्याक गर्दछ\nयदि तपाईंले लिनक्स प्रयोग गरिरहेका छन् भने कृपया सुनिश्चित गर्नुहोस् कि तपाईंले कुनै सीक्रेट-सर्भिस (गोनोम-किरिङ, केडीइ-वालेट, किपासेक्ससि इत्यादि) इन्स्टल गरेका छौं", + "querying_info": "जानकारी हेर्दै...", + "piped_api_down": "पाइपड एपीआई डाउन छ", + "piped_down_error_instructions": "पाइपड इन्स्ट्यान्स {pipedInstance} हाल डाउन छ\n\nजीसनै इन्स्ट्यान्स परिवर्तन गर्नुहोस् वा 'एपीआई प्रकार' लाइ YouTube आफिसियल एपीआईमा परिवर्तन गर्नुहोस्\n\nपरिवर्तनपछि एप्लिकेसन पुन: सुरु गर्नुहोस्", + "you_are_offline": "तपाईं वर्तमान अफलाइन हुनुहुन्छ", + "connection_restored": "तपाईंको इन्टरनेट कनेक्सन पुन: स्थापित भएको छ", + "use_system_title_bar": "सिस्टम शीर्षक पट्टी प्रयोग गर्नुहोस्", + "crunching_results": "परिणामहरू कपालबाट पीस्दै...", + "search_to_get_results": "परिणामहरू प्राप्त गर्नका लागि खोज्नुहोस्", + "use_amoled_mode": "कृष्ण ब्ल्याक गाढा थिम प्रयोग गर्नुहोस्", + "pitch_dark_theme": "एमोलेड मोड", + "normalize_audio": "अडियो सामान्य गर्नुहोस्", + "change_cover": "कवर परिवर्तन गर्नुहोस्", + "add_cover": "कवर थप्नुहोस्", + "restore_defaults": "पूर्वनिर्धारितहरू पुनः स्थापित गर्नुहोस्", + "download_music_codec": "साङ्गीत कोडेक डाउनलोड गर्नुहोस्", + "streaming_music_codec": "स्ट्रिमिङ साङ्गीत कोडेक", + "login_with_lastfm": "लास्ट.एफ.एम सँग लगइन गर्नुहोस्", + "connect": "जडान गर्नुहोस्", + "disconnect_lastfm": "लास्ट.एफ.एम डिसकनेक्ट गर्नुहोस्", + "disconnect": "डिसकनेक्ट", + "username": "प्रयोगकर्ता नाम", + "password": "पासवर्ड", + "login": "लगइन", + "login_with_your_lastfm": "तपाईंको लास्ट.एफ.एम खातामा लगइन गर्नुहोस्", + "scrobble_to_lastfm": "लास्ट.एफ.एम मा स्क्रबल गर्नुहोस्", + "go_to_album": "आल्बममा जानुहोस्", + "discord_rich_presence": "डिस्कर्ड धनी उपस्थिति", + "browse_all": "सबै हेर्नुहोस्", + "genres": "शैलीहरू", + "explore_genres": "शैलीहरू अन्वेषण गर्नुहोस्", + "friends": "साथीहरू", + "no_lyrics_available": "क्षमा गर्दैछौं, यस ट्र्याकका लागि गीतका शब्दहरू फेला परेन" +} \ No newline at end of file diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb new file mode 100644 index 00000000..3bece8be --- /dev/null +++ b/lib/l10n/app_nl.arb @@ -0,0 +1,290 @@ +{ + "guest": "Gast", + "browse": "Bladeren", + "search": "Zoeken", + "library": "Bibliotheek", + "lyrics": "Teksten", + "settings": "Instellingen", + "genre_categories_filter": "Categorieën of genres filteren…", + "genre": "Genre", + "personalized": "Gepersonaliseerd", + "featured": "Aanbevolen", + "new_releases": "Nieuwe uitgaves", + "songs": "Liedjes", + "playing_track": "{track} afspelen", + "queue_clear_alert": "Dit zal de huidige wachtrij wissen. {track_length} nummers worden verwijderd\nWil je doorgaan?", + "load_more": "Meer laden", + "playlists": "Afspeellijsten", + "artists": "Artiesten", + "albums": "Albums", + "tracks": "Nummers", + "downloads": "Downloads", + "filter_playlists": "Afspeellijsten filteren…", + "liked_tracks": "Geliefde tracks", + "liked_tracks_description": "Al je favoriete nummers", + "create_playlist": "Afspeellijst aanmaken", + "create_a_playlist": "Een afspeellijst aanmaken", + "update_playlist": "Afspeellijst bijwerken", + "create": "Aanmaken", + "cancel": "Annuleren", + "update": "Bijwerken", + "playlist_name": "Naam afspeellijst", + "name_of_playlist": "Naam van de afspeellijst", + "description": "Beschrijving", + "public": "Openbaar", + "collaborative": "Samenwerkend", + "search_local_tracks": "Lokale nummers zoeken…", + "play": "Afspelen", + "delete": "Wissen", + "none": "Geen", + "sort_a_z": "Sorteren op A-Z", + "sort_z_a": "Sorteren op Z-A", + "sort_artist": "Sorteren op artiest", + "sort_album": "Sorteren op album", + "sort_tracks": "Nummers sorteren", + "currently_downloading": "Momenteel aan het downloaden ({tracks_length})", + "cancel_all": "Alle annuleren", + "filter_artist": "Artiesten filteren…", + "followers": "{followers} volgers", + "add_artist_to_blacklist": "Artiest toevoegen aan zwarte lijst", + "top_tracks": "Topsporen", + "fans_also_like": "Liefhebbers willen ook", + "loading": "Laden…", + "artist": "Artiest", + "blacklisted": "Zwarte lijst", + "following": "Volgen", + "follow": "Volgen", + "artist_url_copied": "URL artiest gekopieerd naar klembord", + "added_to_queue": "{tracks} nummers toegevoegd aan wachtrij", + "filter_albums": "Albums filteren…", + "synced": "Gesynchroniseerd", + "plain": "Eenvoudig", + "shuffle": "Willekeurig", + "search_tracks": "Nummers zoeken…", + "released": "Uitgegeven", + "error": "Fout {error}", + "title": "Titel", + "time": "Tijd", + "more_actions": "Meer acties", + "download_count": "({count}) downloads", + "add_count_to_playlist": "({count}) aan afspeellijst toevoegen", + "add_count_to_queue": "({count}) aan wachtrij toevoegen", + "play_count_next": "Volgende ({count}) afspelen", + "album": "Album", + "copied_to_clipboard": "{data} naar klembord gekopieerd", + "add_to_following_playlists": "{track} aan volgende afspeellijsten toevoegen", + "add": "Toevoegen", + "added_track_to_queue": "{track} aan wachtrij toegevoegd", + "add_to_queue": "Toevoegen aan wachtrij", + "track_will_play_next": "{track} wordt hierna afgespeeld", + "play_next": "Volgende afspelen", + "removed_track_from_queue": "{track} van wachtrij verwijderd", + "remove_from_queue": "Van wachtrij verwijderen", + "remove_from_favorites": "Van favorieten verwijderen", + "save_as_favorite": "Opslaan als favoriet", + "add_to_playlist": "Aan afspeellijst toevoegen", + "remove_from_playlist": "Van afspeellijst verwijderen", + "add_to_blacklist": "Aan zwarte lijst toevoegen", + "remove_from_blacklist": "Van zwarte lijst verwijderen", + "share": "Delen", + "mini_player": "Minispeler", + "slide_to_seek": "Schuiven om vooruit of achteruit te zoeken", + "shuffle_playlist": "Afspeellijst schuifelen", + "unshuffle_playlist": "Afspeellijst onschuifelen", + "previous_track": "Vorige nummer", + "next_track": "Volgende nummer", + "pause_playback": "Afspelen pauzeren", + "resume_playback": "Afspelen hervatten", + "loop_track": "Nummer herhalen", + "repeat_playlist": "Afspeellijst herhalen", + "queue": "Wachtrij", + "alternative_track_sources": "Alternatieve nummerbronnen", + "download_track": "Nummer downloaden", + "tracks_in_queue": "{tracks} nummers in wachtrij", + "clear_all": "Alles wissen", + "show_hide_ui_on_hover": "UI tonen/verbergen bij zweven", + "always_on_top": "Altijd bovenaan", + "exit_mini_player": "Minispeler afsluiten", + "download_location": "Downloadlocatie", + "account": "Account", + "login_with_spotify": "Inloggen met je Spotify-account", + "connect_with_spotify": "Verbinden met Spotify", + "logout": "Afmelden", + "logout_of_this_account": "Afmelden van dit account", + "language_region": "Taal & regio", + "language": "Taal", + "system_default": "Systeemstandaard", + "market_place_region": "Marktplaats-regio", + "recommendation_country": "Aanbeveling Land", + "appearance": "Uiterlijk", + "layout_mode": "Opmaakmodus", + "override_layout_settings": "Instellingen voor responsieve opmaakmodus opheffen", + "adaptive": "Adaptief", + "compact": "Compact", + "extended": "Uitgebreid", + "theme": "Thema", + "dark": "Donker", + "light": "Licht", + "system": "Systeem", + "accent_color": "Accentkleur", + "sync_album_color": "Albumkleur synchroniseren", + "sync_album_color_description": "Gebruikt de overheersende kleur van het album als accentkleur", + "playback": "Weergave", + "audio_quality": "Audiokwaliteit", + "high": "Hoog", + "low": "Laag", + "pre_download_play": "Vooraf downloaden en afspelen", + "pre_download_play_description": "In plaats van audio te streamen, kun je bytes downloaden en afspelen (aanbevolen voor gebruikers met een hogere bandbreedte)", + "skip_non_music": "Niet-muzieksegmenten overslaan (SponsorBlock)", + "blacklist_description": "Nummers en artiesten op de zwarte lijst", + "wait_for_download_to_finish": "Wacht tot de huidige download is voltooid", + "desktop": "Bureaublad", + "close_behavior": "Sluitgedrag", + "close": "Afsluiten", + "minimize_to_tray": "Minimaliseren naar systeemvak", + "show_tray_icon": "Systeemvakpictogram tonen", + "about": "Over", + "u_love_spotube": "We weten dat je van Spotube houd", + "check_for_updates": "Controleren op updates", + "about_spotube": "Over Spotube", + "blacklist": "Zwarte lijst", + "please_sponsor": "Sponsor/Doneer a.u.b.", + "spotube_description": "Spotube, een lichtgewicht, cross-platform, vrij-voor-alles Spotify-client", + "version": "Versie", + "build_number": "Bouwnummer", + "founder": "Grondlegger", + "repository": "Opslagplaats", + "bug_issues": "Bug+problemen", + "made_with": "Met ❤️ gemaakt in Bangladesh🇧🇩", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "Licentie", + "add_spotify_credentials": "Voeg om te beginnen je spotify-aanmeldgegevens toe", + "credentials_will_not_be_shared_disclaimer": "Maak je geen zorgen, je gegevens worden niet verzameld of gedeeld met anderen.", + "know_how_to_login": "Weet je niet hoe je dit moet doen?", + "follow_step_by_step_guide": "Volg de stapsgewijze handleiding", + "spotify_cookie": "Spotify {name} Cookie", + "cookie_name_cookie": "{name} Cookie", + "fill_in_all_fields": "Vul alle velden in a.u.b.", + "submit": "Verzenden", + "exit": "Afronden", + "previous": "Vorige", + "next": "Volgende", + "done": "Klaar", + "step_1": "Stap 1", + "first_go_to": "Ga eerst naar", + "login_if_not_logged_in": "en Inloggen/Aanmelden als je niet bent ingelogd", + "step_2": "Stap 2", + "step_2_steps": "1. Zodra je bent aangemeld, druk je op F12 of klik je met de rechtermuisknop > Inspect om de Browser devtools te openen.\n2. Ga vervolgens naar het tabblad \"Toepassing\" (Chrome, Edge, Brave enz..) of naar het tabblad \"Opslag\" (Firefox, Palemoon enz..).\n3. Ga naar de sectie \"Cookies\" en vervolgens naar de subsectie \"https://accounts.spotify.com\".", + "step_3": "Stap 3", + "step_3_steps": "De waarde van cookie \"sp_dc\" kopiëren", + "success_emoji": "Succes🥳", + "success_message": "Je bent nu ingelogd met je Spotify account. Goed gedaan!", + "step_4": "Stap 4", + "step_4_steps": "De gekopieerde waarde \"sp_dc\" plakken", + "something_went_wrong": "Er ging iets mis", + "piped_instance": "Piped-serverinstantie", + "piped_description": "De Piped-serverinstantie die moet worden gebruikt voor overeenkomstige nummers", + "piped_warning": "Sommige werken misschien niet goed. Dus gebruik ze op eigen risico", + "generate_playlist": "Afspeellijst genereren", + "track_exists": "Nummer {track} bestaat al", + "replace_downloaded_tracks": "Alle gedownloade nummers vervangen", + "skip_download_tracks": "Downloaden van alle gedownloade nummers overslaan", + "do_you_want_to_replace": "Wil je het bestaande nummer vervangen?", + "replace": "Vervangen", + "skip": "Overslaan", + "select_up_to_count_type": "Selecteer tot {count} {type}", + "select_genres": "Genres selecteren", + "add_genres": "Genres toevoegen", + "country": "Land", + "number_of_tracks_generate": "Aantal nummers om te genereren", + "acousticness": "Akoestiek", + "danceability": "Dansbaarheid", + "energy": "Energie", + "instrumentalness": "Instrumentaliteit", + "liveness": "Levendigheid", + "loudness": "Luidheid", + "speechiness": "Spraak", + "valence": "Valentie", + "popularity": "Populariteit", + "key": "Sleutel", + "duration": "Tijdsduur (s)", + "tempo": "Tempo (SPM)", + "mode": "Modus", + "time_signature": "Tijdsnotatie", + "short": "Kort", + "medium": "Middel", + "long": "Lang", + "min": "Min", + "max": "Max", + "target": "Doel", + "moderate": "Matig", + "deselect_all": "Selectie opheffen", + "select_all": "Alles selecteren", + "are_you_sure": "Weet je het zeker?", + "generating_playlist": "Aangepaste afspeellijst genereren…", + "selected_count_tracks": "{count} nummers geselecteerd", + "download_warning": "Als je alle nummers in bulk downloadt, ben je duidelijk bezig met muziekpiraterij en breng je schade toe aan de creatieve muziekmaatschappij. Ik hoop dat je je hiervan bewust bent. Probeer altijd het harde werk van artiesten te respecteren en te steunen.", + "download_ip_ban_warning": "BTW, je IP-adres kan worden geblokkeerd op YouTube als gevolg van buitensporige downloadverzoeken. IP-blokkering betekent dat je YouTube niet kunt gebruiken (zelfs als je ingelogd bent) voor tenminste 2-3 maanden vanaf dat IP-apparaat. Spotube is niet verantwoordelijk als dit ooit gebeurt.", + "by_clicking_accept_terms": "Door op 'accepteren' te klikken ga je akkoord met de volgende voorwaarden:", + "download_agreement_1": "Ik weet dat ik muziek illegaal donload. Ik ben slecht.", + "download_agreement_2": "Ik steun de artiest waar ik kan en ik doe dit alleen omdat ik geen geld heb om hun kunst te kopen.", + "download_agreement_3": "Ik ben me er volledig van bewust dat mijn IP geblokkeerd kan worden op YouTube & ik houd Spotube of zijn eigenaars/contributeurs niet verantwoordelijk voor ongelukken die veroorzaakt worden door mijn huidige actie.", + "decline": "Weigeren", + "accept": "Accepteren", + "details": "Bijzonderheden", + "youtube": "YouTube", + "channel": "Kanaal", + "likes": "Liefs", + "dislikes": "Hekels", + "views": "Weergaven", + "streamUrl": "Stream-URL", + "stop": "Stoppen", + "sort_newest": "Sorteren op nieuwste toegevoegd", + "sort_oldest": "Sorteren op oudste toegevoegd", + "sleep_timer": "Slaaptimer", + "mins": "{minutes} minuten", + "hours": "{hours} uren", + "hour": "{hours} uur", + "custom_hours": "Aangepaste uren", + "logs": "Logboeken", + "developers": "Ontwikkelaars", + "not_logged_in": "Je bent niet aangemeld", + "search_mode": "Zoekmodus", + "youtube_api_type": "API-type", + "ok": "Oké", + "failed_to_encrypt": "Versleuteling mislukt", + "encryption_failed_warning": "Spotube gebruikt versleuteling om je gegevens veilig op te slaan. Maar dat is niet gelukt. Dus zal het terugvallen op onveilige opslag.\nAls je linux gebruikt, zorg er dan voor dat je een geheim-dienst (gnome-keyring, kde-wallet, keepassxc etc) hebt geïnstalleerd.", + "querying_info": "Info opvragen…", + "piped_api_down": "Piped API is uit", + "piped_down_error_instructions": "De Piped-instantie {pipedInstance} is momenteel uitgevallen\n\nVerander de instantie of verander het 'API-type' naar de officiële YouTube API.\n\nZorg ervoor dat u de app herstart na de wijziging", + "you_are_offline": "Je bent momenteel offline", + "connection_restored": "Je internetverbinding is hersteld", + "use_system_title_bar": "Systeemtitelbalk gebruiken", + "crunching_results": "Resultaten verwerken…", + "search_to_get_results": "Zoeken naar resultaten", + "use_amoled_mode": "Pikzwart donkerthema", + "pitch_dark_theme": "AMOLED-modus", + "normalize_audio": "Audio normaliseren", + "change_cover": "Hoes aanpassen", + "add_cover": "Hoes toevoegen", + "restore_defaults": "Standaardwaarden herstellen", + "download_music_codec": "Download-codec", + "streaming_music_codec": "Streaming-codec", + "login_with_lastfm": "Inloggen met Last.fm", + "connect": "Verbinden", + "disconnect_lastfm": "Last.fm verbreken", + "disconnect": "Verbeken", + "username": "Gebruikersnaam", + "password": "Wachtwoord", + "login": "Inloggen", + "login_with_your_lastfm": "Inloggen met je Last.fm account", + "scrobble_to_lastfm": "Scrobbelen naar Last.fm", + "go_to_album": "Ga naar album", + "discord_rich_presence": "Discord Rich Presence", + "browse_all": "Alles doorbladeren", + "genres": "Genres", + "explore_genres": "Genres verkennen", + "friends": "Vrienden", + "no_lyrics_available": "Sorry, geen teksten gevonden voor dit nummer" +} diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 641f5a62..b7ce8923 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -136,7 +136,6 @@ "skip_non_music": "Pomiń nie-muzyczne segmenty (SponsorBlock)", "blacklist_description": "Czarna lista utworów i artystów", "wait_for_download_to_finish": "Proszę poczekać na zakończenie obecnego pobierania.", - "download_lyrics": "Pobierz utwory razem z tekstem", "desktop": "Pulpit", "close_behavior": "Zamknij", "close": "Zamknij", @@ -176,11 +175,9 @@ "step_2": "Krok 2", "step_2_steps": "1. Jeśli jesteś zalogowany, naciśnij klawisz F12 lub Kliknij prawym przyciskiem myszy > Zbadaj, aby odtworzyć narzędzia developerskie.\n2. Następnie przejdź do zakładki \"Application\" (Chrome, Edge, Brave etc..) lub zakładki \"Storage\" (Firefox, Palemoon etc..)\n3. Przejdź do sekcji \"Cookies\" a następnie do pod-sekcji \"https://accounts.spotify.com\"", "step_3": "Krok 3", - "step_3_steps": "Skopiuj wartości \"sp_dc\" i \"sp_key\" (lub sp_gaid) Ciasteczek", "success_emoji": "Sukces!🥳", "success_message": "Udało ci się zalogować! Dobra robota, stary!", "step_4": "Krok 4", - "step_4_steps": "Wklej wartości \"sp_dc\" i \"sp_key\" (lub sp_gaid) do odpowiednich pul.", "something_went_wrong": "Coś poszło nie tak 🙁", "piped_instance": "Instancja serwera Piped", "piped_description": "Instancja serwera Piped używana jest do dopasowania utworów.", @@ -250,11 +247,44 @@ "developers": "Developerzy", "not_logged_in": "Nie jesteś zalogowany", "search_mode": "Tryb szukania", - "youtube_api_type": "Typ API", + "audio_source": "Źródło dźwięku", "ok": "Ok", "failed_to_encrypt": "Nie można zaszyfrować :(", "encryption_failed_warning": "Spotube używa szyfrowania do bezpiecznego przechowywania danych. Ale nie udało się tego zrobić. Więc powróci do niezabezpieczonego przechowywania\nJeśli używasz Linuksa, upewnij się, że masz zainstalowane jakieś usługi do szyfrowania (gnome-keyring, kde-wallet, keepassxc itp.)", "querying_info": "Szukam informacji...", - "piped_api_down": "Piped API jest wyłączone", - "piped_down_error_instructions": "Instancja Piped {pipedInstance} jest obecnie wyłączona.\n\nJednak możesz zmienić instancję lub 'Typ API' na oficialne API YouTube'a.\n\nUpewnij się, że uruchomiłeś ponownie aplikacje po tej zmianie." + "piped_api_down": "API Piped jest niedostępne", + "piped_down_error_instructions": "Instancja Piped {pipedInstance} jest obecnie niedostępna\n\nZmień instancję lub zmień 'Rodzaj API' na oficjalne API YouTube\n\nUpewnij się, że po zmianie zrestartujesz aplikację", + "you_are_offline": "Obecnie jesteś offline", + "connection_restored": "Twoje połączenie z internetem zostało przywrócone", + "use_system_title_bar": "Użyj paska tytułu systemu", + "update_playlist": "Zaktualizuj playlistę", + "update": "Aktualizuj", + "crunching_results": "Przetwarzanie wyników...", + "search_to_get_results": "Szukaj, aby uzyskać wyniki", + "use_amoled_mode": "Tryb AMOLED", + "pitch_dark_theme": "Ciemny motyw", + "normalize_audio": "Normalizuj dźwięk", + "change_cover": "Zmień okładkę", + "add_cover": "Dodaj okładkę", + "restore_defaults": "Przywróć domyślne", + "download_music_codec": "Pobierz kodek muzyczny", + "streaming_music_codec": "Kodek strumieniowy muzyki", + "login_with_lastfm": "Zaloguj się z Last.fm", + "connect": "Połącz", + "disconnect_lastfm": "Rozłącz z Last.fm", + "disconnect": "Rozłącz", + "username": "Nazwa użytkownika", + "password": "Hasło", + "login": "Zaloguj", + "login_with_your_lastfm": "Zaloguj się na swoje konto Last.fm", + "scrobble_to_lastfm": "Scrobbluj do Last.fm", + "go_to_album": "Przejdź do albumu", + "discord_rich_presence": "Obecność na Discordzie", + "browse_all": "Przeglądaj wszystko", + "genres": "Gatunki muzyczne", + "explore_genres": "Eksploruj gatunki", + "step_3_steps": "Skopiuj wartość ciasteczka \"sp_dc\"", + "step_4_steps": "Wklej skopiowaną wartość \"sp_dc\"", + "friends": "Przyjaciele", + "no_lyrics_available": "Przepraszamy, nie można znaleźć tekstu dla tego utworu" } \ No newline at end of file diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb new file mode 100644 index 00000000..1c75f734 --- /dev/null +++ b/lib/l10n/app_pt.arb @@ -0,0 +1,290 @@ +{ + "guest": "Visitante", + "browse": "Explorar", + "search": "Buscar", + "library": "Biblioteca", + "lyrics": "Letras", + "settings": "Configurações", + "genre_categories_filter": "Filtrar categorias ou gêneros...", + "genre": "Gênero", + "personalized": "Personalizado", + "featured": "Destaque", + "new_releases": "Novos Lançamentos", + "songs": "Músicas", + "playing_track": "Tocando {track}", + "queue_clear_alert": "Isso irá limpar a fila atual. {track_length} músicas serão removidas.\nDeseja continuar?", + "load_more": "Carregar mais", + "playlists": "Playlists", + "artists": "Artistas", + "albums": "Álbuns", + "tracks": "Faixas", + "downloads": "Downloads", + "filter_playlists": "Filtrar suas playlists...", + "liked_tracks": "Músicas Curtidas", + "liked_tracks_description": "Todas as suas músicas curtidas", + "create_playlist": "Criar Playlist", + "create_a_playlist": "Criar uma playlist", + "create": "Criar", + "cancel": "Cancelar", + "playlist_name": "Nome da Playlist", + "name_of_playlist": "Nome da playlist", + "description": "Descrição", + "public": "Pública", + "collaborative": "Colaborativa", + "search_local_tracks": "Buscar músicas locais...", + "play": "Reproduzir", + "delete": "Excluir", + "none": "Nenhum", + "sort_a_z": "Ordenar de A-Z", + "sort_z_a": "Ordenar de Z-A", + "sort_artist": "Ordenar por Artista", + "sort_album": "Ordenar por Álbum", + "sort_tracks": "Ordenar Faixas", + "currently_downloading": "Baixando no momento ({tracks_length})", + "cancel_all": "Cancelar Tudo", + "filter_artist": "Filtrar artistas...", + "followers": "{followers} Seguidores", + "add_artist_to_blacklist": "Adicionar artista à lista negra", + "top_tracks": "Principais Músicas", + "fans_also_like": "Fãs também curtiram", + "loading": "Carregando...", + "artist": "Artista", + "blacklisted": "Na Lista Negra", + "following": "Seguindo", + "follow": "Seguir", + "artist_url_copied": "URL do artista copiada para a área de transferência", + "added_to_queue": "Adicionadas {tracks} músicas à fila", + "filter_albums": "Filtrar álbuns...", + "synced": "Sincronizado", + "plain": "Simples", + "shuffle": "Aleatório", + "search_tracks": "Buscar músicas...", + "released": "Lançado", + "error": "Erro {error}", + "title": "Título", + "time": "Tempo", + "more_actions": "Mais ações", + "download_count": "Baixar ({count})", + "add_count_to_playlist": "Adicionar ({count}) à Playlist", + "add_count_to_queue": "Adicionar ({count}) à Fila", + "play_count_next": "Reproduzir ({count}) em seguida", + "album": "Álbum", + "copied_to_clipboard": "{data} copiado para a área de transferência", + "add_to_following_playlists": "Adicionar {track} às Playlists Seguintes", + "add": "Adicionar", + "added_track_to_queue": "Adicionada {track} à fila", + "add_to_queue": "Adicionar à fila", + "track_will_play_next": "{track} será reproduzida em seguida", + "play_next": "Reproduzir em seguida", + "removed_track_from_queue": "{track} removida da fila", + "remove_from_queue": "Remover da fila", + "remove_from_favorites": "Remover dos favoritos", + "save_as_favorite": "Salvar como favorita", + "add_to_playlist": "Adicionar à playlist", + "remove_from_playlist": "Remover da playlist", + "add_to_blacklist": "Adicionar à lista negra", + "remove_from_blacklist": "Remover da lista negra", + "share": "Compartilhar", + "mini_player": "Mini Player", + "slide_to_seek": "Arraste para avançar ou retroceder", + "shuffle_playlist": "Embaralhar playlist", + "unshuffle_playlist": "Desembaralhar playlist", + "previous_track": "Faixa anterior", + "next_track": "Próxima faixa", + "pause_playback": "Pausar Reprodução", + "resume_playback": "Continuar Reprodução", + "loop_track": "Repetir faixa", + "repeat_playlist": "Repetir playlist", + "queue": "Fila", + "alternative_track_sources": "Fontes alternativas de faixas", + "download_track": "Baixar faixa", + "tracks_in_queue": "{tracks} músicas na fila", + "clear_all": "Limpar tudo", + "show_hide_ui_on_hover": "Mostrar/Ocultar UI ao passar o mouse", + "always_on_top": "Sempre no topo", + "exit_mini_player": "Sair do Mini player", + "download_location": "Local de download", + "account": "Conta", + "login_with_spotify": "Fazer login com sua conta do Spotify", + "connect_with_spotify": "Conectar ao Spotify", + "logout": "Sair", + "logout_of_this_account": "Sair desta conta", + "language_region": "Idioma e Região", + "language": "Idioma", + "system_default": "Padrão do Sistema", + "market_place_region": "Região da Loja", + "recommendation_country": "País de Recomendação", + "appearance": "Aparência", + "layout_mode": "Modo de Layout", + "override_layout_settings": "Substituir configurações do modo de layout responsivo", + "adaptive": "Adaptável", + "compact": "Compacto", + "extended": "Estendido", + "theme": "Tema", + "dark": "Escuro", + "light": "Claro", + "system": "Sistema", + "accent_color": "Cor de Destaque", + "sync_album_color": "Sincronizar cor do álbum", + "sync_album_color_description": "Usa a cor predominante da capa do álbum como cor de destaque", + "playback": "Reprodução", + "audio_quality": "Qualidade do Áudio", + "high": "Alta", + "low": "Baixa", + "pre_download_play": "Pré-download e reprodução", + "pre_download_play_description": "Em vez de transmitir áudio, baixar bytes e reproduzir (recomendado para usuários com maior largura de banda)", + "skip_non_music": "Pular segmentos não musicais (SponsorBlock)", + "blacklist_description": "Faixas e artistas na lista negra", + "wait_for_download_to_finish": "Aguarde o download atual ser concluído", + "desktop": "Desktop", + "close_behavior": "Comportamento de Fechamento", + "close": "Fechar", + "minimize_to_tray": "Minimizar para a bandeja", + "show_tray_icon": "Mostrar ícone na bandeja do sistema", + "about": "Sobre", + "u_love_spotube": "Sabemos que você adora o Spotube", + "check_for_updates": "Verificar atualizações", + "about_spotube": "Sobre o Spotube", + "blacklist": "Lista Negra", + "please_sponsor": "Por favor, patrocine/doe", + "spotube_description": "Spotube, um cliente leve, multiplataforma e gratuito para o Spotify", + "version": "Versão", + "build_number": "Número de Build", + "founder": "Fundador", + "repository": "Repositório", + "bug_issues": "Bugs/Problemas", + "made_with": "Feito com ❤️ em Bangladesh🇧🇩", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "Licença", + "add_spotify_credentials": "Adicione suas credenciais do Spotify para começar", + "credentials_will_not_be_shared_disclaimer": "Não se preocupe, suas credenciais não serão coletadas nem compartilhadas com ninguém", + "know_how_to_login": "Não sabe como fazer isso?", + "follow_step_by_step_guide": "Siga o guia passo a passo", + "spotify_cookie": "Cookie do Spotify {name}", + "cookie_name_cookie": "Cookie {name}", + "fill_in_all_fields": "Preencha todos os campos, por favor", + "submit": "Enviar", + "exit": "Sair", + "previous": "Anterior", + "next": "Próximo", + "done": "Concluído", + "step_1": "Passo 1", + "first_go_to": "Primeiro, vá para", + "login_if_not_logged_in": "e faça login/cadastro se ainda não estiver logado", + "step_2": "Passo 2", + "step_2_steps": "1. Uma vez logado, pressione F12 ou clique com o botão direito do mouse > Inspecionar para abrir as ferramentas de desenvolvimento do navegador.\n2. Em seguida, vá para a guia \"Aplicativo\" (Chrome, Edge, Brave, etc.) ou \"Armazenamento\" (Firefox, Palemoon, etc.)\n3. Acesse a seção \"Cookies\" e depois a subseção \"https://accounts.spotify.com\"", + "step_3": "Passo 3", + "success_emoji": "Sucesso🥳", + "success_message": "Agora você está logado com sucesso em sua conta do Spotify. Bom trabalho!", + "step_4": "Passo 4", + "something_went_wrong": "Algo deu errado", + "piped_instance": "Instância do Servidor Piped", + "piped_description": "A instância do servidor Piped a ser usada para correspondência de faixas", + "piped_warning": "Algumas delas podem não funcionar bem. Use por sua conta e risco", + "generate_playlist": "Gerar Playlist", + "track_exists": "A faixa {track} já existe", + "replace_downloaded_tracks": "Substituir todas as faixas baixadas", + "skip_download_tracks": "Pular o download de todas as faixas baixadas", + "do_you_want_to_replace": "Deseja substituir a faixa existente?", + "replace": "Substituir", + "skip": "Pular", + "select_up_to_count_type": "Selecione até {count} {type}", + "select_genres": "Selecionar Gêneros", + "add_genres": "Adicionar Gêneros", + "country": "País", + "number_of_tracks_generate": "Número de faixas a gerar", + "acousticness": "Acústica", + "danceability": "Dançabilidade", + "energy": "Energia", + "instrumentalness": "Instrumentalidade", + "liveness": "Vivacidade", + "loudness": "Volume", + "speechiness": "Discurso", + "valence": "Valência", + "popularity": "Popularidade", + "key": "Tonalidade", + "duration": "Duração (s)", + "tempo": "Tempo (BPM)", + "mode": "Modo", + "time_signature": "Assinatura de tempo", + "short": "Curto", + "medium": "Médio", + "long": "Longo", + "min": "Min", + "max": "Máx", + "target": "Alvo", + "moderate": "Moderado", + "deselect_all": "Desmarcar Todos", + "select_all": "Selecionar Todos", + "are_you_sure": "Tem certeza?", + "generating_playlist": "Gerando sua playlist personalizada...", + "selected_count_tracks": "{count} faixas selecionadas", + "download_warning": "Se você baixar todas as faixas em massa, estará claramente pirateando música e causando danos à sociedade criativa da música. Espero que você esteja ciente disso. Sempre tente respeitar e apoiar o trabalho árduo dos artistas", + "download_ip_ban_warning": "Além disso, seu IP pode ser bloqueado no YouTube devido a solicitações de download excessivas. O bloqueio de IP significa que você não poderá usar o YouTube (mesmo se estiver conectado) por pelo menos 2-3 meses a partir do dispositivo IP. E o Spotube não se responsabiliza se isso acontecer", + "by_clicking_accept_terms": "Ao clicar em 'aceitar', você concorda com os seguintes termos:", + "download_agreement_1": "Eu sei que estou pirateando música. Sou mau", + "download_agreement_2": "Vou apoiar o artista onde puder e estou fazendo isso porque não tenho dinheiro para comprar sua arte", + "download_agreement_3": "Estou completamente ciente de que meu IP pode ser bloqueado no YouTube e não responsabilizo o Spotube ou seus proprietários/colaboradores por quaisquer acidentes causados pela minha ação atual", + "decline": "Recusar", + "accept": "Aceitar", + "details": "Detalhes", + "youtube": "YouTube", + "channel": "Canal", + "likes": "Curtidas", + "dislikes": "Descurtidas", + "views": "Visualizações", + "streamUrl": "URL do Stream", + "stop": "Parar", + "sort_newest": "Ordenar por mais recente adicionado", + "sort_oldest": "Ordenar por mais antigo adicionado", + "sleep_timer": "Temporizador de Sono", + "mins": "{minutes} Minutos", + "hours": "{hours} Horas", + "hour": "{hours} Hora", + "custom_hours": "Horas Personalizadas", + "logs": "Registros", + "developers": "Desenvolvedores", + "not_logged_in": "Você não está logado", + "search_mode": "Modo de Busca", + "audio_source": "Fonte de Áudio", + "ok": "Ok", + "failed_to_encrypt": "Falha ao criptografar", + "encryption_failed_warning": "O Spotube usa criptografia para armazenar seus dados com segurança, mas falhou em fazê-lo. Portanto, ele voltará para o armazenamento não seguro.\nSe você estiver usando o Linux, certifique-se de ter algum serviço secreto (gnome-keyring, kde-wallet, keepassxc, etc.) instalado", + "querying_info": "Consultando informações...", + "piped_api_down": "A API do Piped está indisponível", + "piped_down_error_instructions": "A instância do Piped {pipedInstance} está atualmente indisponível\n\nMude a instância ou mude o 'Tipo de API' para a API oficial do YouTube\n\nCertifique-se de reiniciar o aplicativo após a alteração", + "you_are_offline": "Você está offline no momento", + "connection_restored": "Sua conexão com a internet foi restaurada", + "use_system_title_bar": "Usar a barra de título do sistema", + "update_playlist": "Atualizar lista de reprodução", + "update": "Atualizar", + "crunching_results": "Processando resultados...", + "search_to_get_results": "Pesquisar para obter resultados", + "use_amoled_mode": "Modo AMOLED", + "pitch_dark_theme": "Tema escuro", + "normalize_audio": "Normalizar áudio", + "change_cover": "Alterar capa", + "add_cover": "Adicionar capa", + "restore_defaults": "Restaurar padrões", + "download_music_codec": "Descarregar codec de música", + "streaming_music_codec": "Codec de streaming de música", + "login_with_lastfm": "Iniciar sessão com o Last.fm", + "connect": "Ligar", + "disconnect_lastfm": "Desligar do Last.fm", + "disconnect": "Desligar", + "username": "Nome de utilizador", + "password": "Palavra-passe", + "login": "Iniciar sessão", + "login_with_your_lastfm": "Inicie sessão na sua conta Last.fm", + "scrobble_to_lastfm": "Scrobble para o Last.fm", + "go_to_album": "Ir para o álbum", + "discord_rich_presence": "Presença rica no Discord", + "browse_all": "Navegar por tudo", + "genres": "Gêneros", + "explore_genres": "Explorar gêneros", + "step_3_steps": "Copie o valor do cookie \"sp_dc\"", + "step_4_steps": "Cole o valor copiado de \"sp_dc\"", + "friends": "Amigos", + "no_lyrics_available": "Desculpe, não foi possível encontrar a letra desta faixa" +} \ No newline at end of file diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb new file mode 100644 index 00000000..7ed67f4f --- /dev/null +++ b/lib/l10n/app_ru.arb @@ -0,0 +1,290 @@ +{ + "guest": "Гость", + "browse": "Обзор", + "search": "Поиск", + "library": "Библиотека", + "lyrics": "Текст", + "settings": "Настройки", + "genre_categories_filter": "Фильтр по категориям или жанрам...", + "genre": "Жанр", + "personalized": "Персонализированный", + "featured": "Будующий", + "new_releases": "Новые", + "songs": "Песни", + "playing_track": "Играет {track}", + "queue_clear_alert": "Это удалит текущую очередь. {track_length} треков будет удалено. Вы хотите продолжить?", + "load_more": "Загрузить больше", + "playlists": "Плейлисты", + "artists": "Исполнители", + "albums": "Альбомы", + "tracks": "Трек", + "downloads": "Загрузки", + "filter_playlists": "Применить фильтры к вашим плейлистам...", + "liked_tracks": "Понравившиеся треки", + "liked_tracks_description": "Все понравившиеся треки", + "create_playlist": "Создание плейлиста", + "create_a_playlist": "Создать плейлист", + "create": "Создать", + "cancel": "Отменить", + "playlist_name": "Назвать плейлист", + "name_of_playlist": "Название плейлиста", + "description": "Описание", + "public": "Публичные", + "collaborative": "Совместный", + "search_local_tracks": "Поиск песен на вашем устройстве...", + "play": "Играть", + "delete": "Удалить", + "none": "Никто", + "sort_a_z": "Сортировка по алфавиту", + "sort_z_a": "Сортировка по алфавиту в обратную сторону", + "sort_artist": "Сортировать по исполнителю", + "sort_album": "Сортировать по альбомам", + "sort_tracks": "Сортировать треки", + "currently_downloading": "Загружается ({tracks_length})", + "cancel_all": "Отменить все", + "filter_artist": "Фильтровать по исполнителю...", + "followers": "{followers} Подписчики", + "add_artist_to_blacklist": "Добавить исполнителя в черный список", + "top_tracks": "Чарт", + "fans_also_like": "Поклонникам также нравится", + "loading": "Загрузка...", + "artist": "Исполнитель", + "blacklisted": "Внесен в черный список", + "following": "Подписаны", + "follow": "Подписаться", + "artist_url_copied": "URL-адрес исполнителя скопирован в буфер обмена", + "added_to_queue": "Добавлено {tracks} треков в очередь", + "filter_albums": "Фильтровать альбомы...", + "synced": "Синхронизировано", + "plain": "Обычный", + "shuffle": "Перемешать", + "search_tracks": "Поиск треков...", + "released": "Дата выхода", + "error": "Ошибка {error}", + "title": "Заголовок", + "time": "Время", + "more_actions": "Больше действий", + "download_count": "Скачать ({count})", + "add_count_to_playlist": "Добавить ({count}) в плейлист", + "add_count_to_queue": "Добавить ({count}) в очередь", + "play_count_next": "Воспроизвести ({count}) следующий", + "album": "Альбом", + "copied_to_clipboard": "Скопировано {data} в буфер обмена", + "add_to_following_playlists": "Добавить {track} в этот плейлист", + "add": "Добавить", + "added_track_to_queue": "Добавлен {track} в очередь", + "add_to_queue": "Добавить в очередь", + "track_will_play_next": "{track} будет воспроизведен следующим", + "play_next": "Воспроизвести следующий", + "removed_track_from_queue": "{track} удален из очереди", + "remove_from_queue": "Удалить из очереди", + "remove_from_favorites": "Удалить из избранного", + "save_as_favorite": "Сохранить в избранное", + "add_to_playlist": "Добавить в плейлист", + "remove_from_playlist": "Удалить из плейлиста", + "add_to_blacklist": "Добавить в черный список", + "remove_from_blacklist": "Удалить из черного списка", + "share": "Поделиться", + "mini_player": "Мини-плеер", + "slide_to_seek": "Потяните для перемотки вперед или назад", + "shuffle_playlist": "Перемешать плейлист", + "unshuffle_playlist": "Снять перемешивание плейлиста", + "previous_track": "Предыдущий трек", + "next_track": "Следующий трек", + "pause_playback": "Пауза воспроизведения", + "resume_playback": "Возобновить воспроизведение", + "loop_track": "Циклический трек", + "repeat_playlist": "Повторите плейлист", + "queue": "Очередь", + "alternative_track_sources": "Альтернативные источники треков", + "download_track": "Скачать трек", + "tracks_in_queue": "{tracks} треков в очереди", + "clear_all": "Очистить все", + "show_hide_ui_on_hover": "Показать/Скрыть интерфейс при наведении", + "always_on_top": "Всегда сверху", + "exit_mini_player": "Выйти из мини-плеера", + "download_location": "Место загрузки", + "account": "Аккаунт", + "login_with_spotify": "Войдите с помощью своей учетной записи Spotify", + "connect_with_spotify": "Подключитесь к Spotify", + "logout": "Выйти", + "logout_of_this_account": "Выйдите из этого аккаунта", + "language_region": "Язык и регион", + "language": "Язык", + "system_default": "Системное значение по умолчанию", + "market_place_region": "Региональное пространство", + "recommendation_country": "Страна рекомендаций", + "appearance": "Внешний вид", + "layout_mode": "Режим компоновки", + "override_layout_settings": "Изменить настройки режима адаптивной компоновки", + "adaptive": "Адаптивный", + "compact": "Компактный", + "extended": "Расширенный", + "theme": "Тема", + "dark": "Тёмная", + "light": "Светлая", + "system": "Системная", + "accent_color": "Акцентный цвет", + "sync_album_color": "Синхронизировать цвет альбома", + "sync_album_color_description": "Использует основной цвет обложки альбома как цвет акцента", + "playback": "Воспроизведение", + "audio_quality": "Качество звука", + "high": "Высокое", + "low": "Низкое", + "pre_download_play": "Предварительная загрузка и воспроизведение", + "pre_download_play_description": "Вместо потоковой передачи аудио используйте загруженные байты и воспроизводьте их (рекомендуется для пользователей с высокой пропускной способностью)", + "skip_non_music": "Пропускать немузыкальные сегменты (SponsorBlock)", + "blacklist_description": "Черный список треков и артистов", + "wait_for_download_to_finish": "Пожалуйста, дождитесь завершения текущей загрузки", + "desktop": "Компьютер", + "close_behavior": "Поведение при закрытии", + "close": "Закрыть", + "minimize_to_tray": "Свернуть", + "show_tray_icon": "Показать значок на панели задач", + "about": "О", + "u_love_spotube": "Мы знаем что вам нравится Spotube", + "check_for_updates": "Проверьте наличие обновлений", + "about_spotube": "О Spotube", + "blacklist": "Чёрный список", + "please_sponsor": "Стать спосором/поддержать", + "spotube_description": "Spotube – это легкий, кросс-платформенный клиент Spotify, предоставляющий бесплатный доступ для всех пользователей", + "version": "Версия", + "build_number": "Номер сборки", + "founder": "Создатель", + "repository": "Репозиторий", + "bug_issues": "Ошибки и проблемы", + "made_with": "Сделано Bangladesh🇧🇩 с ❤️", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "Лицензия", + "add_spotify_credentials": "Добавьте ваши учетные данные Spotify, чтобы начать", + "credentials_will_not_be_shared_disclaimer": "Не беспокойся, никакая личная информация не собирается и не передается", + "know_how_to_login": "Не знаете, как это сделать?", + "follow_step_by_step_guide": "Следуйте пошаговому руководству", + "spotify_cookie": "Spotify {name} Cookie", + "cookie_name_cookie": "{name} Cookie", + "fill_in_all_fields": "Пожалуйста, заполните все поля", + "submit": "Отправить", + "exit": "Выйти", + "previous": "Предыдущий", + "next": "Следующий", + "done": "Готово", + "step_1": "Шаг 1", + "first_go_to": "Сначала перейдите в", + "login_if_not_logged_in": "и войдите или зарегистрируйтесь, если вы не вошли в систему", + "step_2": "Шаг 2", + "step_2_steps": "1. После входа в систему нажмите F12 или щелкните правой кнопкой мыши > «Проверить», чтобы открыть инструменты разработчика браузера.\n2. Затем перейдите на вкладку \"Application\" (Chrome, Edge, Brave и т.д..) or \"Storage\" (Firefox, Palemoon и т.д..)\n3. Перейдите в раздел \"Cookies\", а затем в подраздел \"https://accounts.spotify.com\"", + "step_3": "Шаг 3", + "success_emoji": "Успешно 🥳", + "success_message": "Теперь вы успешно вошли в свою учетную запись Spotify. Отличная работа, приятель!", + "step_4": "Шаг 4", + "something_went_wrong": "Что-то пошло не так", + "piped_instance": "Экземпляр сервера Piped", + "piped_description": "Серверный экземпляр Piped для сопоставления треков", + "piped_warning": "Некоторые из них могут работать неправильно, поэтому используйте на свой страх и риск", + "generate_playlist": "Создать плейлист", + "track_exists": "Трек {track} уже существует", + "replace_downloaded_tracks": "Заменить все ранее скачанные треки", + "skip_download_tracks": "Пропустить загрузку всех ранее скачанных треков", + "do_you_want_to_replace": "Хотите заменить существующий трек??", + "replace": "Заменить", + "skip": "Пропустить", + "select_up_to_count_type": "Выберите до {count} {type}", + "select_genres": "Выберите жанр", + "add_genres": "Добавить жанр", + "country": "Страна", + "number_of_tracks_generate": "Количество треков для создания", + "acousticness": "Акустичность", + "danceability": "Ритмичность", + "energy": "Энергичность", + "instrumentalness": "Инструментальность", + "liveness": "Живость", + "loudness": "Громкость", + "speechiness": "Речевой характер", + "valence": "Значимость", + "popularity": "Популярность", + "key": "Ключ", + "duration": "Продолжительность (с)", + "tempo": "Время (BPM)", + "mode": "Режим", + "time_signature": "Тактовый размер", + "short": "Короткий", + "medium": "Средний", + "long": "Длинный", + "min": "Минимум", + "max": "Максимум", + "target": "Цель", + "moderate": "Отобрать", + "deselect_all": "Убрать выделение со всех", + "select_all": "Выделить все", + "are_you_sure": "Вы уверены?", + "generating_playlist": "Создание собственного плейлиста...", + "selected_count_tracks": "Выбрано {count} треков", + "download_warning": "При скачивании всех треков пакетом вы фактически занимаетесь пиратством и наносите ущерб творческому обществу музыки. Надеюсь, что вы осознаете это. Всегда старайтесь уважать и поддерживать усилия исполнителей, вложенные в их творчество", + "download_ip_ban_warning": "Кроме того, стоит учитывать, что из-за чрезмерного количества запросов на скачивание ваш IP-адрес может быть заблокирован на YouTube. Блокировка IP означает, что вы не сможете использовать YouTube (даже если вы вошли в свою учетную запись) в течение, как минимум, 2-3 месяцев с того устройства, с которого были сделаны эти запросы. Важно заметить, что Spotube не несет ответственности за такие события", + "by_clicking_accept_terms": "Нажимая 'принять', вы соглашаетесь с следующими условиями:", + "download_agreement_1": "Я осознаю, что я использую музыку незаконно. Это плохо.", + "download_agreement_2": "Я бы поддержал исполнителей, где только смог, и делаю это, так как не имею средств на приобретение их творчества", + "download_agreement_3": "Я полностью осознаю, что мой IP-адрес может быть заблокирован на YouTube, и я не считаю Spotube или его владельцев/соавторов ответственными за какие-либо неприятности, вызванные моими текущими действиями", + "decline": "Отклонить", + "accept": "Принять", + "details": "Детали", + "youtube": "YouTube", + "channel": "Канал", + "likes": "Нравится", + "dislikes": "Не нравится", + "views": "Просмотров", + "streamUrl": "URL-адрес потока", + "stop": "Остановить", + "sort_newest": "Сортировать по самым новым добавленным", + "sort_oldest": "Сортировать по самым старым добавленным", + "sleep_timer": "Таймер сна", + "mins": "{minutes} Минут", + "hours": "{hours} Часы", + "hour": "{hours} Час", + "custom_hours": "Пользовательские часы", + "logs": "Журналы", + "developers": "Разработчики", + "not_logged_in": "Вы не выполнили вход", + "search_mode": "Режим поиска", + "audio_source": "Источник аудио", + "ok": "Ок", + "failed_to_encrypt": "Не удалось зашифровать", + "encryption_failed_warning": "Spotube использует шифрование для безопасного хранения ваших данных. Однако в этом случае произошла ошибка. Поэтому будет использовано небезопасное хранилище.\nЕсли вы используете Linux, убедитесь, что у вас установлен какой-либо инструмент для работы с секретами (gnome-keyring, kde-wallet, keepassxc и т.д.)", + "querying_info": "Запрос информации...", + "piped_api_down": "Piped API не отвечает", + "piped_down_error_instructions": "Экземпляр Piped {pipedInstance} в данный момент недоступен.\n\nВы можете либо изменить экземпляр, либо переключиться на использование официального API YouTube.\n\nНе забудьте перезапустить приложение после внесенных изменений", + "you_are_offline": "Нет доступа к сети", + "connection_restored": "Ваше интернет-соединение восстановлено", + "use_system_title_bar": "Использовать системную панель заголовка", + "update_playlist": "Обновить плейлист", + "update": "Обновить", + "crunching_results": "Обработка результатов...", + "search_to_get_results": "Поиск для получения результатов", + "use_amoled_mode": "Режим AMOLED", + "pitch_dark_theme": "Темная тема", + "normalize_audio": "Нормализовать звук", + "change_cover": "Изменить обложку", + "add_cover": "Добавить обложку", + "restore_defaults": "Восстановить настройки по умолчанию", + "download_music_codec": "Загрузить кодек для музыки", + "streaming_music_codec": "Кодек потоковой передачи музыки", + "login_with_lastfm": "Войти с помощью Last.fm", + "connect": "Подключить", + "disconnect_lastfm": "Отключиться от Last.fm", + "disconnect": "Отключить", + "username": "Имя пользователя", + "password": "Пароль", + "login": "Войти", + "login_with_your_lastfm": "Войти в свою учетную запись Last.fm", + "scrobble_to_lastfm": "Скробблинг на Last.fm", + "go_to_album": "Перейти к альбому", + "discord_rich_presence": "Богатое присутствие в Discord", + "browse_all": "Просмотреть все", + "genres": "Жанры", + "explore_genres": "Исследовать жанры", + "step_3_steps": "Скопируйте значение файла cookie \"sp_dc\"", + "step_4_steps": "Вставьте скопированное значение \"sp_dc\"", + "friends": "Друзья", + "no_lyrics_available": "Извините, не удается найти текст для этого трека" +} \ No newline at end of file diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb new file mode 100644 index 00000000..4d9066fd --- /dev/null +++ b/lib/l10n/app_tr.arb @@ -0,0 +1,290 @@ +{ + "guest": "Misafir", + "browse": "Gözat", + "search": "Ara", + "library": "Kütüphane", + "lyrics": "Sözler", + "settings": "Ayarlar", + "genre_categories_filter": "Kategorileri veya türleri filtreleyin...", + "genre": "Tür", + "personalized": "Kişiselleştirilmiş", + "featured": "Öne Çıkanlar", + "new_releases": "Yeni Çıkanlar", + "songs": "Şarkılar", + "playing_track": "Oynatılıyor {track}", + "queue_clear_alert": "Bu, mevcut kuyruğu temizleyecektir. {track_length} parçaları kaldırılacaktır\nDevam etmek istiyor musunuz?", + "load_more": "Daha fazlasını yükle", + "playlists": "Çalma Listeleri", + "artists": "Sanatçılar", + "albums": "Albümler", + "tracks": "Parçalar", + "downloads": "İndirmeler", + "filter_playlists": "Çalma 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": "Oluştur", + "cancel": "İptal", + "update": "Güncelle", + "playlist_name": "Çalma Listesi Adı", + "name_of_playlist": "Çalma listesi adı", + "description": "Açıklama", + "public": "Halka açık", + "collaborative": "İşbirliği", + "search_local_tracks": "Yerel parçaları arayın...", + "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...", + "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", + "loading": "Yükleniyor...", + "artist": "Sanatçı", + "blacklisted": "Kara Listede", + "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", + "plain": "Sade", + "shuffle": "Karıştır", + "search_tracks": "Parça ara...", + "released": "Yayınlandı", + "error": "Hata {error}", + "title": "Başlık", + "time": "Zaman", + "more_actions": "Daha fazla işlem", + "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", + "album": "Albüm", + "copied_to_clipboard": "Panoya {data} kopyalandı", + "add_to_following_playlists": "Aşağıdaki Çalma Listelerine {track} ekle", + "add": "Ekle", + "added_track_to_queue": "Sıraya {track} 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", + "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_blacklist": "Kara listeye ekle", + "remove_from_blacklist": "Kara listeden çıkar", + "share": "Paylaş", + "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", + "previous_track": "Önceki parça", + "next_track": "Sonraki parça", + "pause_playback": "Çalmayı Duraklat", + "resume_playback": "Çalmaya Devam Et", + "loop_track": "Döngü parçası", + "repeat_playlist": "Çalma listesini tekrarla", + "queue": "Sıra", + "alternative_track_sources": "Alternatif parça kaynakları", + "download_track": "Parçayı indir", + "tracks_in_queue": "{tracks} sıradaki parçalar", + "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", + "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", + "appearance": "Görünüm", + "layout_mode": "Düzen Modu", + "override_layout_settings": "Duyarlı düzen modu ayarlarını geçersiz kıl", + "adaptive": "Uyarlanabilir", + "compact": "Sıkıştırılmış", + "extended": "Genişletilmiş", + "theme": "Tema", + "dark": "Karanlık", + "light": "Aydınlı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", + "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)", + "blacklist_description": "Kara listeye alınan parçalar ve sanatçılar", + "wait_for_download_to_finish": "Lütfen mevcut indirme işleminin bitmesini bekleyin", + "desktop": "Masaüstü", + "close_behavior": "Yakın 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.", + "version": "Sürüm", + "build_number": "Derleme Numarası", + "founder": "Kurucu", + "repository": "Depo", + "bug_issues": "Hata + Sorunlar", + "made_with": "❤️ ile Bangladesh🇧🇩 adresinde yapılmıştır.", + "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", + "fill_in_all_fields": "Lütfen tüm alanları doldurun", + "submit": "Gönder", + "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", + "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_3": "3. Adım", + "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!", + "step_4": "4. Adım", + "something_went_wrong": "Bir şeyler ters gitti", + "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", + "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?", + "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", + "country": "Ülke", + "number_of_tracks_generate": "Oluşturulacak parça sayısı", + "acousticness": "Akustiklik", + "danceability": "Dansedilebilirlik", + "energy": "Enerji", + "instrumentalness": "Enstrümansallık", + "liveness": "Canlılık", + "loudness": "Yükseklik", + "speechiness": "Konuşkanlık", + "valence": "Değerlilik", + "popularity": "Popülerlik", + "key": "Anahtar", + "duration": "Süre (sn)", + "tempo": "Tempo (BPM)", + "mode": "Mod", + "time_signature": "Zaman İmzası", + "short": "Kısa", + "medium": "Orta", + "long": "Uzun", + "min": "Min", + "max": "Maks", + "target": "Hedef", + "moderate": "Orta", + "deselect_all": "Tüm Seçimleri Kaldır", + "select_all": "Tümünü Seç", + "are_you_sure": "Emin misiniz?", + "generating_playlist": "Özel ç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", + "decline": "Reddet", + "accept": "Kabul et", + "details": "Detaylar", + "youtube": "YouTube", + "channel": "Kanal", + "likes": "Beğeniler", + "dislikes": "Beğenmemeler", + "views": "İzlenmeler", + "streamUrl": "Yayın Bağlantısı", + "stop": "Dur", + "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", + "custom_hours": "Özel Saatler", + "logs": "Günlükler", + "developers": "Geliştiriciler", + "not_logged_in": "Giriş yapmadınız", + "search_mode": "Arama Modu", + "audio_source": "Ses Kaynağı", + "ok": "Tamam", + "failed_to_encrypt": "Şifreleme başarısız oldu", + "encryption_failed_warning": "Spotube, verilerinizi güvenli bir şekilde depolamak için şifreleme kullanır. Ancak bunu başaramadı. Bu nedenle güvensiz 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.", + "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", + "you_are_offline": "Şu anda çevrimdışısınız", + "connection_restored": "İnternet bağlantınız yeniden kuruldu", + "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ı", + "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", + "login_with_lastfm": "Last.fm ile giriş yap", + "connect": "Bağlan", + "disconnect_lastfm": "Last.fm bağlantısını kes", + "disconnect": "Bağlantıyı Kes", + "username": "Kullanıcı Adı", + "password": "Şifre", + "login": "Giriş Yap", + "login_with_your_lastfm": "Last.fm hesabınız ile 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", + "friends": "Arkadaşlar", + "no_lyrics_available": "Üzgünüz, bu parça için şarkı sözleri bulunamıyor" +} \ No newline at end of file diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb new file mode 100644 index 00000000..a4586a5e --- /dev/null +++ b/lib/l10n/app_uk.arb @@ -0,0 +1,290 @@ +{ + "guest": "Гість", + "browse": "Огляд", + "search": "Пошук", + "library": "Медіатека", + "lyrics": "Тексти пісень", + "settings": "Налаштування", + "genre_categories_filter": "Фільтрувати категорії або жанри...", + "genre": "Жанр", + "personalized": "Персоналізовані", + "featured": "Рекомендовані", + "new_releases": "Нові релізи", + "songs": "Пісні", + "playing_track": "Відтворюється {track}", + "queue_clear_alert": "Це очистить поточну чергу. Буде видалено {track_length} треків\nПродовжити?", + "load_more": "Завантажити більше", + "playlists": "Плейлисти", + "artists": "Виконавці", + "albums": "Альбоми", + "tracks": "Треки", + "downloads": "Завантаження", + "filter_playlists": "Фільтрувати плейлисти...", + "liked_tracks": "Сподобалися треки", + "liked_tracks_description": "Усі ваші сподобалися треки", + "create_playlist": "Створити плейлист", + "create_a_playlist": "Створити плейлист", + "update_playlist": "Оновити плейлист", + "create": "Створити", + "cancel": "Скасувати", + "update": "Оновити", + "playlist_name": "Назва плейлиста", + "name_of_playlist": "Назва плейлиста", + "description": "Опис", + "public": "Публічний", + "collaborative": "Спільний", + "search_local_tracks": "Пошук локальних треків...", + "play": "Відтворити", + "delete": "Видалити", + "none": "Немає", + "sort_a_z": "Сортувати за алфавітом A-Я", + "sort_z_a": "Сортувати за алфавітом Я-А", + "sort_artist": "Сортувати за виконавцем", + "sort_album": "Сортувати за альбомом", + "sort_tracks": "Сортувати треки", + "currently_downloading": "Завантажується ({tracks_length})", + "cancel_all": "Скасувати все", + "filter_artist": "Фільтрувати виконавців...", + "followers": "{followers} підписників", + "add_artist_to_blacklist": "Додати виконавця до чорного списку", + "top_tracks": "Топ треки", + "fans_also_like": "Шанувальникам також подобається", + "loading": "Завантаження...", + "artist": "Виконавець", + "blacklisted": "У чорному списку", + "following": "Стежу", + "follow": "Стежити", + "artist_url_copied": "URL виконавця скопійовано до буфера обміну", + "added_to_queue": "Додано {tracks} треків до черги", + "filter_albums": "Фільтрувати альбоми...", + "synced": "Синхронізовано", + "plain": "Звичайний", + "shuffle": "Випадковий порядок", + "search_tracks": "Пошук треків...", + "released": "Випущено", + "error": "Помилка {error}", + "title": "Назва", + "time": "Час", + "more_actions": "Більше дій", + "download_count": "Завантажено ({count})", + "add_count_to_playlist": "Додати ({count}) до плейлиста", + "add_count_to_queue": "Додати ({count}) до черги", + "play_count_next": "Відтворити ({count}) наступними", + "album": "Альбом", + "copied_to_clipboard": "Скопійовано {data} до буфера обміну", + "add_to_following_playlists": "Додати {track} до наступних плейлистів", + "add": "Додати", + "added_track_to_queue": "Додано {track} до черги", + "add_to_queue": "Додати до черги", + "track_will_play_next": "{track} буде відтворено наступним", + "play_next": "Відтворити наступним", + "removed_track_from_queue": "Видалено {track} з черги", + "remove_from_queue": "Видалити з черги", + "remove_from_favorites": "Видалити з обраних", + "save_as_favorite": "Зберегти як обране", + "add_to_playlist": "Додати до плейлиста", + "remove_from_playlist": "Видалити з плейлиста", + "add_to_blacklist": "Додати до чорного списку", + "remove_from_blacklist": "Видалити з чорного списку", + "share": "Поділитися", + "mini_player": "Міні-плеєр", + "slide_to_seek": "Проведіть пальцем, щоб перемотати вперед або назад", + "shuffle_playlist": "Випадковий порядок відтворення плейлиста", + "unshuffle_playlist": "Відключити випадковий порядок відтворення плейлиста", + "previous_track": "Попередній трек", + "next_track": "Наступний трек", + "pause_playback": "Призупинити відтворення", + "resume_playback": "Відновити відтворення", + "loop_track": "Повторювати трек", + "repeat_playlist": "Повторювати плейлист", + "queue": "Черга", + "alternative_track_sources": "Альтернативні джерела треків", + "download_track": "Завантажити трек", + "tracks_in_queue": "{tracks} треків у черзі", + "clear_all": "Очистити все", + "show_hide_ui_on_hover": "Показувати/приховувати інтерфейс при наведенні курсору", + "always_on_top": "Завжди зверху", + "exit_mini_player": "Вийти з міні-плеєра", + "download_location": "Шлях завантаження", + "account": "Обліковий запис", + "login_with_spotify": "Увійти за допомогою облікового запису Spotify", + "connect_with_spotify": "Підключитися до Spotify", + "logout": "Вийти", + "logout_of_this_account": "Вийти з цього облікового запису", + "language_region": "Мова та регіон", + "language": "Мова", + "system_default": "Системна мова", + "market_place_region": "Регіон маркетплейсу", + "recommendation_country": "Країна рекомендацій", + "appearance": "Зовнішній вигляд", + "layout_mode": "Режим макета", + "override_layout_settings": "Перезаписати налаштування адаптивного режиму макета", + "adaptive": "Адаптивний", + "compact": "Компактний", + "extended": "Розширений", + "theme": "Тема", + "dark": "Темна", + "light": "Світла", + "system": "Системна", + "accent_color": "Колір акценту", + "sync_album_color": "Синхронізувати колір альбому", + "sync_album_color_description": "Використовує домінуючий колір обкладинки альбому як колір акценту", + "playback": "Відтворення", + "audio_quality": "Якість аудіо", + "high": "Висока", + "low": "Низька", + "pre_download_play": "Попереднє завантаження та відтворення", + "pre_download_play_description": "Замість потокового відтворення аудіо завантажте байти та відтворіть їх (рекомендовано для користувачів з високою пропускною здатністю)", + "skip_non_music": "Пропустити не музичні сегменти", + "blacklist_description": "Треки та виконавці в чорному списку", + "wait_for_download_to_finish": "Зачекайте, поки завершиться поточна загрузка", + "desktop": "Робочий стіл", + "close_behavior": "Поведінка при закритті", + "close": "Закрити", + "minimize_to_tray": "Згорнути в трей", + "show_tray_icon": "Показувати значок у системному треї", + "about": "Про", + "u_love_spotube": "Ми знаємо, що ви любите Spotube", + "check_for_updates": "Перевірити наявність оновлень", + "about_spotube": "Про Spotube", + "blacklist": "Чорний список", + "please_sponsor": "Будь ласка, станьте спонсором/зробіть пожертву", + "spotube_description": "Spotube, легкий, кросплатформовий, безкоштовний клієнт Spotify", + "version": "Версія", + "build_number": "Номер збірки", + "founder": "Засновник", + "repository": "Репозиторій", + "bug_issues": "Помилки та проблеми", + "made_with": "Зроблено з ❤️ в Бангладеш 🇧🇩", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "Ліцензія", + "add_spotify_credentials": "Додайте свої облікові дані Spotify, щоб почати", + "credentials_will_not_be_shared_disclaimer": "Не хвилюйтеся, жодні ваші облікові дані не будуть зібрані або передані кому-небудь", + "know_how_to_login": "Не знаєте, як це зробити?", + "follow_step_by_step_guide": "Дотримуйтесь покрокової інструкції", + "spotify_cookie": "Кукі-файл Spotify {name}", + "cookie_name_cookie": "Кукі-файл {name}", + "fill_in_all_fields": "Будь ласка, заповніть усі поля", + "submit": "Надіслати", + "exit": "Вийти", + "previous": "Попередній", + "next": "Наступний", + "done": "Готово", + "step_1": "Крок 1", + "first_go_to": "Спочатку перейдіть на", + "login_if_not_logged_in": "та Увійдіть/Зареєструйтесь, якщо ви не ввійшли", + "step_2": "Крок 2", + "step_2_steps": "1. Після входу натисніть F12 або клацніть правою кнопкою миші > Інспектувати, щоб відкрити інструменти розробки браузера.\n2. Потім перейдіть на вкладку 'Програма' (Chrome, Edge, Brave тощо) або вкладку 'Сховище' (Firefox, Palemoon тощо).\n3. Перейдіть до розділу 'Кукі-файли', а потім до підрозділу 'https://accounts.spotify.com'", + "step_3": "Крок 3", + "success_emoji": "Успіх🥳", + "success_message": "Тепер ви успішно ввійшли у свій обліковий запис Spotify. Гарна робота, друже!", + "step_4": "Крок 4", + "something_went_wrong": "Щось пішло не так", + "piped_instance": "Примірник сервера Piped", + "piped_description": "Примірник сервера Piped, який використовуватиметься для зіставлення треків", + "piped_warning": "Деякі з них можуть працювати неправильно. Тому використовуйте на свій страх і ризик", + "generate_playlist": "Створити плейлист", + "track_exists": "Трек {track} вже існує", + "replace_downloaded_tracks": "Замінити всі завантажені треки", + "skip_download_tracks": "Пропустити завантаження всіх завантажених треків", + "do_you_want_to_replace": "Ви хочете замінити існуючий трек?", + "replace": "Замінити", + "skip": "Пропустити", + "select_up_to_count_type": "Виберіть до {count} {type}", + "select_genres": "Виберіть жанри", + "add_genres": "Додати жанри", + "country": "Країна", + "number_of_tracks_generate": "Кількість треків для створення", + "acousticness": "Акустичність", + "danceability": "Танцювальність", + "energy": "Енергія", + "instrumentalness": "Інструментальність", + "liveness": "Живість", + "loudness": "Гучність", + "speechiness": "Розмовність", + "valence": "Валентність", + "popularity": "Популярність", + "key": "Тональність", + "duration": "Тривалість (с)", + "tempo": "Темп (BPM)", + "mode": "Режим", + "time_signature": "Розмір", + "short": "Короткий", + "medium": "Середній", + "long": "Довгий", + "min": "Мін", + "max": "Макс", + "target": "Цільовий", + "moderate": "Помірний", + "deselect_all": "Зняти вибір з усіх", + "select_all": "Вибрати всі", + "are_you_sure": "Ви впевнені?", + "generating_playlist": "Створення вашого персонального плейлиста...", + "selected_count_tracks": "Вибрано {count} треків", + "download_warning": "Якщо ви завантажуєте всі треки масово, ви явно піратствуєте і завдаєте шкоди музичному творчому співтовариству. Сподіваюся, ви усвідомлюєте це. Завжди намагайтеся поважати і підтримувати важку працю артиста", + "download_ip_ban_warning": "До речі, ваш IP може бути заблокований на YouTube через надмірну кількість запитів на завантаження, ніж зазвичай. Блокування IP-адреси означає, що ви не зможете користуватися YouTube (навіть якщо ви увійшли в систему) протягом щонайменше 2-3 місяців з цього пристрою. І Spotube не несе жодної відповідальності, якщо це станеться", + "by_clicking_accept_terms": "Натискаючи 'прийняти', ви погоджуєтеся з наступними умовами:", + "download_agreement_1": "Я знаю, що краду музику. Я поганий.", + "download_agreement_2": "Я підтримаю автора, де тільки зможу, і роблю це лише тому, що не маю грошей, щоб купити його роботи.", + "download_agreement_3": "Я повністю усвідомлюю, що мій IP може бути заблокований на YouTube, і я не покладаю на Spotube або його власників/контрибуторів відповідальність за будь-які нещасні випадки, спричинені моїми діями.", + "decline": "Відхилити", + "accept": "Прийняти", + "details": "Деталі", + "youtube": "YouTube", + "channel": "Канал", + "likes": "Подобається", + "dislikes": "Не подобається", + "views": "Переглядів", + "streamUrl": "Посилання на стрімінг", + "stop": "Зупинити", + "sort_newest": "Сортувати за датою додавання (новіші першими)", + "sort_oldest": "Сортувати за датою додавання (старіші першими)", + "sleep_timer": "Таймер сну", + "mins": "{minutes} хвилин", + "hours": "{hours} годин", + "hour": "{hours} година", + "custom_hours": "Кількість годин на замовлення", + "logs": "Логи", + "developers": "Розробники", + "not_logged_in": "Ви не ввійшли в обліковий запис", + "search_mode": "Режим пошуку", + "audio_source": "Джерело аудіо", + "ok": "Гаразд", + "failed_to_encrypt": "Не вдалося зашифрувати", + "encryption_failed_warning": "Spotube використовує шифрування для безпечного зберігання ваших даних. Але не вдалося цього зробити. Тому він перейде до небезпечного зберігання\nЯкщо ви використовуєте Linux, переконайтеся, що у вас встановлено будь-який секретний сервіс (gnome-keyring, kde-wallet, keepassxc тощо)", + "querying_info": "Запит інформації...", + "piped_api_down": "API Piped не працює", + "piped_down_error_instructions": "Поточний екземпляр Piped {pipedInstance} не працює\n\nЗмініть екземпляр або змініть 'Тип API' на офіційний YouTube API\n\nОбов'язково перезапустіть програму після зміни", + "you_are_offline": "Ви зараз не в мережі", + "connection_restored": "Ваше інтернет-з'єднання відновлено", + "use_system_title_bar": "Використовувати системний заголовок", + "crunching_results": "Опрацювання результатів...", + "search_to_get_results": "Почніть пошук, щоб отримати результати", + "use_amoled_mode": "Режим AMOLED", + "pitch_dark_theme": "Темна тема", + "normalize_audio": "Нормалізувати звук", + "change_cover": "Змінити обкладинку", + "add_cover": "Додати обкладинку", + "restore_defaults": "Відновити налаштування за замовчуванням", + "download_music_codec": "Завантажити кодек для музики", + "streaming_music_codec": "Кодек потокової передачі музики", + "login_with_lastfm": "Увійти з Last.fm", + "connect": "Підключити", + "disconnect_lastfm": "Відключитися від Last.fm", + "disconnect": "Відключити", + "username": "Ім'я користувача", + "password": "Пароль", + "login": "Увійти", + "login_with_your_lastfm": "Увійти в свій обліковий запис Last.fm", + "scrobble_to_lastfm": "Скробблінг на Last.fm", + "go_to_album": "Перейти до альбому", + "discord_rich_presence": "Багата присутність у Discord", + "browse_all": "Переглянути все", + "genres": "Жанри", + "explore_genres": "Досліджувати жанри", + "step_3_steps": "Скопіюйте значення cookie \"sp_dc\"", + "step_4_steps": "Вставте скопійоване значення \"sp_dc\"", + "friends": "Друзі", + "no_lyrics_available": "Вибачте, не вдалося знайти текст для цього треку" +} \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 9810e4f2..20fdb329 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -136,7 +136,6 @@ "skip_non_music": "跳过非音乐片段(屏蔽赞助商)", "blacklist_description": "已屏蔽的歌曲与艺人", "wait_for_download_to_finish": "请等待当前下载任务完成", - "download_lyrics": "下载歌曲时同时下载歌词", "desktop": "桌面端设置", "close_behavior": "点击关闭按钮行为", "close": "关闭", @@ -176,11 +175,9 @@ "step_2": "步骤 2", "step_2_steps": "1. 一旦你已经完成登录, 按 F12 键或者鼠标右击网页空白区域 > 选择“检查”以打开浏览器开发者工具(DevTools)\n2. 然后选择 \"应用(Application)\" 标签页(Chrome, Edge, Brave 等基于 Chromium 的浏览器) 或 \"存储(Storage)\" 标签页 (Firefox, Palemoon 等基于 Firefox 的浏览器))\n3. 选择 \"Cookies\" 栏目然后选择 \"https://accounts.spotify.com\" 子栏目", "step_3": "步骤 3", - "step_3_steps": "复制名称为 \"sp_dc\" 和 \"sp_key\" (或 sp_gaid) 的值(Cookie Value)", "success_emoji": "成功🥳", "success_message": "你已经成功使用 Spotify 登录。干得漂亮!", "step_4": "步骤 4", - "step_4_steps": "将 \"sp_dc\" 与 \"sp_key\" (或 sp_gaid) 的值分别复制后粘贴到对应的区域", "something_went_wrong": "某些地方出现了问题", "piped_instance": "管道服务器实例", "piped_description": "管道服务器实例用于匹配歌曲", @@ -250,9 +247,44 @@ "developers": "开发者", "not_logged_in": "你尚未登录", "search_mode": "搜索模式", - "youtube_api_type": "API 类型", + "audio_source": "音频源", "ok": "确定", "failed_to_encrypt": "加密失败", "encryption_failed_warning": "Spotube使用加密来安全地存储您的数据。但是失败了。因此,它将回退到不安全的存储\n如果您使用Linux,请确保已安装gnome-keyring、kde-wallet和keepassxc等秘密服务", - "querying_info": "正在查询信息..." + "querying_info": "正在查询信息...", + "piped_api_down": "Piped API不可用", + "piped_down_error_instructions": "当前Piped实例{pipedInstance}不可用\n\n请更改实例或将'API类型'更改为官方YouTube API\n\n更改后请确保重新启动应用程序", + "you_are_offline": "您当前处于离线状态", + "connection_restored": "您的互联网连接已恢复", + "use_system_title_bar": "使用系统标题栏", + "update_playlist": "更新播放列表", + "update": "更新", + "crunching_results": "处理结果中...", + "search_to_get_results": "搜索以获取结果", + "use_amoled_mode": "使用 AMOLED 模式", + "pitch_dark_theme": "深色主题", + "normalize_audio": "标准化音频", + "change_cover": "更改封面", + "add_cover": "添加封面", + "restore_defaults": "恢复默认值", + "download_music_codec": "下载音乐编解码器", + "streaming_music_codec": "流媒体音乐编解码器", + "login_with_lastfm": "使用 Last.fm 登录", + "connect": "连接", + "disconnect_lastfm": "断开 Last.fm 连接", + "disconnect": "断开连接", + "username": "用户名", + "password": "密码", + "login": "登录", + "login_with_your_lastfm": "使用您的 Last.fm 帐户登录", + "scrobble_to_lastfm": "在 Last.fm 上记录播放", + "go_to_album": "前往专辑", + "discord_rich_presence": "Discord 丰富展现", + "browse_all": "浏览全部", + "genres": "音乐类型", + "explore_genres": "探索音乐类型", + "step_3_steps": "复制\"sp_dc\" Cookie的值", + "step_4_steps": "粘贴复制的\"sp_dc\"值", + "friends": "朋友", + "no_lyrics_available": "抱歉,无法找到此曲的歌词" } \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 449cf5fb..51d4ef7c 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -5,19 +5,32 @@ /// maboroshin@github => Japanese /// iceyear@github => Simplified Chinese /// TexturedPolak@github => Polish +/// yuri-val@github => Ukrainian +/// energywave@github, ncvescera@github, OpenCode@github => Italian +/// mdksec@github => Turkish +/// Stephan-P@github, SecularSteve@github => Dutch import 'package:flutter/material.dart'; class L10n { static final all = [ const Locale('en'), + const Locale('ar', 'SA'), const Locale('bn', 'BD'), - const Locale('de', 'GE'), const Locale('ca', 'AD'), + const Locale('de', 'GE'), const Locale('es', 'ES'), + const Locale("fa", "IR"), const Locale('fr', 'FR'), + const Locale('ne', 'NP'), const Locale('hi', 'IN'), + const Locale('it', 'IT'), const Locale('ja', 'JP'), - const Locale('zh', 'CN'), + const Locale('nl', 'NL'), const Locale('pl', 'PL'), + const Locale('pt', 'PT'), + const Locale('ru', 'RU'), + const Locale('uk', 'UA'), + const Locale('tr', 'TR'), + const Locale('zh', 'CN'), ]; } diff --git a/lib/main.dart b/lib/main.dart index 1c4b9365..b6afa85c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,7 @@ -import 'dart:io'; - -import 'package:args/args.dart'; -import 'package:catcher/catcher.dart'; +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 'package:fl_query_connectivity_plus_adapter/fl_query_connectivity_plus_adapter.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -15,66 +12,39 @@ import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:media_kit/media_kit.dart'; import 'package:metadata_god/metadata_god.dart'; -import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotube/collections/initializers.dart'; import 'package:spotube/collections/routes.dart'; import 'package:spotube/collections/intents.dart'; -import 'package:spotube/hooks/use_disable_battery_optimizations.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_get_storage_perms.dart'; import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/models/logger.dart'; -import 'package:spotube/models/matched_track.dart'; import 'package:spotube/models/skip_segment.dart'; +import 'package:spotube/models/source_match.dart'; import 'package:spotube/provider/palette_provider.dart'; -import 'package:spotube/provider/user_preferences_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/themes/theme.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:system_theme/system_theme.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:spotube/hooks/use_init_sys_tray.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'; Future main(List rawArgs) async { - final parser = ArgParser(); - - parser.addFlag( - 'verbose', - abbr: 'v', - help: 'Verbose mode', - defaultsTo: !kReleaseMode, - callback: (verbose) { - if (verbose) { - logEnv['VERBOSE'] = 'true'; - logEnv['DEBUG'] = 'true'; - logEnv['ERROR'] = 'true'; - } - }, - ); - parser.addFlag( - "version", - help: "Print version and exit", - negatable: false, - ); - - parser.addFlag("help", abbr: "h", negatable: false); - - final arguments = parser.parse(rawArgs); - - if (arguments["help"] == true) { - print(parser.usage); - exit(0); - } - - if (arguments["version"] == true) { - final package = await PackageInfo.fromPlatform(); - print("Spotube v${package.version}"); - exit(0); - } + final arguments = await startCLI(rawArgs); final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); + await registerWindowsScheme("spotify"); + FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); MediaKit.ensureInitialized(); @@ -84,14 +54,9 @@ Future main(List rawArgs) async { await FlutterDisplayMode.setHighRefreshRate(); } - await DesktopTools.ensureInitialized( - DesktopWindowOptions( - hideTitleBar: true, - title: "Spotube", - backgroundColor: Colors.transparent, - minimumSize: const Size(300, 700), - ), - ); + if (DesktopTools.platform.isDesktop) { + await DesktopTools.window.setPreventClose(true); + } await SystemTheme.accentColor.load(); @@ -99,24 +64,30 @@ Future main(List rawArgs) async { MetadataGod.initialize(); } + if (DesktopTools.platform.isWindows || DesktopTools.platform.isLinux) { + DiscordRPC.initialize(); + } + final hiveCacheDir = kIsWeb ? null : (await getApplicationSupportDirectory()).path; await QueryClient.initialize( cachePrefix: "oss.krtirtho.spotube", cacheDir: hiveCacheDir, - connectivity: FlQueryConnectivityPlusAdapter(), + connectivity: FlQueryInternetConnectionCheckerAdapter(), ); - Hive.registerAdapter(MatchedTrackAdapter()); + Hive.registerAdapter(SkipSegmentAdapter()); - Hive.registerAdapter(SearchModeAdapter()); + + Hive.registerAdapter(SourceMatchAdapter()); + Hive.registerAdapter(SourceTypeAdapter()); // Cache versioning entities with Adapter - MatchedTrack.version = 'v1'; + SourceMatch.version = 'v1'; SkipSegment.version = 'v1'; - await Hive.openLazyBox( - MatchedTrack.boxName, + await Hive.openLazyBox( + SourceMatch.boxName, path: hiveCacheDir, ); await Hive.openLazyBox( @@ -127,9 +98,18 @@ Future main(List rawArgs) async { path: hiveCacheDir, ); - Catcher( + await DesktopTools.ensureInitialized( + DesktopWindowOptions( + hideTitleBar: true, + title: "Spotube", + backgroundColor: Colors.transparent, + minimumSize: const Size(300, 700), + ), + ); + + Catcher2( enableLogger: arguments["verbose"], - debugConfig: CatcherOptions( + debugConfig: Catcher2Options( SilentReportMode(), [ ConsoleHandler( @@ -139,7 +119,7 @@ Future main(List rawArgs) async { if (!kIsWeb) FileHandler(await getLogsPath(), printLogs: false), ], ), - releaseConfig: CatcherOptions( + releaseConfig: Catcher2Options( SilentReportMode(), [ if (arguments["verbose"] ?? false) ConsoleHandler(), @@ -199,11 +179,17 @@ class SpotubeState extends ConsumerState { ref.watch(userPreferencesProvider.select((s) => s.themeMode)); final accentMaterialColor = ref.watch(userPreferencesProvider.select((s) => s.accentColorScheme)); + final isAmoledTheme = + ref.watch(userPreferencesProvider.select((s) => s.amoledDarkTheme)); final locale = ref.watch(userPreferencesProvider.select((s) => s.locale)); final paletteColor = ref.watch(paletteProvider.select((s) => s?.dominantColor?.color)); + useDisableBatteryOptimizations(); useInitSysTray(ref); + useDeepLinking(ref); + useCloseBehavior(ref); + useGetStoragePermissions(ref); useEffect(() { FlutterNativeSplash.remove(); @@ -211,19 +197,20 @@ class SpotubeState extends ConsumerState { /// For enabling hot reload for audio player if (!kDebugMode) return; audioPlayer.dispose(); - // youtube.close(); }; }, []); - useDisableBatterOptimizations(); - final lightTheme = useMemoized( - () => theme(paletteColor ?? accentMaterialColor, Brightness.light), + () => theme(paletteColor ?? accentMaterialColor, Brightness.light, false), [paletteColor, accentMaterialColor], ); final darkTheme = useMemoized( - () => theme(paletteColor ?? accentMaterialColor, Brightness.dark), - [paletteColor, accentMaterialColor], + () => theme( + paletteColor ?? accentMaterialColor, + Brightness.dark, + isAmoledTheme, + ), + [paletteColor, accentMaterialColor, isAmoledTheme], ); return MaterialApp.router( @@ -235,15 +222,15 @@ class SpotubeState extends ConsumerState { GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], - routeInformationParser: router.routeInformationParser, - routerDelegate: router.routerDelegate, - routeInformationProvider: router.routeInformationProvider, + routerConfig: router, debugShowCheckedModeBanner: false, title: 'Spotube', builder: (context, child) { return DevicePreview.appBuilder( context, - DragToResizeArea(child: child!), + DesktopTools.platform.isDesktop + ? DragToResizeArea(child: child!) + : child, ); }, themeMode: themeMode, @@ -260,22 +247,22 @@ class SpotubeState extends ConsumerState { LogicalKeySet(LogicalKeyboardKey.comma, LogicalKeyboardKey.control): NavigationIntent(router, "/settings"), LogicalKeySet( - LogicalKeyboardKey.keyB, + LogicalKeyboardKey.digit1, LogicalKeyboardKey.control, LogicalKeyboardKey.shift, ): HomeTabIntent(ref, tab: HomeTabs.browse), LogicalKeySet( - LogicalKeyboardKey.keyS, + LogicalKeyboardKey.digit2, LogicalKeyboardKey.control, LogicalKeyboardKey.shift, ): HomeTabIntent(ref, tab: HomeTabs.search), LogicalKeySet( - LogicalKeyboardKey.keyL, + LogicalKeyboardKey.digit3, LogicalKeyboardKey.control, LogicalKeyboardKey.shift, ): HomeTabIntent(ref, tab: HomeTabs.library), LogicalKeySet( - LogicalKeyboardKey.keyY, + LogicalKeyboardKey.digit4, LogicalKeyboardKey.control, LogicalKeyboardKey.shift, ): HomeTabIntent(ref, tab: HomeTabs.lyrics), diff --git a/lib/models/current_playlist.dart b/lib/models/current_playlist.dart index 1c3f8e16..53ea2799 100644 --- a/lib/models/current_playlist.dart +++ b/lib/models/current_playlist.dart @@ -1,6 +1,7 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/extensions/track.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; class CurrentPlaylist { List? _tempTrack; @@ -18,13 +19,13 @@ class CurrentPlaylist { this.isLocal = false, }); - static CurrentPlaylist fromJson(Map map) { + static CurrentPlaylist fromJson(Map map, Ref ref) { return CurrentPlaylist( id: map["id"], tracks: List.castFrom(map["tracks"] .map( (track) => map["isLocal"] == true - ? SpotubeTrack.fromJson(track) + ? SourcedTrack.fromJson(track, ref: ref) : Track.fromJson(track), ) .toList()), @@ -66,7 +67,7 @@ class CurrentPlaylist { "name": name, "tracks": tracks .map((track) => - track is SpotubeTrack ? track.toJson() : track.toJson()) + track is SourcedTrack ? track.toJson() : track.toJson()) .toList(), "thumbnail": thumbnail, "isLocal": isLocal, diff --git a/lib/models/local_track.dart b/lib/models/local_track.dart index b973cdbb..134cd327 100644 --- a/lib/models/local_track.dart +++ b/lib/models/local_track.dart @@ -1,6 +1,5 @@ import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/album_simple.dart'; -import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/extensions/track.dart'; class LocalTrack extends Track { final String path; @@ -38,22 +37,7 @@ class LocalTrack extends Track { Map toJson() { return { - "album": album?.toJson(), - "artists": artists?.map((artist) => artist.toJson()).toList(), - "availableMarkets": availableMarkets, - "discNumber": discNumber, - "duration": duration.toString(), - "durationMs": durationMs, - "explicit": explicit, - "href": href, - "id": id, - "isPlayable": isPlayable, - "name": name, - "popularity": popularity, - "previewUrl": previewUrl, - "trackNumber": trackNumber, - "type": type, - "uri": uri, + ...TrackJson.trackToJson(this), 'path': path, }; } diff --git a/lib/models/logger.dart b/lib/models/logger.dart index d4199173..4f687d09 100644 --- a/lib/models/logger.dart +++ b/lib/models/logger.dart @@ -37,7 +37,8 @@ class SpotubeLogger extends Logger { SpotubeLogger([this.owner]) : super(filter: _SpotubeLogFilter()); @override - void log(Level level, message, [error, StackTrace? stackTrace]) async { + void log(Level level, dynamic message, + {Object? error, StackTrace? stackTrace, DateTime? time}) async { if (!kIsWeb) { if (level == Level.error) { String dir = (await getApplicationDocumentsDirectory()).path; @@ -56,7 +57,7 @@ class SpotubeLogger extends Logger { } } - super.log(level, "[$owner] $message", error, stackTrace); + super.log(level, "[$owner] $message", error: error, stackTrace: stackTrace); } } @@ -64,7 +65,7 @@ class _SpotubeLogFilter extends DevelopmentFilter { @override bool shouldLog(LogEvent event) { if ((logEnv["DEBUG"] == "true" && event.level == Level.debug) || - (logEnv["VERBOSE"] == "true" && event.level == Level.verbose) || + (logEnv["VERBOSE"] == "true" && event.level == Level.trace) || (logEnv["ERROR"] == "true" && event.level == Level.error)) { return true; } diff --git a/lib/models/matched_track.dart b/lib/models/matched_track.dart deleted file mode 100644 index b7cc0a3f..00000000 --- a/lib/models/matched_track.dart +++ /dev/null @@ -1,69 +0,0 @@ -import "package:hive/hive.dart"; -part "matched_track.g.dart"; - -@HiveType(typeId: 1) -class MatchedTrack { - @HiveField(0) - String youtubeId; - @HiveField(1) - String spotifyId; - @HiveField(2) - SearchMode searchMode; - - String? id; - DateTime? createdAt; - - bool get isSynced => id != null; - - static String version = 'v1'; - static final boxName = "oss.krtirtho.spotube.matched_tracks.$version"; - - static LazyBox get box => Hive.lazyBox(boxName); - - MatchedTrack({ - required this.youtubeId, - required this.spotifyId, - required this.searchMode, - this.id, - this.createdAt, - }); - - factory MatchedTrack.fromJson(Map json) { - return MatchedTrack( - searchMode: SearchMode.fromString(json["searchMode"]), - youtubeId: json["youtube_id"], - spotifyId: json["spotify_id"], - id: json["id"], - createdAt: DateTime.parse(json["created_at"]), - ); - } - - Map toJson() { - return { - "youtube_id": youtubeId, - "spotify_id": spotifyId, - "id": id, - "searchMode": searchMode.name, - "created_at": createdAt?.toString() - }..removeWhere((key, value) => value == null); - } -} - -@HiveType(typeId: 4) -enum SearchMode { - @HiveField(0) - youtube._internal('YouTube'), - @HiveField(1) - youtubeMusic._internal('YouTube Music'); - - final String label; - - const SearchMode._internal(this.label); - - factory SearchMode.fromString(String value) { - return SearchMode.values.firstWhere( - (element) => element.name == value, - orElse: () => SearchMode.youtube, - ); - } -} diff --git a/lib/models/matched_track.g.dart b/lib/models/matched_track.g.dart deleted file mode 100644 index dd166e77..00000000 --- a/lib/models/matched_track.g.dart +++ /dev/null @@ -1,86 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'matched_track.dart'; - -// ************************************************************************** -// TypeAdapterGenerator -// ************************************************************************** - -class MatchedTrackAdapter extends TypeAdapter { - @override - final int typeId = 1; - - @override - MatchedTrack read(BinaryReader reader) { - final numOfFields = reader.readByte(); - final fields = { - for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), - }; - return MatchedTrack( - youtubeId: fields[0] as String, - spotifyId: fields[1] as String, - searchMode: fields[2] as SearchMode, - ); - } - - @override - void write(BinaryWriter writer, MatchedTrack obj) { - writer - ..writeByte(3) - ..writeByte(0) - ..write(obj.youtubeId) - ..writeByte(1) - ..write(obj.spotifyId) - ..writeByte(2) - ..write(obj.searchMode); - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is MatchedTrackAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} - -class SearchModeAdapter extends TypeAdapter { - @override - final int typeId = 4; - - @override - SearchMode read(BinaryReader reader) { - switch (reader.readByte()) { - case 0: - return SearchMode.youtube; - case 1: - return SearchMode.youtubeMusic; - default: - return SearchMode.youtube; - } - } - - @override - void write(BinaryWriter writer, SearchMode obj) { - switch (obj) { - case SearchMode.youtube: - writer.writeByte(0); - break; - case SearchMode.youtubeMusic: - writer.writeByte(1); - break; - } - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SearchModeAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} diff --git a/lib/models/source_match.dart b/lib/models/source_match.dart new file mode 100644 index 00000000..57a9f963 --- /dev/null +++ b/lib/models/source_match.dart @@ -0,0 +1,54 @@ +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 new file mode 100644 index 00000000..11f34bf3 --- /dev/null +++ b/lib/models/source_match.g.dart @@ -0,0 +1,119 @@ +// 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_friends.dart b/lib/models/spotify_friends.dart new file mode 100644 index 00000000..b386fb81 --- /dev/null +++ b/lib/models/spotify_friends.dart @@ -0,0 +1,111 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'spotify_friends.g.dart'; + +@JsonSerializable(createToJson: false) +class SpotifyFriend { + final String uri; + final String name; + final String imageUrl; + + const SpotifyFriend({ + required this.uri, + required this.name, + required this.imageUrl, + }); + + factory SpotifyFriend.fromJson(Map json) => + _$SpotifyFriendFromJson(json); + + String get id => uri.split(":").last; +} + +@JsonSerializable(createToJson: false) +class SpotifyActivityArtist { + final String uri; + final String name; + + const SpotifyActivityArtist({required this.uri, required this.name}); + + factory SpotifyActivityArtist.fromJson(Map json) => + _$SpotifyActivityArtistFromJson(json); + + String get id => uri.split(":").last; +} + +@JsonSerializable(createToJson: false) +class SpotifyActivityAlbum { + final String uri; + final String name; + + const SpotifyActivityAlbum({required this.uri, required this.name}); + + factory SpotifyActivityAlbum.fromJson(Map json) => + _$SpotifyActivityAlbumFromJson(json); + + String get id => uri.split(":").last; +} + +@JsonSerializable(createToJson: false) +class SpotifyActivityContext { + final String uri; + final String name; + final num index; + + const SpotifyActivityContext({ + required this.uri, + required this.name, + required this.index, + }); + + factory SpotifyActivityContext.fromJson(Map json) => + _$SpotifyActivityContextFromJson(json); + + String get id => uri.split(":").last; + String get path => uri.split(":").skip(1).join("/"); +} + +@JsonSerializable(createToJson: false) +class SpotifyActivityTrack { + final String uri; + final String name; + final String imageUrl; + final SpotifyActivityArtist artist; + final SpotifyActivityAlbum album; + final SpotifyActivityContext context; + + const SpotifyActivityTrack({ + required this.uri, + required this.name, + required this.imageUrl, + required this.artist, + required this.album, + required this.context, + }); + + factory SpotifyActivityTrack.fromJson(Map json) => + _$SpotifyActivityTrackFromJson(json); + + String get id => uri.split(":").last; +} + +@JsonSerializable(createToJson: false) +class SpotifyFriendActivity { + SpotifyFriend user; + SpotifyActivityTrack track; + + SpotifyFriendActivity({required this.user, required this.track}); + + factory SpotifyFriendActivity.fromJson(Map json) => + _$SpotifyFriendActivityFromJson(json); +} + +@JsonSerializable(createToJson: false) +class SpotifyFriends { + List friends; + + SpotifyFriends({required this.friends}); + + factory SpotifyFriends.fromJson(Map json) => + _$SpotifyFriendsFromJson(json); +} diff --git a/lib/models/spotify_friends.g.dart b/lib/models/spotify_friends.g.dart new file mode 100644 index 00000000..4a32dd09 --- /dev/null +++ b/lib/models/spotify_friends.g.dart @@ -0,0 +1,65 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'spotify_friends.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +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( + uri: json['uri'] as String, + name: json['name'] as String, + ); + +SpotifyActivityAlbum _$SpotifyActivityAlbumFromJson( + Map json) => + SpotifyActivityAlbum( + uri: json['uri'] as String, + name: json['name'] as String, + ); + +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( + 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), + context: SpotifyActivityContext.fromJson( + json['context'] as Map), + ); + +SpotifyFriendActivity _$SpotifyFriendActivityFromJson( + Map json) => + SpotifyFriendActivity( + user: SpotifyFriend.fromJson(json['user'] as Map), + track: + SpotifyActivityTrack.fromJson(json['track'] as Map), + ); + +SpotifyFriends _$SpotifyFriendsFromJson(Map json) => + SpotifyFriends( + friends: (json['friends'] as List) + .map((e) => SpotifyFriendActivity.fromJson(e as Map)) + .toList(), + ); diff --git a/lib/models/spotube_track.dart b/lib/models/spotube_track.dart deleted file mode 100644 index 268a273e..00000000 --- a/lib/models/spotube_track.dart +++ /dev/null @@ -1,267 +0,0 @@ -import 'dart:async'; - -import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/album_simple.dart'; -import 'package:spotube/extensions/artist_simple.dart'; -import 'package:spotube/models/matched_track.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/services/youtube/youtube.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:collection/collection.dart'; - -final officialMusicRegex = RegExp( - r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)", - caseSensitive: false, -); - -class SpotubeTrack extends Track { - final YoutubeVideoInfo ytTrack; - final String ytUri; - - final List siblings; - - SpotubeTrack( - this.ytTrack, - this.ytUri, - this.siblings, - ) : super(); - - SpotubeTrack.fromTrack({ - required Track track, - required this.ytTrack, - required this.ytUri, - required this.siblings, - }) : super() { - album = track.album; - artists = track.artists; - availableMarkets = track.availableMarkets; - discNumber = track.discNumber; - durationMs = track.durationMs; - explicit = track.explicit; - externalIds = track.externalIds; - externalUrls = track.externalUrls; - href = track.href; - id = track.id; - isPlayable = track.isPlayable; - linkedFrom = track.linkedFrom; - name = track.name; - popularity = track.popularity; - previewUrl = track.previewUrl; - trackNumber = track.trackNumber; - type = track.type; - uri = track.uri; - } - - static Future> fetchSiblings( - Track track, - YoutubeEndpoints client, - ) async { - final artists = (track.artists ?? []) - .map((ar) => ar.name) - .toList() - .whereNotNull() - .toList(); - - final title = ServiceUtils.getTitle( - track.name!, - artists: artists, - onlyCleanArtist: true, - ).trim(); - - final query = "$title - ${artists.join(", ")}"; - final List siblings = await client.search(query).then( - (res) { - final isYoutubeApi = - client.preferences.youtubeApiType == YoutubeApiType.youtube; - final siblings = isYoutubeApi || - client.preferences.searchMode == SearchMode.youtube - ? ServiceUtils.onlyContainsEnglish(query) - ? res - : res - .sorted((a, b) => b.views.compareTo(a.views)) - .map((sibling) { - int score = 0; - - for (final artist in artists) { - final isSameChannelArtist = - sibling.channelName.toLowerCase() == - artist.toLowerCase(); - final channelContainsArtist = sibling.channelName - .toLowerCase() - .contains(artist.toLowerCase()); - - if (isSameChannelArtist || channelContainsArtist) { - score += 1; - } - - final titleContainsArtist = sibling.title - .toLowerCase() - .contains(artist.toLowerCase()); - - if (titleContainsArtist) { - score += 1; - } - } - - final titleContainsTrackName = sibling.title - .toLowerCase() - .contains(track.name!.toLowerCase()); - - final hasOfficialFlag = officialMusicRegex - .hasMatch(sibling.title.toLowerCase()); - - if (titleContainsTrackName) { - score += 3; - } - - if (hasOfficialFlag) { - score += 1; - } - - if (hasOfficialFlag && titleContainsTrackName) { - score += 2; - } - - return (sibling: sibling, score: score); - }) - .sorted((a, b) => b.score.compareTo(a.score)) - .map((e) => e.sibling) - : res.sorted((a, b) => b.views.compareTo(a.views)).where((item) { - return artists.any( - (artist) => - artist.toLowerCase() == item.channelName.toLowerCase(), - ); - }); - - return siblings.take(10).toList(); - }, - ); - - return siblings; - } - - static Future fetchFromTrack( - Track track, - YoutubeEndpoints client, - ) async { - final matchedCachedTrack = await MatchedTrack.box.get(track.id!); - var siblings = []; - YoutubeVideoInfo ytVideo; - String ytStreamUrl; - if (matchedCachedTrack != null && - matchedCachedTrack.searchMode == client.preferences.searchMode) { - (ytVideo, ytStreamUrl) = await client.video( - matchedCachedTrack.youtubeId, - matchedCachedTrack.searchMode, - ); - } else { - siblings = await fetchSiblings(track, client); - if (siblings.isEmpty) { - throw Exception("Failed to find any results for ${track.name}"); - } - (ytVideo, ytStreamUrl) = - await client.video(siblings.first.id, siblings.first.searchMode); - - await MatchedTrack.box.put( - track.id!, - MatchedTrack( - youtubeId: ytVideo.id, - spotifyId: track.id!, - searchMode: siblings.first.searchMode, - ), - ); - } - - return SpotubeTrack.fromTrack( - track: track, - ytTrack: ytVideo, - ytUri: ytStreamUrl, - siblings: siblings, - ); - } - - Future swappedCopy( - YoutubeVideoInfo video, - YoutubeEndpoints client, - ) async { - // sibling tracks that were manually searched and swapped - final isStepSibling = siblings.none((element) => element.id == video.id); - - final (ytVideo, ytStreamUrl) = - await client.video(video.id, siblings.first.searchMode); - - if (!isStepSibling) { - await MatchedTrack.box.put( - id!, - MatchedTrack( - youtubeId: video.id, - spotifyId: id!, - searchMode: siblings.first.searchMode, - ), - ); - } - - return SpotubeTrack.fromTrack( - track: this, - ytTrack: ytVideo, - ytUri: ytStreamUrl, - siblings: [ - video, - ...siblings.where((element) => element.id != video.id), - ], - ); - } - - static SpotubeTrack fromJson(Map map) { - return SpotubeTrack.fromTrack( - track: Track.fromJson(map), - ytTrack: YoutubeVideoInfo.fromJson(map["ytTrack"]), - ytUri: map["ytUri"], - siblings: List.castFrom>(map["siblings"]) - .map((sibling) => YoutubeVideoInfo.fromJson(sibling)) - .toList(), - ); - } - - Future populatedCopy(YoutubeEndpoints client) async { - if (this.siblings.isNotEmpty) return this; - - final siblings = await fetchSiblings( - this, - client, - ); - - return SpotubeTrack.fromTrack( - track: this, - ytTrack: ytTrack, - ytUri: ytUri, - siblings: siblings, - ); - } - - Map toJson() { - return { - // super values - "album": album?.toJson(), - "artists": artists?.map((artist) => artist.toJson()).toList(), - "availableMarkets": availableMarkets, - "discNumber": discNumber, - "duration": duration.toString(), - "durationMs": durationMs, - "explicit": explicit, - "href": href, - "id": id, - "isPlayable": isPlayable, - "name": name, - "popularity": popularity, - "previewUrl": previewUrl, - "trackNumber": trackNumber, - "type": type, - "uri": uri, - // this values - "ytTrack": ytTrack.toJson(), - "ytUri": ytUri, - "siblings": siblings.map((sibling) => sibling.toJson()).toList(), - }; - } -} diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index 84e23e8b..72f9a9af 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -1,157 +1,79 @@ +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:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/heart_button.dart'; -import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart'; -import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.dart'; -import 'package:spotube/components/shared/track_table/tracks_table_view.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/models/spotube_track.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.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/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/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class AlbumPage extends HookConsumerWidget { final AlbumSimple album; - const AlbumPage(this.album, {Key? key}) : super(key: key); - - Future playPlaylist( - List tracks, - WidgetRef ref, { - Track? currentTrack, - }) async { - final playlist = ref.read(ProxyPlaylistNotifier.provider); - final playback = ref.read(ProxyPlaylistNotifier.notifier); - final sortBy = ref.read(trackCollectionSortState(album.id!)); - final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy); - currentTrack ??= sortedTracks.first; - final isAlbumPlaying = playlist.containsTracks(tracks); - if (!isAlbumPlaying) { - playback.addCollection(album.id!); // for enabling loading indicator - await playback.load( - sortedTracks, - initialIndex: sortedTracks.indexWhere((s) => s.id == currentTrack?.id), - ); - playback.addCollection(album.id!); - } else if (isAlbumPlaying && - currentTrack.id != null && - currentTrack.id != playlist.activeTrack?.id) { - await playback.jumpToTrack(currentTrack); - } - } + const AlbumPage({ + Key? key, + required this.album, + }) : super(key: key); @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playback = ref.watch(ProxyPlaylistNotifier.notifier); + final spotify = ref.watch(spotifyProvider); + final tracksQuery = useQueries.album.tracksOf(ref, album); - final tracksSnapshot = useQueries.album.tracksOf(ref, album.id!); + final tracks = useMemoized(() { + return tracksQuery.pages.expand((element) => element).toList(); + }, [tracksQuery.pages]); - final albumArt = useMemoized( - () => TypeConversionUtils.image_X_UrlString( - album.images, - placeholder: ImagePlaceholder.albumArt, - ), - [album.images]); + final client = useQueryClient(); - final mediaQuery = MediaQuery.of(context); + final albumIsSaved = useQueries.album.isSavedForMe(ref, album.id!); + final isLiked = albumIsSaved.data ?? false; - final isAlbumPlaying = useMemoized( - () => playlist.collections.contains(album.id!), - [playlist, album], - ); - - final albumTrackPlaying = useMemoized( - () => - tracksSnapshot.data?.any((s) => s.id! == playlist.activeTrack?.id!) == - true && - playlist.activeTrack is SpotubeTrack, - [playlist.activeTrack, tracksSnapshot.data], - ); - - return TrackCollectionView( - id: album.id!, - playingState: isAlbumPlaying && albumTrackPlaying - ? PlayButtonState.playing - : isAlbumPlaying && !albumTrackPlaying - ? PlayButtonState.loading - : PlayButtonState.notPlaying, - title: album.name!, - titleImage: albumArt, - tracksSnapshot: tracksSnapshot, - album: album, - routePath: "/album/${album.id}", - bottomSpace: mediaQuery.mdAndDown, - onPlay: ([track]) { - if (tracksSnapshot.hasData) { - if (!isAlbumPlaying) { - playPlaylist( - tracksSnapshot.data! - .map((track) => - TypeConversionUtils.simpleTrack_X_Track(track, album)) - .toList(), - ref, - ); - } else if (isAlbumPlaying && track != null) { - playPlaylist( - tracksSnapshot.data! - .map((track) => - TypeConversionUtils.simpleTrack_X_Track(track, album)) - .toList(), - currentTrack: track, - ref, - ); - } else { - playback - .removeTracks(tracksSnapshot.data!.map((track) => track.id!)); - } - } + final toggleAlbumLike = useMutations.album.toggleFavorite( + ref, + album.id!, + refreshQueries: [albumIsSaved.key], + onData: (_, __) async { + await client.refreshInfiniteQueryAllPages("current-user-albums"); }, - onAddToQueue: () { - if (tracksSnapshot.hasData && !isAlbumPlaying) { - playback.addTracks( - tracksSnapshot.data! + ); + + return InheritedTrackView( + collectionId: album.id!, + image: TypeConversionUtils.image_X_UrlString( + album.images, + placeholder: ImagePlaceholder.albumArt, + ), + 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(), - ); - playback.addCollection(album.id!); - } - }, - onShare: () { - Clipboard.setData( - ClipboardData(text: "https://open.spotify.com/album/${album.id}"), - ); - }, - heartBtn: AlbumHeartButton(album: album), - onShuffledPlay: ([track]) { - // Shuffle the tracks (create a copy of playlist) - if (tracksSnapshot.hasData) { - final tracks = tracksSnapshot.data! - .map((track) => - TypeConversionUtils.simpleTrack_X_Track(track, album)) - .toList() - ..shuffle(); - if (!isAlbumPlaying) { - playPlaylist( - tracks, - ref, - ); - } else if (isAlbumPlaying && track != null) { - playPlaylist( - tracks, - ref, - currentTrack: track, - ); - } else { - // TODO: Disable ability to stop playback from playlist/album - // playback.stop(); - } - } - }, + .toList(); + }); + }, + ), + routePath: "/album/${album.id}", + shareUrl: album.externalUrls!.spotify!, + isLiked: isLiked, + onHeart: albumIsSaved.hasData + ? () { + toggleAlbumLike.mutate(isLiked); + } + : null, + child: const TrackView(), ); } } diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index 44e40423..d511cb97 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -1,31 +1,19 @@ -import 'package:collection/collection.dart'; -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/spotube_icons.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_artist_profile.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/shared/track_table/track_tile.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/artist/artist_album_list.dart'; -import 'package:spotube/components/artist/artist_card.dart'; -import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_breakpoint_value.dart'; import 'package:spotube/models/logger.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.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/utils/primitive_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - class ArtistPage extends HookConsumerWidget { final String artistId; final logger = getLogger(ArtistPage); @@ -33,427 +21,62 @@ class ArtistPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - SpotifyApi spotify = ref.watch(spotifyProvider); - final parentScrollController = useScrollController(); + final scrollController = useScrollController(); final theme = Theme.of(context); - final scaffoldMessenger = ScaffoldMessenger.of(context); - final textTheme = theme.textTheme; - final chipTextVariant = useBreakpointValue( - xs: textTheme.bodySmall, - sm: textTheme.bodySmall, - md: textTheme.bodyMedium, - lg: textTheme.bodyLarge, - xl: textTheme.titleSmall, - xxl: textTheme.titleMedium, - ); - final mediaQuery = MediaQuery.of(context); - - final avatarWidth = useBreakpointValue( - xs: mediaQuery.size.width * 0.50, - sm: mediaQuery.size.width * 0.50, - md: mediaQuery.size.width * 0.40, - lg: mediaQuery.size.width * 0.18, - xl: mediaQuery.size.width * 0.18, - xxl: mediaQuery.size.width * 0.18, - ); - - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - - final auth = ref.watch(AuthenticationNotifier.provider); - - final queryClient = useQueryClient(); + final artistQuery = useQueries.artist.get(ref, artistId); return SafeArea( bottom: false, child: Scaffold( appBar: const PageWindowTitleBar( leading: BackButton(), + backgroundColor: Colors.transparent, ), - body: HookBuilder( - builder: (context) { - final artistsQuery = useQueries.artist.get(ref, artistId); - - if (artistsQuery.isLoading || !artistsQuery.hasData) { - return const ShimmerArtistProfile(); - } else if (artistsQuery.hasError) { - return Center( - child: Text(artistsQuery.error.toString()), - ); - } - - final data = artistsQuery.data!; - - final blacklist = ref.watch(BlackListNotifier.provider); - final isBlackListed = blacklist.contains( - BlacklistedElement.artist(artistId, data.name!), - ); - - return SingleChildScrollView( - controller: parentScrollController, - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - runAlignment: WrapAlignment.center, - children: [ - const SizedBox(width: 50), - Padding( - padding: const EdgeInsets.all(16), - child: CircleAvatar( - radius: avatarWidth, - backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - data.images, - placeholder: ImagePlaceholder.artist, - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.blue, - borderRadius: - BorderRadius.circular(50)), - child: Text( - data.type!.toUpperCase(), - style: chipTextVariant.copyWith( - color: Colors.white, - ), - ), - ), - if (isBlackListed) ...[ - const SizedBox(width: 5), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.red[400], - borderRadius: - BorderRadius.circular(50)), - child: Text( - context.l10n.blacklisted, - style: chipTextVariant.copyWith( - color: Colors.white, - ), - ), - ), - ] - ], - ), - Text( - data.name!, - style: mediaQuery.smAndDown - ? textTheme.headlineSmall - : textTheme.headlineMedium, - ), - Text( - context.l10n.followers( - PrimitiveUtils.toReadableNumber( - data.followers!.total!.toDouble(), - ), - ), - style: textTheme.bodyMedium?.copyWith( - fontWeight: mediaQuery.mdAndUp - ? FontWeight.bold - : null, - ), - ), - const SizedBox(height: 20), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (auth != null) - HookBuilder( - builder: (context) { - final isFollowingQuery = useQueries - .artist - .doIFollow(ref, artistId); - - final followUnfollow = - useCallback(() async { - try { - isFollowingQuery.data! - ? await spotify.me.unfollow( - FollowingType.artist, - [artistId], - ) - : await spotify.me.follow( - FollowingType.artist, - [artistId], - ); - await isFollowingQuery.refresh(); - - queryClient - .refreshInfiniteQueryAllPages( - "user-following-artists"); - } finally { - queryClient.refreshQuery( - "user-follows-artists-query/$artistId", - ); - } - }, [isFollowingQuery]); - - if (isFollowingQuery.isLoading || - !isFollowingQuery.hasData) { - return const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(), - ); - } - - if (isFollowingQuery.data!) { - return OutlinedButton( - onPressed: followUnfollow, - child: Text(context.l10n.following), - ); - } - - return FilledButton( - onPressed: followUnfollow, - child: Text(context.l10n.follow), - ); - }, - ), - const SizedBox(width: 5), - IconButton( - tooltip: - context.l10n.add_artist_to_blacklist, - icon: Icon( - SpotubeIcons.userRemove, - color: !isBlackListed - ? Colors.red[400] - : Colors.white, - ), - style: IconButton.styleFrom( - backgroundColor: isBlackListed - ? Colors.red[400] - : null, - ), - onPressed: () async { - if (isBlackListed) { - ref - .read(BlackListNotifier - .provider.notifier) - .remove( - BlacklistedElement.artist( - data.id!, data.name!), - ); - } else { - ref - .read(BlackListNotifier - .provider.notifier) - .add( - BlacklistedElement.artist( - data.id!, data.name!), - ); - } - }, - ), - IconButton( - icon: const Icon(SpotubeIcons.share), - onPressed: () async { - if (data.externalUrls?.spotify != null) { - await Clipboard.setData( - ClipboardData( - text: data.externalUrls!.spotify!, - ), - ); - } - - if (!context.mounted) return; - - scaffoldMessenger.showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - context.l10n.artist_url_copied, - textAlign: TextAlign.center, - ), - ), - ); - }, - ) - ], - ) - ], - ), - ), - ], - ), - const SizedBox(height: 50), - HookBuilder( - builder: (context) { - final topTracksQuery = useQueries.artist.topTracksOf( - ref, - artistId, - ); - - final isPlaylistPlaying = playlist.containsTracks( - topTracksQuery.data ?? [], - ); - - if (topTracksQuery.isLoading || - !topTracksQuery.hasData) { - return const CircularProgressIndicator(); - } else if (topTracksQuery.hasError) { - return Center( - child: Text(topTracksQuery.error.toString()), - ); - } - - final topTracks = topTracksQuery.data!; - - 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); - } - } - - return Column( - children: [ - Row( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.l10n.top_tracks, - style: theme.textTheme.headlineSmall, - ), - ), - if (!isPlaylistPlaying) - IconButton( - icon: const Icon( - SpotubeIcons.queueAdd, - ), - onPressed: () { - playlistNotifier - .addTracks(topTracks.toList()); - scaffoldMessenger.showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - context.l10n.added_to_queue( - topTracks.length, - ), - textAlign: TextAlign.center, - ), - ), - ); - }, - ), - const SizedBox(width: 5), - IconButton( - icon: Icon( - isPlaylistPlaying - ? SpotubeIcons.stop - : SpotubeIcons.play, - color: Colors.white, - ), - style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.primary, - ), - onPressed: () => - playPlaylist(topTracks.toList()), - ) - ], - ), - ...topTracks.mapIndexed((i, track) { - return TrackTile( - index: i, - track: track, - onTap: () { - playPlaylist( - topTracks.toList(), - currentTrack: track, - ); - }, - ); - }), - ], - ); - }, - ), - const SizedBox(height: 50), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.l10n.albums, - style: theme.textTheme.headlineSmall, - ), - ), - ArtistAlbumList(artistId), - const SizedBox(height: 20), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.l10n.fans_also_like, - style: theme.textTheme.headlineSmall, - ), - ), - const SizedBox(height: 10), - HookBuilder( - builder: (context) { - final relatedArtists = - useQueries.artist.relatedArtistsOf( - ref, - artistId, - ); - - if (relatedArtists.isLoading || - !relatedArtists.hasData) { - return const CircularProgressIndicator(); - } else if (relatedArtists.hasError) { - return Center( - child: Text(relatedArtists.error.toString()), - ); - } - - return Center( - child: Wrap( - spacing: 20, - runSpacing: 20, - children: relatedArtists.data! - .map((artist) => ArtistCard(artist)) - .toList(), - ), - ); - }, - ), - ], + extendBodyBehindAppBar: true, + body: Builder(builder: (context) { + if (artistQuery.hasError && artistQuery.data == null) { + return Center(child: Text(artistQuery.error.toString())); + } + return Skeletonizer( + enabled: artistQuery.isLoading, + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverToBoxAdapter( + child: SafeArea( + bottom: false, + child: ArtistPageHeader(artistId: artistId), + ), ), - ), - ); - }, - ), + const SliverGap(50), + ArtistPageTopTracks(artistId: artistId), + const SliverGap(50), + SliverToBoxAdapter(child: ArtistAlbumList(artistId)), + const SliverGap(20), + SliverPadding( + padding: const EdgeInsets.all(8.0), + sliver: SliverToBoxAdapter( + child: Text( + context.l10n.fans_also_like, + style: theme.textTheme.headlineSmall, + ), + ), + ), + SliverSafeArea( + sliver: ArtistPageRelatedArtists(artistId: artistId), + ), + if (artistQuery.data != null) + SliverSafeArea( + top: false, + sliver: SliverToBoxAdapter( + child: ArtistPageFooter(artist: artistQuery.data!), + ), + ), + ], + ), + ); + }), ), ); } diff --git a/lib/pages/artist/section/footer.dart b/lib/pages/artist/section/footer.dart new file mode 100644 index 00000000..b01ef705 --- /dev/null +++ b/lib/pages/artist/section/footer.dart @@ -0,0 +1,93 @@ +import 'package:flutter/gestures.dart'; +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/extensions/constrains.dart'; +import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class ArtistPageFooter extends HookConsumerWidget { + final Artist artist; + const ArtistPageFooter({Key? key, required this.artist}) : super(key: key); + + @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, + placeholder: ImagePlaceholder.artist, + ); + final summary = useQueries.artist.wikipediaSummary(artist); + if (summary.hasError || !summary.hasData) return const SizedBox.shrink(); + return Container( + margin: const EdgeInsets.all(16), + padding: mediaQuery.smAndDown + ? const EdgeInsets.all(20) + : const EdgeInsets.all(30), + constraints: const BoxConstraints(minHeight: 300), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + image: DecorationImage( + colorFilter: ColorFilter.mode( + Colors.black.withOpacity(0.5), + BlendMode.darken, + ), + image: UniversalImage.imageProvider( + summary.data!.thumbnail?.source_ ?? artistImage, + height: summary.data!.thumbnail?.height.toDouble(), + width: summary.data!.thumbnail?.width.toDouble(), + ), + fit: BoxFit.cover, + alignment: Alignment.center, + ), + ), + alignment: Alignment.center, + child: RichText( + text: TextSpan( + style: textTheme.bodyLarge?.copyWith( + color: Colors.white, + ), + children: [ + // icon + const WidgetSpan( + child: Icon( + SpotubeIcons.wikipedia, + color: Colors.white, + size: 30, + ), + ), + TextSpan( + text: " Wikipedia", + style: textTheme.titleLarge?.copyWith( + color: Colors.white, + ), + ), + const TextSpan(text: '\n\n'), + TextSpan( + text: summary.data!.extract, + ), + TextSpan( + text: '\n...read more at wikipedia', + style: textTheme.bodyLarge?.copyWith( + color: Colors.lightBlue[300], + decoration: TextDecoration.underline, + decorationColor: Colors.lightBlue[300], + ), + recognizer: TapGestureRecognizer() + ..onTap = () async { + await launchUrlString( + "http://en.wikipedia.org/wiki?curid=${summary.data?.pageid}", + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart new file mode 100644 index 00000000..7cee7a01 --- /dev/null +++ b/lib/pages/artist/section/header.dart @@ -0,0 +1,259 @@ +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/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; +import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/blacklist_provider.dart'; +import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/services/queries/queries.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); + + @override + Widget build(BuildContext context, ref) { + final queryClient = useQueryClient(); + final artistQuery = useQueries.artist.get(ref, artistId); + final artist = artistQuery.data ?? FakeData.artist; + + final scaffoldMessenger = ScaffoldMessenger.of(context); + final mediaQuery = MediaQuery.of(context); + final theme = Theme.of(context); + final ThemeData(:textTheme) = theme; + + final chipTextVariant = useBreakpointValue( + xs: textTheme.bodySmall, + sm: textTheme.bodySmall, + md: textTheme.bodyMedium, + lg: textTheme.bodyLarge, + xl: textTheme.titleSmall, + 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 image = TypeConversionUtils.image_X_UrlString( + artist.images, + placeholder: ImagePlaceholder.artist, + ); + + return LayoutBuilder( + builder: (context, constrains) { + return Center( + child: Flex( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: constrains.smAndDown + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + direction: constrains.smAndDown ? Axis.vertical : Axis.horizontal, + children: [ + DecoratedBox( + decoration: BoxDecoration( + boxShadow: kElevationToShadow[2], + borderRadius: BorderRadius.circular(35), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(35), + child: UniversalImage( + path: image, + width: 250, + height: 250, + fit: BoxFit.cover, + ), + ), + ), + const Gap(20), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.blue, + borderRadius: BorderRadius.circular(50)), + child: Skeleton.keep( + child: Text( + artist.type!.toUpperCase(), + style: chipTextVariant.copyWith( + color: Colors.white, + ), + ), + ), + ), + if (isBlackListed) ...[ + const SizedBox(width: 5), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.red[400], + borderRadius: BorderRadius.circular(50)), + child: Text( + context.l10n.blacklisted, + style: chipTextVariant.copyWith( + color: Colors.white, + ), + ), + ), + ] + ], + ), + Text( + artist.name!, + style: mediaQuery.smAndDown + ? textTheme.headlineSmall + : textTheme.headlineMedium, + ), + Text( + context.l10n.followers( + PrimitiveUtils.toReadableNumber( + artist.followers!.total!.toDouble(), + ), + ), + style: textTheme.bodyMedium?.copyWith( + fontWeight: mediaQuery.mdAndUp ? FontWeight.bold : null, + ), + ), + const Gap(20), + Skeleton.keep( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (auth != null) + HookBuilder( + builder: (context) { + final isFollowingQuery = + useQueries.artist.doIFollow(ref, artistId); + + final followUnfollow = useCallback(() async { + try { + isFollowingQuery.data! + ? await spotify.me.unfollow( + FollowingType.artist, + [artistId], + ) + : await spotify.me.follow( + FollowingType.artist, + [artistId], + ); + await isFollowingQuery.refresh(); + + queryClient.refreshInfiniteQueryAllPages( + "user-following-artists"); + } finally { + queryClient.refreshQuery( + "user-follows-artists-query/$artistId", + ); + } + }, [isFollowingQuery]); + + if (isFollowingQuery.isLoading || + !isFollowingQuery.hasData) { + return const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(), + ); + } + + if (isFollowingQuery.data!) { + return OutlinedButton( + onPressed: followUnfollow, + child: Text(context.l10n.following), + ); + } + + return FilledButton( + onPressed: followUnfollow, + child: Text(context.l10n.follow), + ); + }, + ), + const SizedBox(width: 5), + IconButton( + tooltip: context.l10n.add_artist_to_blacklist, + icon: Icon( + SpotubeIcons.userRemove, + color: + !isBlackListed ? Colors.red[400] : Colors.white, + ), + style: IconButton.styleFrom( + backgroundColor: + isBlackListed ? Colors.red[400] : null, + ), + onPressed: () async { + if (isBlackListed) { + ref + .read(BlackListNotifier.provider.notifier) + .remove( + BlacklistedElement.artist( + artist.id!, artist.name!), + ); + } else { + ref.read(BlackListNotifier.provider.notifier).add( + BlacklistedElement.artist( + artist.id!, artist.name!), + ); + } + }, + ), + IconButton( + icon: const Icon(SpotubeIcons.share), + onPressed: () async { + if (artist.externalUrls?.spotify != null) { + await Clipboard.setData( + ClipboardData( + text: artist.externalUrls!.spotify!, + ), + ); + } + + if (!context.mounted) return; + + scaffoldMessenger.showSnackBar( + SnackBar( + width: 300, + behavior: SnackBarBehavior.floating, + content: Text( + context.l10n.artist_url_copied, + textAlign: TextAlign.center, + ), + ), + ); + }, + ) + ], + ), + ) + ], + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/pages/artist/section/related_artists.dart b/lib/pages/artist/section/related_artists.dart new file mode 100644 index 00000000..2938c084 --- /dev/null +++ b/lib/pages/artist/section/related_artists.dart @@ -0,0 +1,49 @@ +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'; + +class ArtistPageRelatedArtists extends HookConsumerWidget { + final String artistId; + const ArtistPageRelatedArtists({ + Key? key, + required this.artistId, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final relatedArtists = useQueries.artist.relatedArtistsOf( + ref, + 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 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, + ), + itemBuilder: (context, index) { + final artist = relatedArtists.data!.elementAt(index); + return ArtistCard(artist); + }, + ), + ); + } +} diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart new file mode 100644 index 00000000..771757b9 --- /dev/null +++ b/lib/pages/artist/section/top_tracks.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/fake.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/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'; + +class ArtistPageTopTracks extends HookConsumerWidget { + final String artistId; + const ArtistPageTopTracks({Key? key, required this.artistId}) + : super(key: key); + + @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 isPlaylistPlaying = playlist.containsTracks( + topTracksQuery.data ?? [], + ); + + if (topTracksQuery.hasError) { + return SliverToBoxAdapter( + child: Center( + child: Text(topTracksQuery.error.toString()), + ), + ); + } + + final topTracks = + topTracksQuery.data ?? 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); + } + } + + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: Row( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + context.l10n.top_tracks, + style: theme.textTheme.headlineSmall, + ), + ), + if (!isPlaylistPlaying) + IconButton( + icon: const Icon( + SpotubeIcons.queueAdd, + ), + onPressed: () { + playlistNotifier.addTracks(topTracks.toList()); + scaffoldMessenger.showSnackBar( + SnackBar( + width: 300, + behavior: SnackBarBehavior.floating, + content: Text( + context.l10n.added_to_queue( + topTracks.length, + ), + textAlign: TextAlign.center, + ), + ), + ); + }, + ), + const SizedBox(width: 5), + IconButton( + icon: Skeleton.keep( + child: Icon( + isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play, + color: Colors.white, + ), + ), + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + ), + onPressed: () => playPlaylist(topTracks.toList()), + ) + ], + ), + ), + SliverList.builder( + itemCount: topTracks.length, + itemBuilder: (context, index) { + final track = topTracks.elementAt(index); + return TrackTile( + index: index, + track: track, + onTap: () async { + playPlaylist( + topTracks.toList(), + currentTrack: track, + ); + }, + ); + }, + ), + ], + ); + } +} diff --git a/lib/pages/home/genres.dart b/lib/pages/home/genres.dart deleted file mode 100644 index af0d3836..00000000 --- a/lib/pages/home/genres.dart +++ /dev/null @@ -1,123 +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:spotify/spotify.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/genre/category_card.dart'; -import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_categories.dart'; -import 'package:spotube/components/shared/waypoint.dart'; - -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; - -class GenrePage extends HookConsumerWidget { - const GenrePage({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final scrollController = useScrollController(); - final recommendationMarket = ref.watch( - userPreferencesProvider.select((s) => s.recommendationMarket), - ); - final categoriesQuery = useQueries.category.list(ref, recommendationMarket); - final isFiltering = useState(false); - - final isMounted = useIsMounted(); - - final searchController = useTextEditingController(); - final searchFocus = useFocusNode(); - - useValueListenable(searchController); - - final categories = useMemoized( - () { - final categories = categoriesQuery.pages - .expand( - (page) => page.items ?? const Iterable.empty(), - ) - .toList(); - if (searchController.text.isEmpty) { - return categories; - } - return categories - .map((e) => ( - weightedRatio(e.name!, searchController.text), - e, - )) - .sorted((a, b) => b.$1.compareTo(a.$1)) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList(); - }, - [categoriesQuery.pages, searchController.text], - ); - - final list = RefreshIndicator( - onRefresh: () async { - await categoriesQuery.refreshAll(); - }, - child: Waypoint( - onTouchEdge: () async { - if (categoriesQuery.hasNextPage && isMounted()) { - await categoriesQuery.fetchNext(); - } - }, - controller: scrollController, - child: Column( - children: [ - ExpandableSearchField( - isFiltering: isFiltering, - searchController: searchController, - searchFocus: searchFocus, - ), - Expanded( - child: ListView.builder( - controller: scrollController, - itemCount: categories.length, - itemBuilder: (context, index) { - return AnimatedCrossFade( - crossFadeState: searchController.text.isEmpty && - index == categories.length - 1 && - categoriesQuery.hasNextPage - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - duration: const Duration(milliseconds: 300), - firstChild: const ShimmerCategories(), - secondChild: CategoryCard(categories[index]), - ); - }, - ), - ), - ], - ), - ), - ); - - return Stack( - children: [ - Positioned.fill(child: list), - Positioned( - top: 0, - right: 10, - child: ExpandableSearchButton( - isFiltering: isFiltering, - searchFocus: searchFocus, - icon: const Icon(SpotubeIcons.search), - onPressed: (value) { - if (isFiltering.value) { - scrollController.animateTo( - 0, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - }, - ), - ), - ], - ); - } -} diff --git a/lib/pages/home/genres/genre_playlists.dart b/lib/pages/home/genres/genre_playlists.dart new file mode 100644 index 00000000..78f32245 --- /dev/null +++ b/lib/pages/home/genres/genre_playlists.dart @@ -0,0 +1,174 @@ +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:skeletonizer/skeletonizer.dart'; +import 'package:spotify/spotify.dart' hide Offset; +import 'package:spotube/collections/fake.dart'; +import 'package:spotube/components/playlist/playlist_card.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/shared/waypoint.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/services/queries/queries.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; + +class GenrePlaylistsPage extends HookConsumerWidget { + final Category category; + const GenrePlaylistsPage({Key? key, required this.category}) + : super(key: key); + + @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 scrollController = useScrollController(); + + return Scaffold( + appBar: DesktopTools.platform.isDesktop + ? const PageWindowTitleBar( + leading: BackButton(color: Colors.white), + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + ) + : null, + extendBodyBehindAppBar: true, + body: CustomScrollView( + controller: scrollController, + slivers: [ + SliverAppBar( + automaticallyImplyLeading: DesktopTools.platform.isMobile, + expandedHeight: mediaQuery.mdAndDown ? 200 : 150, + pinned: true, + floating: false, + title: const Text(""), + backgroundColor: Colors.brown.withOpacity(0.7), + flexibleSpace: FlexibleSpaceBar( + stretchModes: const [ + StretchMode.zoomBackground, + StretchMode.blurBackground, + ], + background: DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: UniversalImage.imageProvider( + category.icons!.first.url!, + ), + fit: BoxFit.cover, + ), + ), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: const ColoredBox(color: Colors.transparent), + ), + ), + centerTitle: DesktopTools.platform.isDesktop, + title: Text( + category.name!, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: Colors.white, + letterSpacing: 3, + shadows: [ + const Shadow( + offset: Offset(-1.5, -1.5), + color: Colors.black54, + ), + const Shadow( + offset: Offset(1.5, -1.5), + color: Colors.black54, + ), + const Shadow( + offset: Offset(1.5, 1.5), + color: Colors.black54, + ), + const Shadow( + offset: Offset(-1.5, 1.5), + color: Colors.black54, + ), + ], + ), + ), + collapseMode: CollapseMode.parallax, + ), + ), + const SliverGap(20), + SliverSafeArea( + top: false, + sliver: SliverPadding( + padding: EdgeInsets.symmetric( + horizontal: mediaQuery.mdAndDown ? 12 : 24, + ), + sliver: playlists.isEmpty + ? Skeletonizer.sliver( + child: SliverToBoxAdapter( + child: Wrap( + spacing: 12, + runSpacing: 12, + children: List.generate( + 6, + (index) => PlaylistCard(FakeData.playlist), + ), + ), + ), + ) + : SliverGrid.builder( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 190, + mainAxisExtent: mediaQuery.mdAndDown ? 225 : 250, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: playlists.length + 1, + itemBuilder: (context, index) { + final playlist = playlists.elementAtOrNull(index); + + if (playlist == null) { + if (!playlistsQuery.hasNextPage) { + return const SizedBox.shrink(); + } + return Skeletonizer( + enabled: true, + child: Waypoint( + controller: scrollController, + isGrid: true, + onTouchEdge: () async { + if (playlistsQuery.hasNextPage) { + await playlistsQuery.fetchNext(); + } + }, + child: PlaylistCard(FakeData.playlist), + ), + ); + } + + return Skeleton.keep( + child: PlaylistCard(playlist), + ); + }, + ), + ), + ), + const SliverGap(20), + ], + ), + ); + } +} diff --git a/lib/pages/home/genres/genres.dart b/lib/pages/home/genres/genres.dart new file mode 100644 index 00000000..dc165fe4 --- /dev/null +++ b/lib/pages/home/genres/genres.dart @@ -0,0 +1,98 @@ +import 'dart:math'; + +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart' hide Offset; +import 'package:spotube/collections/gradients.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; + +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/queries/queries.dart'; + +class GenrePage extends HookConsumerWidget { + const GenrePage({Key? key}) : super(key: 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 mediaQuery = MediaQuery.of(context); + + return Scaffold( + appBar: PageWindowTitleBar( + title: Text(context.l10n.explore_genres), + automaticallyImplyLeading: true, + ), + body: SafeArea( + top: false, + child: GridView.builder( + padding: const EdgeInsets.all(12), + controller: scrollController, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + childAspectRatio: 9 / 18, + maxCrossAxisExtent: mediaQuery.smAndDown ? 200 : 300, + mainAxisExtent: 200, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: categories.length, + itemBuilder: (context, index) { + final category = categories[index]; + final gradient = gradients[Random().nextInt(gradients.length)]; + return InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () { + context.push("/genre/${category.id}", extra: category); + }, + child: Ink( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: DecorationImage( + image: NetworkImage(category.icons!.first.url!), + fit: BoxFit.cover, + ), + gradient: gradient, + ), + child: Align( + alignment: Alignment.bottomCenter, + child: AutoSizeText( + category.name!, + style: textTheme.titleLarge?.copyWith( + color: Colors.white, + shadows: [ + // stroke shadow + const Shadow( + color: Colors.black, + offset: Offset(1, 1), + blurRadius: 2, + ), + ], + ), + maxLines: 1, + textAlign: TextAlign.center, + maxFontSize: textTheme.titleLarge!.fontSize!, + minFontSize: textTheme.titleMedium!.fontSize!, + ), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 34f136b6..dbec0564 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -1,36 +1,43 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/widgets.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/components/shared/themed_button_tab_bar.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/home/genres.dart'; -import 'package:spotube/pages/home/personalized.dart'; class HomePage extends HookConsumerWidget { const HomePage({Key? key}) : super(key: key); @override Widget build(BuildContext context, ref) { - return DefaultTabController( - length: 2, - child: Scaffold( - appBar: PageWindowTitleBar( - centerTitle: true, - leadingWidth: double.infinity, - leading: ThemedButtonsTabBar( - tabs: [ - Tab(text: " ${context.l10n.personalized} "), - Tab(text: " ${context.l10n.genre} "), + final controller = useScrollController(); + + return SafeArea( + bottom: false, + child: Scaffold( + appBar: + DesktopTools.platform.isLinux || DesktopTools.platform.isWindows + ? const PageWindowTitleBar() + : null, + body: CustomScrollView( + controller: controller, + slivers: [ + if (DesktopTools.platform.isMacOS || DesktopTools.platform.isWeb) + const SliverGap(20), + const HomeGenresSection(), + const SliverToBoxAdapter(child: HomeFeaturedSection()), + const HomePageFriendsSection(), + const SliverToBoxAdapter(child: HomeNewReleasesSection()), + const SliverSafeArea(sliver: HomeMadeForUserSection()), ], ), - ), - body: const TabBarView( - children: [ - PersonalizedPage(), - GenrePage(), - ], - ), - ), - ); + )); } } diff --git a/lib/pages/home/personalized.dart b/lib/pages/home/personalized.dart deleted file mode 100644 index 29f6ecb5..00000000 --- a/lib/pages/home/personalized.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'package:flutter/gestures.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/album/album_card.dart'; -import 'package:spotube/components/playlist/playlist_card.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; -import 'package:spotube/components/shared/waypoint.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class PersonalizedItemCard extends HookWidget { - final Iterable? playlists; - final Iterable? albums; - final String title; - final bool hasNextPage; - final void Function() onFetchMore; - - PersonalizedItemCard({ - this.playlists, - this.albums, - required this.title, - required this.hasNextPage, - required this.onFetchMore, - Key? key, - }) : assert(playlists == null || albums == null), - super(key: key); - - final logger = getLogger(PersonalizedItemCard); - - @override - Widget build(BuildContext context) { - final scrollController = useScrollController(); - - return Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - title, - style: Theme.of(context).textTheme.titleLarge, - ), - ), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - controller: scrollController, - interactive: false, - child: Waypoint( - controller: scrollController, - onTouchEdge: hasNextPage ? onFetchMore : null, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: scrollController, - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ...?playlists?.map((playlist) => PlaylistCard(playlist)), - ...?albums?.map( - (album) => AlbumCard( - TypeConversionUtils.simpleAlbum_X_Album(album), - ), - ), - if (hasNextPage) const ShimmerPlaybuttonCard(count: 1), - ], - ), - ), - ), - ), - ), - ], - ), - ); - } -} - -class PersonalizedPage extends HookConsumerWidget { - const PersonalizedPage({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final auth = ref.watch(AuthenticationNotifier.provider); - final featuredPlaylistsQuery = useQueries.playlist.featured(ref); - final playlists = useMemoized( - () => featuredPlaylistsQuery.pages - .whereType>() - .expand((page) => page.items ?? const []), - [featuredPlaylistsQuery.pages], - ); - - final madeForUser = useQueries.views.get(ref, "made-for-x-hub"); - - final newReleases = useQueries.album.newReleases(ref); - final userArtists = useQueries.artist - .followedByMeAll(ref) - .data - ?.map((s) => s.id!) - .toList() ?? - const []; - - final albums = useMemoized( - () => newReleases.pages - .whereType>() - .expand((page) => page.items ?? const []) - .where((album) { - return album.artists - ?.any((artist) => userArtists.contains(artist.id!)) == - true; - }), - [newReleases.pages], - ); - - return ListView( - children: [ - PersonalizedItemCard( - playlists: playlists, - title: context.l10n.featured, - hasNextPage: featuredPlaylistsQuery.hasNextPage, - onFetchMore: featuredPlaylistsQuery.fetchNext, - ), - if (auth != null) - PersonalizedItemCard( - albums: albums, - title: context.l10n.new_releases, - hasNextPage: newReleases.hasNextPage, - onFetchMore: newReleases.fetchNext, - ), - ...?madeForUser.data?["content"]?["items"]?.map((item) { - final playlists = item["content"]?["items"] - ?.where((itemL2) => itemL2["type"] == "playlist") - .map((itemL2) => PlaylistSimple.fromJson(itemL2)) - .toList() - .cast() ?? - []; - if (playlists.isEmpty) return const SizedBox.shrink(); - return PersonalizedItemCard( - playlists: playlists, - title: item["name"] ?? "", - hasNextPage: false, - onFetchMore: () {}, - ); - }) - ], - ); - } -} diff --git a/lib/pages/lastfm_login/lastfm_login.dart b/lib/pages/lastfm_login/lastfm_login.dart new file mode 100644 index 00000000..4280328f --- /dev/null +++ b/lib/pages/lastfm_login/lastfm_login.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:form_validator/form_validator.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/scrobbler_provider.dart'; + +class LastFMLoginPage extends HookConsumerWidget { + const LastFMLoginPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final theme = Theme.of(context); + final router = GoRouter.of(context); + final scrobblerNotifier = ref.read(scrobblerProvider.notifier); + + final formKey = useMemoized(() => GlobalKey(), []); + final username = useTextEditingController(); + final password = useTextEditingController(); + final passwordVisible = useState(false); + + final isLoading = useState(false); + + return Scaffold( + appBar: const PageWindowTitleBar(leading: BackButton()), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Card( + margin: const EdgeInsets.all(8.0), + child: Padding( + padding: const EdgeInsets.all(16.0).copyWith(top: 8), + child: Form( + key: formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30), + color: const Color.fromARGB(255, 186, 0, 0), + ), + padding: const EdgeInsets.all(12), + child: const Icon( + SpotubeIcons.lastFm, + color: Colors.white, + size: 60, + ), + ), + Text( + "last.fm", + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 10), + Text(context.l10n.login_with_your_lastfm), + const SizedBox(height: 10), + AutofillGroup( + child: Column( + children: [ + TextFormField( + autofillHints: const [ + AutofillHints.username, + AutofillHints.email, + ], + controller: username, + validator: ValidationBuilder().required().build(), + decoration: InputDecoration( + labelText: context.l10n.username, + ), + ), + const SizedBox(height: 10), + TextFormField( + autofillHints: const [ + AutofillHints.password, + ], + controller: password, + validator: ValidationBuilder().required().build(), + obscureText: !passwordVisible.value, + decoration: InputDecoration( + labelText: context.l10n.password, + suffixIcon: IconButton( + icon: Icon( + passwordVisible.value + ? SpotubeIcons.eye + : SpotubeIcons.noEye, + ), + onPressed: () => passwordVisible.value = + !passwordVisible.value, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 10), + FilledButton( + onPressed: isLoading.value + ? null + : () async { + try { + isLoading.value = true; + if (formKey.currentState?.validate() != true) { + return; + } + await scrobblerNotifier.login( + username.text.trim(), + password.text, + ); + router.pop(); + } catch (e) { + if (context.mounted) { + showPromptDialog( + context: context, + title: context.l10n + .error("Authentication failed"), + message: e.toString(), + cancelText: null, + ); + } + } finally { + isLoading.value = false; + } + }, + child: Text(context.l10n.login), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 2a2e16ca..802b28d3 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -17,7 +17,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart'; import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/provider/user_preferences_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'; @@ -37,7 +37,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { final genresCollection = useQueries.category.genreSeeds(ref); final limit = useValueNotifier(10); - final market = useValueNotifier(preferences.recommendationMarket); + final market = useValueNotifier(preferences.recommendationMarket); final genres = useState>([]); final artists = useState>([]); @@ -220,7 +220,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { final countrySelector = ValueListenableBuilder( valueListenable: market, builder: (context, value, _) { - return DropdownButtonFormField( + return DropdownButtonFormField( decoration: InputDecoration( labelText: context.l10n.country, labelStyle: textTheme.titleMedium, @@ -242,267 +242,284 @@ class PlaylistGeneratorPage extends HookConsumerWidget { }, ); + final controller = useScrollController(); + return Scaffold( appBar: PageWindowTitleBar( leading: const BackButton(), title: Text(context.l10n.generate_playlist), centerTitle: true, ), - body: Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: Breakpoints.lg), - child: SliderTheme( - data: const SliderThemeData( - overlayShape: RoundSliderOverlayShape(), - ), - child: SafeArea( - child: LayoutBuilder(builder: (context, constrains) { - return ListView( - padding: const EdgeInsets.all(16), - children: [ - ValueListenableBuilder( - valueListenable: limit, - builder: (context, value, child) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.number_of_tracks_generate, - style: textTheme.titleMedium, - ), - Row( + body: Scrollbar( + controller: controller, + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: Breakpoints.lg), + child: SliderTheme( + data: const SliderThemeData( + overlayShape: RoundSliderOverlayShape(), + ), + child: SafeArea( + child: LayoutBuilder(builder: (context, constrains) { + return ScrollConfiguration( + behavior: ScrollConfiguration.of(context) + .copyWith(scrollbars: false), + child: ListView( + controller: controller, + padding: const EdgeInsets.all(16), + children: [ + ValueListenableBuilder( + valueListenable: limit, + builder: (context, value, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 40, - height: 40, - alignment: Alignment.center, - decoration: BoxDecoration( - color: theme.colorScheme.primary, - shape: BoxShape.circle, - ), - child: Text( - value.round().toString(), - style: textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.primaryContainer, - ), - ), + Text( + context.l10n.number_of_tracks_generate, + style: textTheme.titleMedium, ), - Expanded( - child: Slider( - value: value.toDouble(), - min: 10, - max: 100, - divisions: 9, - label: value.round().toString(), - onChanged: (value) { - limit.value = value.round(); - }, - ), + Row( + children: [ + Container( + width: 40, + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + color: theme.colorScheme.primary, + shape: BoxShape.circle, + ), + child: Text( + value.round().toString(), + style: textTheme.bodyLarge?.copyWith( + color: theme + .colorScheme.primaryContainer, + ), + ), + ), + Expanded( + child: Slider( + value: value.toDouble(), + min: 10, + max: 100, + divisions: 9, + label: value.round().toString(), + onChanged: (value) { + limit.value = value.round(); + }, + ), + ) + ], ) ], - ) - ], - ); - }, - ), - const SizedBox(height: 16), - if (constrains.mdAndUp) - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: countrySelector, - ), - const SizedBox(width: 16), - Expanded( - child: genreSelector, - ), + ); + }, + ), + const SizedBox(height: 16), + if (constrains.mdAndUp) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: countrySelector, + ), + const SizedBox(width: 16), + Expanded( + child: genreSelector, + ), + ], + ) + else ...[ + countrySelector, + const SizedBox(height: 16), + genreSelector, ], - ) - else ...[ - countrySelector, - const SizedBox(height: 16), - genreSelector, - ], - const SizedBox(height: 16), - if (constrains.mdAndUp) - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: artistAutoComplete, - ), - const SizedBox(width: 16), - Expanded( - child: tracksAutocomplete, - ), + const SizedBox(height: 16), + if (constrains.mdAndUp) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: artistAutoComplete, + ), + const SizedBox(width: 16), + Expanded( + child: tracksAutocomplete, + ), + ], + ) + else ...[ + artistAutoComplete, + const SizedBox(height: 16), + tracksAutocomplete, ], - ) - else ...[ - artistAutoComplete, - const SizedBox(height: 16), - tracksAutocomplete, - ], - const SizedBox(height: 16), - RecommendationAttributeDials( - title: Text(context.l10n.acousticness), - values: acousticness.value, - onChanged: (value) { - acousticness.value = value; - }, + const SizedBox(height: 16), + RecommendationAttributeDials( + title: Text(context.l10n.acousticness), + values: acousticness.value, + onChanged: (value) { + acousticness.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.danceability), + values: danceability.value, + onChanged: (value) { + danceability.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.energy), + values: energy.value, + onChanged: (value) { + energy.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.instrumentalness), + values: instrumentalness.value, + onChanged: (value) { + instrumentalness.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.liveness), + values: liveness.value, + onChanged: (value) { + liveness.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.loudness), + values: loudness.value, + onChanged: (value) { + loudness.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.speechiness), + values: speechiness.value, + onChanged: (value) { + speechiness.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.valence), + values: valence.value, + onChanged: (value) { + valence.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.popularity), + values: popularity.value, + base: 100, + onChanged: (value) { + popularity.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.key), + values: key.value, + base: 11, + onChanged: (value) { + key.value = value; + }, + ), + RecommendationAttributeFields( + title: Text(context.l10n.duration), + values: ( + max: durationMs.value.max / 1000, + target: durationMs.value.target / 1000, + min: durationMs.value.min / 1000, + ), + onChanged: (value) { + durationMs.value = ( + max: value.max * 1000, + target: value.target * 1000, + min: value.min * 1000, + ); + }, + presets: { + context.l10n.short: (min: 50, target: 90, max: 120), + context.l10n.medium: ( + min: 120, + target: 180, + max: 200 + ), + context.l10n.long: (min: 480, target: 560, max: 640) + }, + ), + RecommendationAttributeFields( + title: Text(context.l10n.tempo), + values: tempo.value, + onChanged: (value) { + tempo.value = value; + }, + ), + RecommendationAttributeFields( + title: Text(context.l10n.mode), + values: mode.value, + onChanged: (value) { + mode.value = value; + }, + ), + RecommendationAttributeFields( + title: Text(context.l10n.time_signature), + values: timeSignature.value, + onChanged: (value) { + timeSignature.value = value; + }, + ), + const SizedBox(height: 20), + FilledButton.icon( + icon: const Icon(SpotubeIcons.magic), + label: Text(context.l10n.generate_playlist), + onPressed: artists.value.isEmpty && + tracks.value.isEmpty && + genres.value.isEmpty + ? null + : () { + final PlaylistGenerateResultRouteState + routeState = ( + seeds: ( + artists: artists.value + .map((a) => a.id!) + .toList(), + tracks: tracks.value + .map((t) => t.id!) + .toList(), + genres: genres.value + ), + market: market.value, + limit: limit.value, + parameters: ( + acousticness: acousticness.value, + danceability: danceability.value, + energy: energy.value, + instrumentalness: instrumentalness.value, + liveness: liveness.value, + loudness: loudness.value, + speechiness: speechiness.value, + valence: valence.value, + popularity: popularity.value, + key: key.value, + duration_ms: durationMs.value, + tempo: tempo.value, + mode: mode.value, + time_signature: timeSignature.value, + ) + ); + GoRouter.of(context).push( + "/library/generate/result", + extra: routeState, + ); + }, + ), + ], ), - RecommendationAttributeDials( - title: Text(context.l10n.danceability), - values: danceability.value, - onChanged: (value) { - danceability.value = value; - }, - ), - RecommendationAttributeDials( - title: Text(context.l10n.energy), - values: energy.value, - onChanged: (value) { - energy.value = value; - }, - ), - RecommendationAttributeDials( - title: Text(context.l10n.instrumentalness), - values: instrumentalness.value, - onChanged: (value) { - instrumentalness.value = value; - }, - ), - RecommendationAttributeDials( - title: Text(context.l10n.liveness), - values: liveness.value, - onChanged: (value) { - liveness.value = value; - }, - ), - RecommendationAttributeDials( - title: Text(context.l10n.loudness), - values: loudness.value, - onChanged: (value) { - loudness.value = value; - }, - ), - RecommendationAttributeDials( - title: Text(context.l10n.speechiness), - values: speechiness.value, - onChanged: (value) { - speechiness.value = value; - }, - ), - RecommendationAttributeDials( - title: Text(context.l10n.valence), - values: valence.value, - onChanged: (value) { - valence.value = value; - }, - ), - RecommendationAttributeDials( - title: Text(context.l10n.popularity), - values: popularity.value, - base: 100, - onChanged: (value) { - popularity.value = value; - }, - ), - RecommendationAttributeDials( - title: Text(context.l10n.key), - values: key.value, - base: 11, - onChanged: (value) { - key.value = value; - }, - ), - RecommendationAttributeFields( - title: Text(context.l10n.duration), - values: ( - max: durationMs.value.max / 1000, - target: durationMs.value.target / 1000, - min: durationMs.value.min / 1000, - ), - onChanged: (value) { - durationMs.value = ( - max: value.max * 1000, - target: value.target * 1000, - min: value.min * 1000, - ); - }, - presets: { - context.l10n.short: (min: 50, target: 90, max: 120), - context.l10n.medium: (min: 120, target: 180, max: 200), - context.l10n.long: (min: 480, target: 560, max: 640) - }, - ), - RecommendationAttributeFields( - title: Text(context.l10n.tempo), - values: tempo.value, - onChanged: (value) { - tempo.value = value; - }, - ), - RecommendationAttributeFields( - title: Text(context.l10n.mode), - values: mode.value, - onChanged: (value) { - mode.value = value; - }, - ), - RecommendationAttributeFields( - title: Text(context.l10n.time_signature), - values: timeSignature.value, - onChanged: (value) { - timeSignature.value = value; - }, - ), - const SizedBox(height: 20), - FilledButton.icon( - icon: const Icon(SpotubeIcons.magic), - label: Text(context.l10n.generate_playlist), - onPressed: artists.value.isEmpty && - tracks.value.isEmpty && - genres.value.isEmpty - ? null - : () { - final PlaylistGenerateResultRouteState - routeState = ( - seeds: ( - artists: - artists.value.map((a) => a.id!).toList(), - tracks: - tracks.value.map((t) => t.id!).toList(), - genres: genres.value - ), - market: market.value, - limit: limit.value, - parameters: ( - acousticness: acousticness.value, - danceability: danceability.value, - energy: energy.value, - instrumentalness: instrumentalness.value, - liveness: liveness.value, - loudness: loudness.value, - speechiness: speechiness.value, - valence: valence.value, - popularity: popularity.value, - key: key.value, - duration_ms: durationMs.value, - tempo: tempo.value, - mode: mode.value, - time_signature: timeSignature.value, - ) - ); - GoRouter.of(context).push( - "/library/generate/result", - extra: routeState, - ); - }, - ), - ], - ); - }), + ); + }), + ), ), ), ), diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index 653a2263..f751b65b 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -18,7 +18,7 @@ typedef PlaylistGenerateResultRouteState = ({ ({List tracks, List artists, List genres})? seeds, RecommendationParameters? parameters, int limit, - String? market, + Market? market, }); class PlaylistGenerateResultPage extends HookConsumerWidget { @@ -163,6 +163,7 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { context: context, builder: (context) => PlaylistAddTrackDialog( + openFromPlaylist: null, tracks: selectedTracks.value .map( (e) => generatedPlaylist.data! diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index c97649d7..ac4b61e7 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -11,8 +11,8 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/themed_button_tab_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_custom_status_bar_color.dart'; -import 'package:spotube/hooks/use_palette_color.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'; diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index e99674c8..2cf73728 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -1,6 +1,7 @@ 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'; @@ -11,7 +12,7 @@ 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/extensions/context.dart'; -import 'package:spotube/hooks/use_force_update.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'; @@ -19,23 +20,23 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/platform.dart'; class MiniLyricsPage extends HookConsumerWidget { - const MiniLyricsPage({Key? key}) : super(key: key); + final Size prevSize; + const MiniLyricsPage({Key? key, required this.prevSize}) : super(key: key); @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); final update = useForceUpdate(); - final prevSize = useRef(null); final wasMaximized = useRef(false); final playlistQueue = ref.watch(ProxyPlaylistNotifier.provider); final areaActive = useState(false); final hoverMode = useState(true); + final showLyrics = useState(true); useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) async { - prevSize.value = await DesktopTools.window.getSize(); wasMaximized.value = await DesktopTools.window.isMaximized(); }); return null; @@ -83,17 +84,41 @@ class MiniLyricsPage extends HookConsumerWidget { child: Sidebar.brandLogo(), ), const Spacer(), - SizedBox( - height: 30, - child: TabBar( - tabs: [ - Tab(text: context.l10n.synced), - Tab(text: context.l10n.plain), - ], - isScrollable: true, + if (showLyrics.value) + SizedBox( + height: 30, + child: TabBar( + tabs: [ + Tab(text: context.l10n.synced), + Tab(text: context.l10n.plain), + ], + isScrollable: true, + ), ), - ), const Spacer(), + IconButton( + tooltip: context.l10n.lyrics, + icon: showLyrics.value + ? const Icon(SpotubeIcons.lyrics) + : const Icon(SpotubeIcons.lyricsOff), + style: ButtonStyle( + foregroundColor: showLyrics.value + ? MaterialStateProperty.all( + theme.colorScheme.primary) + : null, + ), + onPressed: () async { + showLyrics.value = !showLyrics.value; + areaActive.value = true; + hoverMode.value = false; + + await DesktopTools.window.setSize( + showLyrics.value + ? const Size(400, 500) + : const Size(400, 150), + ); + }, + ), IconButton( tooltip: context.l10n.show_hide_ui_on_hover, icon: hoverMode.value @@ -106,9 +131,7 @@ class MiniLyricsPage extends HookConsumerWidget { : null, ), onPressed: () async { - if (!hoverMode.value == true) { - areaActive.value = true; - } + areaActive.value = true; hoverMode.value = !hoverMode.value; }, ), @@ -151,22 +174,25 @@ class MiniLyricsPage extends HookConsumerWidget { playlistQueue.activeTrack!.name!, style: theme.textTheme.titleMedium, ), - Expanded( - child: TabBarView( - children: [ - SyncedLyrics( - palette: PaletteColor(theme.colorScheme.background, 0), - isModal: true, - defaultTextZoom: 65, - ), - PlainLyrics( - palette: PaletteColor(theme.colorScheme.background, 0), - isModal: true, - defaultTextZoom: 65, - ), - ], - ), - ), + if (showLyrics.value) + Expanded( + child: TabBarView( + children: [ + SyncedLyrics( + palette: PaletteColor(theme.colorScheme.background, 0), + isModal: true, + defaultTextZoom: 65, + ), + PlainLyrics( + palette: PaletteColor(theme.colorScheme.background, 0), + isModal: true, + defaultTextZoom: 65, + ), + ], + ), + ) + else + const Gap(20), AnimatedCrossFade( crossFadeState: areaActive.value ? CrossFadeState.showFirst @@ -213,7 +239,7 @@ class MiniLyricsPage extends HookConsumerWidget { if (wasMaximized.value) { await DesktopTools.window.maximize(); } else { - await DesktopTools.window.setSize(prevSize.value!); + await DesktopTools.window.setSize(prevSize); } await DesktopTools.window .setAlignment(Alignment.center); diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index 1d1237e6..bee5114d 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -1,12 +1,15 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotify/spotify.dart'; +import 'package: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/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; @@ -72,10 +75,22 @@ class PlainLyrics extends HookConsumerWidget { if (lyricsQuery.isLoading || lyricsQuery.isRefreshing) { return const ShimmerLyrics(); } else if (lyricsQuery.hasError) { - return Text( - "Sorry, no Lyrics were found for `${playlist.activeTrack?.name}` :'(\n${lyricsQuery.error.toString()}", - style: textTheme.bodyLarge?.copyWith( - color: palette.bodyTextColor, + return Container( + alignment: Alignment.center, + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.l10n.no_lyrics_available, + style: textTheme.bodyLarge?.copyWith( + color: palette.bodyTextColor, + ), + textAlign: TextAlign.center, + ), + const Gap(26), + const Icon(SpotubeIcons.noLyrics, size: 60), + ], ), ); } @@ -104,7 +119,7 @@ class PlainLyrics extends HookConsumerWidget { ? 1.7 : 2, ), - child: Text( + child: SelectableText( lyrics == null && playlist.activeTrack == null ? "No Track being played currently" : lyrics ?? "", diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index 2566a7a2..ddef1c65 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -1,19 +1,23 @@ 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: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/extensions/constrains.dart'; -import 'package:spotube/hooks/use_auto_scroll_controller.dart'; -import 'package:spotube/hooks/use_synced_lyrics.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:scroll_to_index/scroll_to_index.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.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:stroke_text/stroke_text.dart'; final _delay = StateProvider((ref) => 0); @@ -43,6 +47,11 @@ class SyncedLyrics extends HookConsumerWidget { final lyricValue = timedLyricsQuery.data; + final isUnSyncLyric = useMemoized( + () => lyricValue?.lyrics.every((l) => l.time == Duration.zero), + [lyricValue], + ); + final lyricsMap = useMemoized( () => lyricValue?.lyrics @@ -70,6 +79,9 @@ class SyncedLyrics extends HookConsumerWidget { : textTheme.headlineMedium?.copyWith(fontSize: 25)) ?.copyWith(color: palette.titleTextColor); + final bodyTextTheme = textTheme.bodyLarge?.copyWith( + color: palette.bodyTextColor, + ); return Stack( children: [ Column( @@ -91,7 +103,9 @@ class SyncedLyrics extends HookConsumerWidget { : textTheme.titleLarge, ), ), - if (lyricValue != null && lyricValue.lyrics.isNotEmpty) + if (lyricValue != null && + lyricValue.lyrics.isNotEmpty && + isUnSyncLyric == false) Expanded( child: ListView.builder( controller: controller, @@ -114,9 +128,7 @@ class SyncedLyrics extends HookConsumerWidget { ? Container( padding: index == lyricValue.lyrics.length - 1 ? EdgeInsets.only( - bottom: - MediaQuery.of(context).size.height / - 2, + bottom: mediaQuery.size.height / 2, ) : null, ) @@ -130,19 +142,40 @@ class SyncedLyrics extends HookConsumerWidget { child: AnimatedDefaultTextStyle( duration: const Duration(milliseconds: 250), style: TextStyle( - color: isActive - ? Colors.white - : palette.bodyTextColor, fontWeight: isActive ? FontWeight.w500 : FontWeight.normal, fontSize: (isActive ? 28 : 26) * (textZoomLevel.value / 100), - shadows: kElevationToShadow[9], ), - child: Text( - lyricSlice.text, - textAlign: TextAlign.center, + 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, + ); + }), ), ), ), @@ -152,8 +185,48 @@ class SyncedLyrics extends HookConsumerWidget { ), ), if (playlist.activeTrack != null && - (lyricValue == null || lyricValue.lyrics.isEmpty == true)) - const Expanded(child: ShimmerLyrics()), + (timedLyricsQuery.isLoading || timedLyricsQuery.isRefreshing)) + const Expanded( + 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, + ), + ), + const Gap(26), + const Icon(SpotubeIcons.noLyrics, size: 60), + ] else if (isUnSyncLyric == true) + Expanded( + child: Center( + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: bodyTextTheme, + children: [ + const TextSpan( + text: + "Synced lyrics are not available for this song. Please use the", + ), + TextSpan( + text: " Plain Lyrics ", + style: textTheme.bodyLarge?.copyWith( + color: palette.bodyTextColor, + fontWeight: FontWeight.bold, + ), + ), + const TextSpan(text: "tab instead."), + ], + ), + ), + ), + ), ], ), Align( diff --git a/lib/pages/mobile_login/mobile_login.dart b/lib/pages/mobile_login/mobile_login.dart index 7ab0ea2a..8b9bce4c 100644 --- a/lib/pages/mobile_login/mobile_login.dart +++ b/lib/pages/mobile_login/mobile_login.dart @@ -55,12 +55,7 @@ class WebViewLogin extends HookConsumerWidget { final cookies = await CookieManager.instance().getCookies(url: action); final cookieHeader = - cookies.fold("", (previousValue, element) { - if (element.name == "sp_dc" || element.name == "sp_key") { - return "$previousValue; ${element.name}=${element.value}"; - } - return previousValue; - }); + "sp_dc=${cookies.firstWhere((element) => element.name == "sp_dc").value}"; authenticationNotifier.setCredentials( await AuthenticationCredentials.fromCookie(cookieHeader), diff --git a/lib/pages/playlist/liked_playlist.dart b/lib/pages/playlist/liked_playlist.dart new file mode 100644 index 00000000..1fb2e1dc --- /dev/null +++ b/lib/pages/playlist/liked_playlist.dart @@ -0,0 +1,44 @@ +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'; + +class LikedPlaylistPage extends HookConsumerWidget { + final PlaylistSimple playlist; + const LikedPlaylistPage({ + Key? key, + required this.playlist, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final likedTracks = useQueries.playlist.likedTracksQuery(ref); + final tracks = likedTracks.data ?? []; + + return InheritedTrackView( + collectionId: playlist.id!, + image: "assets/liked-tracks.jpg", + pagination: PaginationProps( + hasNextPage: false, + isLoading: false, + onFetchMore: () {}, + onFetchAll: () async { + return tracks.toList(); + }, + onRefresh: () async { + await likedTracks.refresh(); + }, + ), + title: playlist.name!, + description: playlist.description, + tracks: tracks, + routePath: '/playlist/${playlist.id}', + isLiked: false, + shareUrl: "", + onHeart: null, + child: const TrackView(), + ); + } +} diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index baee0669..29601a09 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -1,165 +1,82 @@ -import 'package:flutter/services.dart'; +import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/shared/heart_button.dart'; -import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart'; -import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.dart'; -import 'package:spotube/components/shared/track_table/tracks_table_view.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:flutter/material.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/models/spotube_track.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.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/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/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; -class PlaylistView extends HookConsumerWidget { - final logger = getLogger(PlaylistView); +class PlaylistPage extends HookConsumerWidget { final PlaylistSimple playlist; - PlaylistView(this.playlist, {Key? key}) : super(key: key); - - Future playPlaylist( - List tracks, - WidgetRef ref, { - Track? currentTrack, - }) async { - final proxyPlaylist = ref.read(ProxyPlaylistNotifier.provider); - final playback = ref.read(ProxyPlaylistNotifier.notifier); - final sortBy = ref.read(trackCollectionSortState(playlist.id!)); - final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy); - currentTrack ??= sortedTracks.first; - final isPlaylistPlaying = proxyPlaylist.containsTracks(tracks); - if (!isPlaylistPlaying) { - playback.addCollection(playlist.id!); // for enabling loading indicator - await playback.load( - sortedTracks, - initialIndex: sortedTracks.indexWhere((s) => s.id == currentTrack?.id), - autoPlay: true, - ); - playback.addCollection(playlist.id!); - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != proxyPlaylist.activeTrack?.id) { - await playback.jumpToTrack(currentTrack); - } - } + const PlaylistPage({ + Key? key, + required this.playlist, + }) : super(key: key); @override Widget build(BuildContext context, ref) { - final proxyPlaylist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final spotify = ref.watch(spotifyProvider); + final tracksQuery = useQueries.playlist.tracksOfQuery(ref, playlist.id!); - final mediaQuery = MediaQuery.of(context); - - final meSnapshot = useQueries.user.me(ref); - final tracksSnapshot = useQueries.playlist.tracksOfQuery(ref, playlist.id!); - - final isPlaylistPlaying = useMemoized( - () => proxyPlaylist.collections.contains(playlist.id!), - [proxyPlaylist, playlist], + final tracks = useMemoized( + () { + return tracksQuery.pages.expand((page) => page).toList(); + }, + [tracksQuery.pages], ); - final titleImage = useMemoized( - () => TypeConversionUtils.image_X_UrlString( - playlist.images, - placeholder: ImagePlaceholder.collection, - ), - [playlist.images]); + final me = useQueries.user.me(ref); - final playlistTrackPlaying = useMemoized( - () => - tracksSnapshot.data - ?.any((s) => s.id! == proxyPlaylist.activeTrack?.id!) == - true && - proxyPlaylist.activeTrack is SpotubeTrack, - [proxyPlaylist.activeTrack, tracksSnapshot.data], + final isLikedQuery = useQueries.playlist.doesUserFollow( + ref, + playlist.id!, + me.data?.id ?? '', ); - return TrackCollectionView( - id: playlist.id!, - playingState: isPlaylistPlaying && playlistTrackPlaying - ? PlayButtonState.playing - : isPlaylistPlaying && !playlistTrackPlaying - ? PlayButtonState.loading - : PlayButtonState.notPlaying, - title: playlist.name!, - titleImage: titleImage, - tracksSnapshot: tracksSnapshot, - description: playlist.description, - isOwned: playlist.owner?.id != null && - playlist.owner!.id == meSnapshot.data?.id, - onPlay: ([track]) { - if (tracksSnapshot.hasData) { - if (!isPlaylistPlaying) { - playPlaylist( - tracksSnapshot.data!, - ref, - currentTrack: track, - ); - } else if (isPlaylistPlaying && track != null) { - playPlaylist( - tracksSnapshot.data!, - ref, - currentTrack: track, - ); - } else { - playlistNotifier - .removeTracks(tracksSnapshot.data!.map((e) => e.id!)); - } - } - }, - onAddToQueue: () { - if (tracksSnapshot.hasData && !isPlaylistPlaying) { - playlistNotifier.addTracks(tracksSnapshot.data!); - playlistNotifier.addCollection(playlist.id!); - } - }, - bottomSpace: mediaQuery.mdAndDown, - showShare: playlist.id != "user-liked-tracks", - routePath: "/playlist/${playlist.id}", - onShare: () { - final data = "https://open.spotify.com/playlist/${playlist.id}"; - Clipboard.setData( - ClipboardData(text: data), - ).then((_) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - "Copied $data to clipboard", - textAlign: TextAlign.center, - ), - ), + final togglePlaylistLike = useMutations.playlist.toggleFavorite( + ref, + playlist.id!, + refreshQueries: [ + isLikedQuery.key, + ], + ); + + return InheritedTrackView( + collectionId: playlist.id!, + image: TypeConversionUtils.image_X_UrlString( + playlist.images, + placeholder: ImagePlaceholder.collection, + ), + pagination: PaginationProps.fromQuery( + tracksQuery, + onFetchAll: () { + return tracksQuery.fetchAllTracks( + getAllTracks: () async { + final res = await spotify.playlists + .getTracksByPlaylistId(playlist.id!) + .all(); + return res.toList(); + }, ); - }); - }, - heartBtn: PlaylistHeartButton(playlist: playlist), - onShuffledPlay: ([track]) { - final tracks = [...?tracksSnapshot.data]..shuffle(); - - if (tracksSnapshot.hasData) { - if (!isPlaylistPlaying) { - playPlaylist( - tracks, - ref, - currentTrack: track, - ); - } else if (isPlaylistPlaying && track != null) { - playPlaylist( - tracks, - ref, - currentTrack: track, - ); - } else { - // TODO: Remove the ability to stop the playlist - // playlistNotifier.stop(); - } + }, + ), + title: playlist.name!, + description: playlist.description, + tracks: tracks, + routePath: '/playlist/${playlist.id}', + isLiked: isLikedQuery.data ?? false, + shareUrl: playlist.externalUrls?.spotify ?? "", + onHeart: () async { + if (!isLikedQuery.hasData || togglePlaylistLike.isMutating) { + return; } + await togglePlaylistLike.mutate(isLikedQuery.data!); }, + child: const TrackView(), ); } } diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index e144e3c6..2ff49737 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -1,24 +1,30 @@ 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/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/hooks/use_update_checker.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/provider/download_manager_provider.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; const rootPaths = { - 0: "/", - 1: "/search", - 2: "/library", - 3: "/lyrics", + "/": 0, + "/search": 1, + "/library": 2, + "/lyrics": 3, }; class RootApp extends HookConsumerWidget { @@ -30,10 +36,12 @@ class RootApp extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final index = useState(0); final isMounted = useIsMounted(); final showingDialogCompleter = useRef(Completer()..complete()); final downloader = ref.watch(downloadManagerProvider); + final scaffoldMessenger = ScaffoldMessenger.of(context); + final theme = Theme.of(context); + final location = GoRouterState.of(context).matchedLocation; useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -44,6 +52,51 @@ class RootApp extends HookConsumerWidget { await PersistedStateNotifier.showNoEncryptionDialog(context); } }); + + final subscription = + QueryClient.connectivity.onConnectivityChanged.listen((status) { + if (status) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Row( + children: [ + Icon( + SpotubeIcons.wifi, + color: theme.colorScheme.onPrimary, + ), + const SizedBox(width: 10), + Text(context.l10n.connection_restored), + ], + ), + backgroundColor: theme.colorScheme.primary, + showCloseIcon: true, + width: 350, + ), + ); + } else { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Row( + children: [ + Icon( + SpotubeIcons.noWifi, + color: theme.colorScheme.onError, + ), + const SizedBox(width: 10), + Text(context.l10n.you_are_offline), + ], + ), + backgroundColor: theme.colorScheme.error, + showCloseIcon: true, + width: 300, + ), + ); + } + }); + + return () { + subscription.cancel(); + }; }, []); useEffect(() { @@ -82,6 +135,8 @@ class RootApp extends HookConsumerWidget { // checks for latest version of the application useUpdateChecker(ref); + useEndlessPlayback(ref); + final backgroundColor = Theme.of(context).scaffoldBackgroundColor; useEffect(() { @@ -96,26 +151,47 @@ class RootApp extends HookConsumerWidget { return null; }, [backgroundColor]); + void onSelectIndexChanged(int d) { + final invertedRouteMap = + rootPaths.map((key, value) => MapEntry(value, key)); + + if (context.mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + GoRouter.of(context).go(invertedRouteMap[d]!); + }); + } + } + return Scaffold( body: Sidebar( - selectedIndex: index.value, - onSelectedIndexChanged: (i) { - index.value = i; - GoRouter.of(context).go(rootPaths[index.value]!); - }, + 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: index.value, - onSelectedIndexChanged: (selectedIndex) { - index.value = selectedIndex; - GoRouter.of(context).go(rootPaths[selectedIndex]!); - }, + selectedIndex: rootPaths[location], + onSelectedIndexChanged: onSelectIndexChanged, ), ], ), diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index 9d5e7eed..f4a78d4f 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -1,29 +1,24 @@ import 'dart:async'; -import 'package:flutter/gestures.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/collections/spotube_icons.dart'; -import 'package:spotube/components/album/album_card.dart'; -import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.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/shared/track_table/track_tile.dart'; -import 'package:spotube/components/shared/waypoint.dart'; -import 'package:spotube/components/artist/artist_card.dart'; -import 'package:spotube/components/playlist/playlist_card.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.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/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:collection/collection.dart'; final searchTermStateProvider = StateProvider((ref) => ""); @@ -37,9 +32,6 @@ class SearchPage extends HookConsumerWidget { ref.watch(AuthenticationNotifier.provider); final authenticationNotifier = ref.watch(AuthenticationNotifier.provider.notifier); - final albumController = useScrollController(); - final playlistController = useScrollController(); - final artistController = useScrollController(); final mediaQuery = MediaQuery.of(context); final searchTerm = ref.watch(searchTermStateProvider); @@ -55,13 +47,58 @@ class SearchPage extends HookConsumerWidget { Future onSearch() async { await Future.wait([ - searchTrack.refreshAll(), - searchAlbum.refreshAll(), - searchPlaylist.refreshAll(), - searchArtist.refreshAll(), - ]); + searchTrack.reset(), + searchAlbum.reset(), + searchPlaylist.reset(), + searchArtist.reset(), + ]).then((_) { + return Future.wait([ + searchTrack.refreshAll(), + searchAlbum.refreshAll(), + searchPlaylist.refreshAll(), + searchArtist.refreshAll(), + ]); + }); } + final queries = [searchTrack, searchAlbum, searchPlaylist, searchArtist]; + final isFetching = queries.every( + (s) => + (!s.hasPageData && !s.hasPageError) || + s.isRefreshingPage || + !s.hasPageData, + ) && + searchTerm.isNotEmpty; + + final resultWidget = HookBuilder( + builder: (context) { + final controller = useScrollController(); + + return InterScrollbar( + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SearchTracksSection(query: searchTrack), + SearchPlaylistsSection(query: searchPlaylist), + const SizedBox(height: 20), + SearchArtistsSection(query: searchArtist), + const SizedBox(height: 20), + SearchAlbumsSection(query: searchAlbum), + ], + ), + ), + ), + ), + ); + }, + ); + return SafeArea( bottom: false, child: Scaffold( @@ -77,7 +114,9 @@ class SearchPage extends HookConsumerWidget { ), color: theme.scaffoldBackgroundColor, child: TextField( - autofocus: true, + autofocus: queries + .none((s) => s.hasPageData && !s.hasPageError) && + !kIsMobile, decoration: InputDecoration( prefixIcon: const Icon(SpotubeIcons.search), hintText: "${context.l10n.search}...", @@ -93,283 +132,64 @@ class SearchPage extends HookConsumerWidget { }, ), ), - HookBuilder( - builder: (context) { - final playlist = - ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = - ref.watch(ProxyPlaylistNotifier.notifier); - List albums = []; - List artists = []; - List tracks = []; - List playlists = []; - final pages = [ - ...searchTrack.pages, - ...searchAlbum.pages, - ...searchPlaylist.pages, - ...searchArtist.pages, - ].expand((page) => page).toList(); - for (MapEntry page in pages.asMap().entries) { - for (var item in page.value.items ?? []) { - if (item is AlbumSimple) { - albums.add(item); - } else if (item is PlaylistSimple) { - playlists.add(item); - } else if (item is Artist) { - artists.add(item); - } else if (item is Track) { - tracks.add(item); - } - } - } - return Expanded( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 20, - ), - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (tracks.isNotEmpty) - Text( - context.l10n.songs, - style: theme.textTheme.titleLarge!, - ), - if (searchTrack.isLoadingPage) - const CircularProgressIndicator() - else if (searchTrack.hasPageError) - Text( - searchTrack.errors.lastOrNull - ?.toString() ?? - "", - ) - else - ...tracks.mapIndexed((i, track) { - return TrackTile( - index: i, - track: track, - 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; - - if (shouldPlay) { - await playlistNotifier.load( - [track], - autoPlay: true, - ); - } - } - }, - ); - }), - if (searchTrack.hasNextPage && - tracks.isNotEmpty) - Center( - child: TextButton( - onPressed: searchTrack.isRefreshingPage - ? null - : () => searchTrack.fetchNext(), - child: searchTrack.isRefreshingPage - ? const CircularProgressIndicator() - : Text(context.l10n.load_more), - ), - ), - if (playlists.isNotEmpty) - Text( - context.l10n.playlists, - style: theme.textTheme.titleLarge!, - ), - const SizedBox(height: 10), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context) - .copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - scrollbarOrientation: mediaQuery.lgAndUp - ? ScrollbarOrientation.bottom - : ScrollbarOrientation.top, - controller: playlistController, - child: Waypoint( - onTouchEdge: () { - searchPlaylist.fetchNext(); - }, - controller: playlistController, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: playlistController, - child: Row( - children: [ - ...playlists.mapIndexed( - (i, playlist) { - if (i == - playlists.length - - 1 && - searchPlaylist - .hasNextPage) { - return const ShimmerPlaybuttonCard( - count: 1); - } - return PlaylistCard(playlist); - }, - ), - ], - ), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: searchTerm.isEmpty + ? Column( + children: [ + SizedBox( + height: mediaQuery.size.height * 0.2, + ), + Icon( + SpotubeIcons.web, + size: 120, + color: theme.colorScheme.onBackground + .withOpacity(0.7), + ), + const SizedBox(height: 20), + Text( + context.l10n.search_to_get_results, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + color: theme.colorScheme.onBackground + .withOpacity(0.5), + ), + ), + ], + ) + : isFetching + ? Container( + constraints: BoxConstraints( + maxWidth: mediaQuery.lgAndUp + ? mediaQuery.size.width * 0.5 + : mediaQuery.size.width, + ), + padding: const EdgeInsets.symmetric( + horizontal: 20, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + Text( + context.l10n.crunching_results, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w900, + color: theme.colorScheme.onBackground + .withOpacity(0.7), ), ), - ), + const SizedBox(height: 20), + const LinearProgressIndicator(), + ], ), - if (searchPlaylist.isLoadingPage) - const CircularProgressIndicator(), - if (searchPlaylist.hasPageError) - Text( - searchPlaylist.errors.lastOrNull - ?.toString() ?? - "", - ), - const SizedBox(height: 20), - if (artists.isNotEmpty) - Text( - context.l10n.artists, - style: theme.textTheme.titleLarge!, - ), - const SizedBox(height: 10), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context) - .copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - controller: artistController, - child: Waypoint( - controller: artistController, - onTouchEdge: () { - searchArtist.fetchNext(); - }, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: artistController, - child: Row( - children: [ - ...artists.mapIndexed( - (i, artist) { - if (i == artists.length - 1 && - searchArtist - .hasNextPage) { - return const ShimmerPlaybuttonCard( - count: 1); - } - return Container( - margin: const EdgeInsets - .symmetric( - horizontal: 15), - child: ArtistCard(artist), - ); - }, - ), - ], - ), - ), - ), - ), - ), - if (searchArtist.isLoadingPage) - const CircularProgressIndicator(), - if (searchArtist.hasPageError) - Text( - searchArtist.errors.lastOrNull - ?.toString() ?? - "", - ), - const SizedBox(height: 20), - if (albums.isNotEmpty) - Text( - context.l10n.albums, - style: theme.textTheme.titleMedium!, - ), - const SizedBox(height: 10), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context) - .copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - controller: albumController, - child: Waypoint( - controller: albumController, - onTouchEdge: () { - searchAlbum.fetchNext(); - }, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: albumController, - child: Row( - children: [ - ...albums.mapIndexed((i, album) { - if (i == albums.length - 1 && - searchAlbum.hasNextPage) { - return const ShimmerPlaybuttonCard( - count: 1); - } - return AlbumCard( - TypeConversionUtils - .simpleAlbum_X_Album( - album, - ), - ); - }), - ], - ), - ), - ), - ), - ), - if (searchAlbum.isLoadingPage) - const CircularProgressIndicator(), - if (searchAlbum.hasPageError) - Text( - searchAlbum.errors.lastOrNull - ?.toString() ?? - "", - ), - ], - ), - ), - ), - ), - ); - }, - ) + ) + : resultWidget, + ), + ), ], ), ), diff --git a/lib/pages/search/sections/albums.dart b/lib/pages/search/sections/albums.dart new file mode 100644 index 00000000..8aa33feb --- /dev/null +++ b/lib/pages/search/sections/albums.dart @@ -0,0 +1,40 @@ +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/extensions/context.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; + +class SearchAlbumsSection extends HookConsumerWidget { + final InfiniteQuery>, dynamic, int> query; + const SearchAlbumsSection({ + required this.query, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + 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], + ); + + return HorizontalPlaybuttonCardView( + isLoadingNextPage: query.isLoadingNextPage, + hasNextPage: query.hasNextPage, + items: albums, + onFetchMore: query.fetchNext, + title: Text(context.l10n.albums), + ); + } +} diff --git a/lib/pages/search/sections/artists.dart b/lib/pages/search/sections/artists.dart new file mode 100644 index 00000000..fe4459d6 --- /dev/null +++ b/lib/pages/search/sections/artists.dart @@ -0,0 +1,38 @@ +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/extensions/context.dart'; + +class SearchArtistsSection extends HookConsumerWidget { + final InfiniteQuery>, dynamic, int> query; + + const SearchArtistsSection({ + Key? key, + required this.query, + }) : super(key: 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], + ); + + return HorizontalPlaybuttonCardView( + isLoadingNextPage: query.isLoadingNextPage, + hasNextPage: query.hasNextPage, + items: artists, + onFetchMore: query.fetchNext, + title: Text(context.l10n.artists), + ); + } +} diff --git a/lib/pages/search/sections/playlists.dart b/lib/pages/search/sections/playlists.dart new file mode 100644 index 00000000..47614a70 --- /dev/null +++ b/lib/pages/search/sections/playlists.dart @@ -0,0 +1,36 @@ +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/extensions/context.dart'; + +class SearchPlaylistsSection extends HookConsumerWidget { + final InfiniteQuery>, dynamic, int> query; + const SearchPlaylistsSection({ + required this.query, + Key? key, + }) : super(key: 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], + ); + + return HorizontalPlaybuttonCardView( + isLoadingNextPage: query.isLoadingNextPage, + hasNextPage: query.hasNextPage, + items: playlists, + onFetchMore: query.fetchNext, + title: Text(context.l10n.playlists), + ); + } +} diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart new file mode 100644 index 00000000..e77cd8f2 --- /dev/null +++ b/lib/pages/search/sections/tracks.dart @@ -0,0 +1,98 @@ +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/extensions/context.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; + +class SearchTracksSection extends HookConsumerWidget { + final InfiniteQuery>, dynamic, int> query; + const SearchTracksSection({ + Key? key, + required this.query, + }) : super(key: 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 theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (tracks.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + context.l10n.songs, + style: theme.textTheme.titleLarge!, + ), + ), + if (!searchTrack.hasPageData && + !searchTrack.hasPageError && + !searchTrack.isLoadingNextPage) + const CircularProgressIndicator() + else if (searchTrack.hasPageError) + Text( + searchTrack.errors.lastOrNull?.toString() ?? "", + ) + else + ...tracks.mapIndexed((i, track) { + return TrackTile( + index: i, + track: track, + 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; + + if (shouldPlay) { + await playlistNotifier.load( + [track], + autoPlay: true, + ); + } + } + }, + ); + }), + if (searchTrack.hasNextPage && tracks.isNotEmpty) + Center( + child: TextButton( + onPressed: searchTrack.isLoadingNextPage + ? null + : () => searchTrack.fetchNext(), + 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 97a0aae9..00263680 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -5,7 +5,7 @@ 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/extensions/context.dart'; -import 'package:spotube/hooks/use_package_info.dart'; +import 'package:spotube/hooks/controllers/use_package_info.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index a41a38eb..b4ce5044 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -5,6 +5,7 @@ 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/extensions/context.dart'; import 'package:spotube/provider/blacklist_provider.dart'; @@ -14,6 +15,7 @@ class BlackListPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { + final controller = useScrollController(); final blacklist = ref.watch(BlackListNotifier.provider); final searchText = useState(""); @@ -56,25 +58,29 @@ class BlackListPage extends HookConsumerWidget { ), ), ), - ListView.builder( - shrinkWrap: true, - itemCount: filteredBlacklist.length, - itemBuilder: (context, index) { - final item = filteredBlacklist.elementAt(index); - return ListTile( - leading: Text("${index + 1}."), - title: Text("${item.name} (${item.type.name})"), - subtitle: Text(item.id), - trailing: IconButton( - icon: Icon(SpotubeIcons.trash, color: Colors.red[400]), - onPressed: () { - ref - .read(BlackListNotifier.provider.notifier) - .remove(filteredBlacklist.elementAt(index)); - }, - ), - ); - }, + InterScrollbar( + controller: controller, + child: ListView.builder( + controller: controller, + shrinkWrap: true, + itemCount: filteredBlacklist.length, + itemBuilder: (context, index) { + final item = filteredBlacklist.elementAt(index); + return ListTile( + leading: Text("${index + 1}."), + title: Text("${item.name} (${item.type.name})"), + subtitle: Text(item.id), + trailing: IconButton( + icon: Icon(SpotubeIcons.trash, color: Colors.red[400]), + onPressed: () { + ref + .read(BlackListNotifier.provider.notifier) + .remove(filteredBlacklist.elementAt(index)); + }, + ), + ); + }, + ), ), ], ), diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart index 3bc1319f..cfb28d18 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.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/extensions/context.dart'; import 'package:spotube/models/logger.dart'; @@ -51,6 +52,7 @@ class LogsPage extends HookWidget { @override Widget build(BuildContext context) { + final controller = useScrollController(); final logs = useState>([]); final rawLogs = useRef(""); final path = useRef(null); @@ -91,47 +93,51 @@ class LogsPage extends HookWidget { ], ), body: SafeArea( - child: ListView.builder( - itemCount: logs.value.length, - itemBuilder: (context, index) { - final log = logs.value[index]; - return Stack( - children: [ - SectionCardWithHeading( - heading: log.date.toString(), - children: [ - Padding( - padding: const EdgeInsets.all(12.0), - child: SelectableText(log.body), - ), - ], - ), - Positioned( - right: 10, - top: 0, - child: IconButton( - icon: const Icon(SpotubeIcons.clipboard), - onPressed: () async { - await Clipboard.setData( - ClipboardData(text: log.body), - ); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.copied_to_clipboard( - log.date.toString(), + child: InterScrollbar( + controller: controller, + child: ListView.builder( + controller: controller, + itemCount: logs.value.length, + itemBuilder: (context, index) { + final log = logs.value[index]; + return Stack( + children: [ + SectionCardWithHeading( + heading: log.date.toString(), + children: [ + Padding( + padding: const EdgeInsets.all(12.0), + child: SelectableText(log.body), + ), + ], + ), + Positioned( + right: 10, + top: 0, + child: IconButton( + icon: const Icon(SpotubeIcons.clipboard), + onPressed: () async { + await Clipboard.setData( + ClipboardData(text: log.body), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.copied_to_clipboard( + log.date.toString(), + ), ), ), - ), - ); - } - }, + ); + } + }, + ), ), - ), - ], - ); - }, + ], + ); + }, + ), ), ), ); diff --git a/lib/pages/settings/sections/about.dart b/lib/pages/settings/sections/about.dart new file mode 100644 index 00000000..9fe59662 --- /dev/null +++ b/lib/pages/settings/sections/about.dart @@ -0,0 +1,85 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.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/settings/section_card_with_heading.dart'; +import 'package:spotube/components/shared/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); + + @override + Widget build(BuildContext context, ref) { + final preferences = ref.watch(userPreferencesProvider); + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); + + 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, + ), + ), + ), + ), + 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), + ], + ), + ), + ), + if (Env.enableUpdateChecker) + SwitchListTile( + secondary: const Icon(SpotubeIcons.update), + title: Text(context.l10n.check_for_updates), + value: preferences.checkUpdate, + onChanged: (checked) => preferencesNotifier.setCheckUpdate(checked), + ), + ListTile( + leading: const Icon(SpotubeIcons.info), + title: Text(context.l10n.about_spotube), + trailing: const Icon(SpotubeIcons.angleRight), + onTap: () { + GoRouter.of(context).push("/settings/about"); + }, + ) + ], + ); + } +} diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart new file mode 100644 index 00000000..83740866 --- /dev/null +++ b/lib/pages/settings/sections/accounts.dart @@ -0,0 +1,128 @@ +import 'package:auto_size_text/auto_size_text.dart'; +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/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/scrobbler_provider.dart'; + +class SettingsAccountSection extends HookConsumerWidget { + const SettingsAccountSection({Key? key}) : super(key: key); + + @override + Widget build(context, ref) { + final theme = Theme.of(context); + final auth = ref.watch(AuthenticationNotifier.provider); + final scrobbler = ref.watch(scrobblerProvider); + final router = GoRouter.of(context); + + final logoutBtnStyle = FilledButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ); + + return SectionCardWithHeading( + heading: context.l10n.account, + children: [ + if (auth == null) + LayoutBuilder(builder: (context, constrains) { + return ListTile( + leading: Icon( + SpotubeIcons.spotify, + color: theme.colorScheme.primary, + ), + title: Align( + alignment: Alignment.centerLeft, + child: AutoSizeText( + context.l10n.login_with_spotify, + maxLines: 1, + style: TextStyle( + color: theme.colorScheme.primary, + ), + ), + ), + onTap: constrains.mdAndUp + ? null + : () { + router.push("/login"); + }, + trailing: constrains.smAndDown + ? null + : FilledButton( + onPressed: () { + router.push("/login"); + }, + style: ButtonStyle( + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25.0), + ), + ), + ), + child: Text( + context.l10n.connect_with_spotify.toUpperCase(), + ), + ), + ); + }) + else + Builder(builder: (context) { + return ListTile( + leading: const Icon(SpotubeIcons.spotify), + title: SizedBox( + height: 50, + width: 180, + child: Align( + alignment: Alignment.centerLeft, + child: AutoSizeText( + context.l10n.logout_of_this_account, + maxLines: 1, + ), + ), + ), + trailing: FilledButton( + style: logoutBtnStyle, + onPressed: () async { + ref.read(AuthenticationNotifier.provider.notifier).logout(); + GoRouter.of(context).pop(); + }, + child: Text(context.l10n.logout), + ), + ); + }), + if (scrobbler == null) + ListTile( + leading: const Icon(SpotubeIcons.lastFm), + title: Text(context.l10n.login_with_lastfm), + subtitle: Text(context.l10n.scrobble_to_lastfm), + trailing: FilledButton.icon( + icon: const Icon(SpotubeIcons.lastFm), + label: Text(context.l10n.connect), + onPressed: () { + router.push("/lastfm-login"); + }, + style: FilledButton.styleFrom( + backgroundColor: const Color.fromARGB(255, 186, 0, 0), + foregroundColor: Colors.white, + ), + ), + ) + else + ListTile( + leading: const Icon(SpotubeIcons.lastFm), + title: Text(context.l10n.disconnect_lastfm), + trailing: FilledButton( + onPressed: () { + ref.read(scrobblerProvider.notifier).logout(); + }, + style: logoutBtnStyle, + child: Text(context.l10n.disconnect), + ), + ), + ], + ); + } +} diff --git a/lib/pages/settings/sections/appearance.dart b/lib/pages/settings/sections/appearance.dart new file mode 100644 index 00000000..5de36c63 --- /dev/null +++ b/lib/pages/settings/sections/appearance.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.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/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/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 { + const SettingsAppearanceSection({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final preferences = ref.watch(userPreferencesProvider); + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); + final pickColorScheme = useCallback(() { + return () => showDialog( + context: context, + builder: (context) { + return const ColorSchemePickerDialog(); + }); + }, []); + + return SectionCardWithHeading( + heading: context.l10n.appearance, + children: [ + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.dashboard), + title: Text(context.l10n.layout_mode), + subtitle: Text(context.l10n.override_layout_settings), + value: preferences.layoutMode, + onChanged: (value) { + if (value != null) { + preferencesNotifier.setLayoutMode(value); + } + }, + options: [ + DropdownMenuItem( + value: LayoutMode.adaptive, + child: Text(context.l10n.adaptive), + ), + DropdownMenuItem( + value: LayoutMode.compact, + child: Text(context.l10n.compact), + ), + DropdownMenuItem( + value: LayoutMode.extended, + child: Text(context.l10n.extended), + ), + ], + ), + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.darkMode), + title: Text(context.l10n.theme), + value: preferences.themeMode, + options: [ + DropdownMenuItem( + value: ThemeMode.dark, + child: Text(context.l10n.dark), + ), + DropdownMenuItem( + value: ThemeMode.light, + child: Text(context.l10n.light), + ), + DropdownMenuItem( + value: ThemeMode.system, + child: Text(context.l10n.system), + ), + ], + onChanged: (value) { + if (value != null) { + preferencesNotifier.setThemeMode(value); + } + }, + ), + SwitchListTile( + secondary: const Icon(SpotubeIcons.amoled), + title: Text(context.l10n.use_amoled_mode), + subtitle: Text(context.l10n.pitch_dark_theme), + value: preferences.amoledDarkTheme, + onChanged: preferencesNotifier.setAmoledDarkTheme, + ), + ListTile( + leading: const Icon(SpotubeIcons.palette), + title: Text(context.l10n.accent_color), + contentPadding: const EdgeInsets.symmetric( + horizontal: 15, + vertical: 5, + ), + trailing: ColorTile.compact( + color: preferences.accentColorScheme, + onPressed: pickColorScheme(), + isActive: true, + ), + onTap: pickColorScheme(), + ), + SwitchListTile( + secondary: const Icon(SpotubeIcons.colorSync), + title: Text(context.l10n.sync_album_color), + subtitle: Text(context.l10n.sync_album_color_description), + value: preferences.albumColorSync, + onChanged: preferencesNotifier.setAlbumColorSync, + ), + ], + ); + } +} diff --git a/lib/pages/settings/sections/desktop.dart b/lib/pages/settings/sections/desktop.dart new file mode 100644 index 00000000..ae721fc4 --- /dev/null +++ b/lib/pages/settings/sections/desktop.dart @@ -0,0 +1,64 @@ +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/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/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); + + @override + Widget build(BuildContext context, ref) { + final preferences = ref.watch(userPreferencesProvider); + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); + + return SectionCardWithHeading( + heading: context.l10n.desktop, + children: [ + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.close), + title: Text(context.l10n.close_behavior), + value: preferences.closeBehavior, + options: [ + DropdownMenuItem( + value: CloseBehavior.close, + child: Text(context.l10n.close), + ), + DropdownMenuItem( + value: CloseBehavior.minimizeToTray, + child: Text(context.l10n.minimize_to_tray), + ), + ], + onChanged: (value) { + if (value != null) { + preferencesNotifier.setCloseBehavior(value); + } + }, + ), + SwitchListTile( + secondary: const Icon(SpotubeIcons.tray), + title: Text(context.l10n.show_tray_icon), + value: preferences.showSystemTrayIcon, + onChanged: preferencesNotifier.setShowSystemTrayIcon, + ), + SwitchListTile( + secondary: const Icon(SpotubeIcons.window), + title: Text(context.l10n.use_system_title_bar), + 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, + ), + ], + ); + } +} diff --git a/lib/pages/settings/sections/developers.dart b/lib/pages/settings/sections/developers.dart new file mode 100644 index 00000000..4b5f58a6 --- /dev/null +++ b/lib/pages/settings/sections/developers.dart @@ -0,0 +1,27 @@ +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/extensions/context.dart'; + +class SettingsDevelopersSection extends HookWidget { + const SettingsDevelopersSection({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return SectionCardWithHeading( + heading: context.l10n.developers, + children: [ + ListTile( + leading: const Icon(SpotubeIcons.logs), + title: Text(context.l10n.logs), + trailing: const Icon(SpotubeIcons.angleRight), + onTap: () { + GoRouter.of(context).push("/settings/logs"); + }, + ) + ], + ); + } +} diff --git a/lib/pages/settings/sections/downloads.dart b/lib/pages/settings/sections/downloads.dart new file mode 100644 index 00000000..b1e360d0 --- /dev/null +++ b/lib/pages/settings/sections/downloads.dart @@ -0,0 +1,52 @@ +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/extensions/context.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; + +class SettingsDownloadsSection extends HookConsumerWidget { + const SettingsDownloadsSection({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); + final preferences = ref.watch(userPreferencesProvider); + + final pickDownloadLocation = useCallback(() async { + if (DesktopTools.platform.isMobile || DesktopTools.platform.isMacOS) { + final dirStr = await FilePicker.platform.getDirectoryPath( + initialDirectory: preferences.downloadLocation, + ); + if (dirStr == null) return; + preferencesNotifier.setDownloadLocation(dirStr); + } else { + String? dirStr = await getDirectoryPath( + initialDirectory: preferences.downloadLocation, + ); + if (dirStr == null) return; + preferencesNotifier.setDownloadLocation(dirStr); + } + }, [preferences.downloadLocation]); + + return SectionCardWithHeading( + heading: context.l10n.downloads, + children: [ + ListTile( + leading: const Icon(SpotubeIcons.download), + title: Text(context.l10n.download_location), + subtitle: Text(preferences.downloadLocation), + trailing: FilledButton( + onPressed: pickDownloadLocation, + child: const Icon(SpotubeIcons.folder), + ), + onTap: pickDownloadLocation, + ), + ], + ); + } +} diff --git a/lib/pages/settings/sections/language_region.dart b/lib/pages/settings/sections/language_region.dart new file mode 100644 index 00000000..9465feb3 --- /dev/null +++ b/lib/pages/settings/sections/language_region.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.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/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/l10n/l10n.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; + +class SettingsLanguageRegionSection extends HookConsumerWidget { + const SettingsLanguageRegionSection({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final preferences = ref.watch(userPreferencesProvider); + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); + final mediaQuery = MediaQuery.of(context); + + return SectionCardWithHeading( + heading: context.l10n.language_region, + children: [ + AdaptiveSelectTile( + value: preferences.locale, + onChanged: (locale) { + if (locale == null) return; + preferencesNotifier.setLocale(locale); + }, + title: Text(context.l10n.language), + secondary: const Icon(SpotubeIcons.language), + options: [ + DropdownMenuItem( + value: const Locale("system", "system"), + child: Text(context.l10n.system_default), + ), + for (final locale in L10n.all) + DropdownMenuItem( + value: locale, + child: Builder(builder: (context) { + final isoCodeName = LanguageLocals.getDisplayLanguage( + locale.languageCode, + ); + return Text( + "${isoCodeName.name} (${isoCodeName.nativeName})", + ); + }), + ), + ], + ), + AdaptiveSelectTile( + breakLayout: mediaQuery.lgAndUp, + secondary: const Icon(SpotubeIcons.shoppingBag), + title: Text(context.l10n.market_place_region), + subtitle: Text(context.l10n.recommendation_country), + value: preferences.recommendationMarket, + onChanged: (value) { + if (value == null) return; + preferencesNotifier.setRecommendationMarket(value); + }, + options: spotifyMarkets + .map( + (country) => DropdownMenuItem( + value: country.$1, + child: Text(country.$2), + ), + ) + .toList(), + ), + ], + ); + } +} diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart new file mode 100644 index 00000000..bd2e33b9 --- /dev/null +++ b/lib/pages/settings/sections/playback.dart @@ -0,0 +1,233 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.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/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); + + @override + Widget build(BuildContext context, ref) { + final preferences = ref.watch(userPreferencesProvider); + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); + final theme = Theme.of(context); + + return SectionCardWithHeading( + heading: context.l10n.playback, + children: [ + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.audioQuality), + title: Text(context.l10n.audio_quality), + value: preferences.audioQuality, + options: [ + DropdownMenuItem( + value: SourceQualities.high, + child: Text(context.l10n.high), + ), + DropdownMenuItem( + value: SourceQualities.medium, + child: Text(context.l10n.medium), + ), + DropdownMenuItem( + value: SourceQualities.low, + child: Text(context.l10n.low), + ), + ], + onChanged: (value) { + if (value != null) { + preferencesNotifier.setAudioQuality(value); + } + }, + ), + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.api), + title: Text(context.l10n.audio_source), + value: preferences.audioSource, + options: AudioSource.values + .map((e) => DropdownMenuItem( + value: e, + child: Text(e.label), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + preferencesNotifier.setAudioSource(value); + }, + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: preferences.audioSource != AudioSource.piped + ? const SizedBox.shrink() + : Consumer(builder: (context, ref, child) { + final instanceList = ref.watch(pipedInstancesFutureProvider); + + return instanceList.when( + data: (data) { + return AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.piped), + title: Text(context.l10n.piped_instance), + subtitle: RichText( + text: TextSpan( + children: [ + TextSpan( + text: context.l10n.piped_description, + style: theme.textTheme.bodyMedium, + ), + const TextSpan(text: "\n"), + TextSpan( + text: context.l10n.piped_warning, + style: theme.textTheme.labelMedium, + ) + ], + ), + ), + value: preferences.pipedInstance, + showValueWhenUnfolded: false, + options: data + .sortedBy((e) => e.name) + .map( + (e) => DropdownMenuItem( + value: e.apiUrl, + child: RichText( + text: TextSpan( + children: [ + TextSpan( + text: "${e.name.trim()}\n", + style: theme.textTheme.labelLarge, + ), + TextSpan( + text: e.locations + .map(countryCodeToEmoji) + .join(""), + style: GoogleFonts.notoColorEmoji(), + ), + ], + ), + ), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + preferencesNotifier.setPipedInstance(value); + } + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stackTrace) => Text(error.toString()), + ); + }), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: preferences.audioSource != AudioSource.piped + ? const SizedBox.shrink() + : AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.search), + title: Text(context.l10n.search_mode), + value: preferences.searchMode, + options: SearchMode.values + .map((e) => DropdownMenuItem( + value: e, + child: Text(e.label), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + preferencesNotifier.setSearchMode(value); + }, + ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: preferences.searchMode == SearchMode.youtube && + (preferences.audioSource == AudioSource.piped || + preferences.audioSource == AudioSource.youtube) + ? SwitchListTile( + secondary: const Icon(SpotubeIcons.skip), + title: Text(context.l10n.skip_non_music), + value: preferences.skipNonMusic, + onChanged: (state) { + preferencesNotifier.setSkipNonMusic(state); + }, + ) + : const SizedBox.shrink(), + ), + ListTile( + leading: const Icon(SpotubeIcons.playlistRemove), + title: Text(context.l10n.blacklist), + subtitle: Text(context.l10n.blacklist_description), + onTap: () { + GoRouter.of(context).push("/settings/blacklist"); + }, + trailing: const Icon(SpotubeIcons.angleRight), + ), + SwitchListTile( + secondary: const Icon(SpotubeIcons.normalize), + title: Text(context.l10n.normalize_audio), + value: preferences.normalizeAudio, + onChanged: preferencesNotifier.setNormalizeAudio, + ), + if (preferences.audioSource != AudioSource.jiosaavn) + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.stream), + title: Text(context.l10n.streaming_music_codec), + value: preferences.streamMusicCodec, + showValueWhenUnfolded: false, + options: SourceCodecs.values + .map((e) => DropdownMenuItem( + value: e, + child: Text( + e.label, + style: theme.textTheme.labelMedium, + ), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + preferencesNotifier.setStreamMusicCodec(value); + }, + ), + if (preferences.audioSource != AudioSource.jiosaavn) + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.file), + title: Text(context.l10n.download_music_codec), + value: preferences.downloadMusicCodec, + showValueWhenUnfolded: false, + options: SourceCodecs.values + .map((e) => DropdownMenuItem( + value: e, + child: Text( + e.label, + style: theme.textTheme.labelMedium, + ), + )) + .toList(), + onChanged: (value) { + 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, + ), + ], + ); + } +} diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index 033d2946..f773b809 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -1,58 +1,27 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:collection/collection.dart'; -import 'package:file_picker/file_picker.dart'; 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: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/env.dart'; -import 'package:spotube/collections/language_codes.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_list_tile.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/collections/spotify_markets.dart'; -import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/l10n/l10n.dart'; -import 'package:spotube/models/matched_track.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/provider/piped_instances_provider.dart'; -import 'package:url_launcher/url_launcher_string.dart'; +import 'package:spotube/pages/settings/sections/about.dart'; +import 'package:spotube/pages/settings/sections/accounts.dart'; +import 'package:spotube/pages/settings/sections/appearance.dart'; +import 'package:spotube/pages/settings/sections/desktop.dart'; +import 'package:spotube/pages/settings/sections/developers.dart'; +import 'package:spotube/pages/settings/sections/downloads.dart'; +import 'package:spotube/pages/settings/sections/language_region.dart'; +import 'package:spotube/pages/settings/sections/playback.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; class SettingsPage extends HookConsumerWidget { const SettingsPage({Key? key}) : super(key: key); @override Widget build(BuildContext context, ref) { - final UserPreferences preferences = ref.watch(userPreferencesProvider); - final auth = ref.watch(AuthenticationNotifier.provider); - final theme = Theme.of(context); - final mediaQuery = MediaQuery.of(context); - - final pickColorScheme = useCallback(() { - return () => showDialog( - context: context, - builder: (context) { - return const ColorSchemePickerDialog(); - }); - }, []); - - final pickDownloadLocation = useCallback(() async { - final dirStr = await FilePicker.platform.getDirectoryPath( - dialogTitle: context.l10n.download_location, - ); - if (dirStr == null) return; - preferences.setDownloadLocation(dirStr); - }, [preferences.downloadLocation]); + final controller = useScrollController(); + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); return SafeArea( bottom: false, @@ -61,528 +30,37 @@ class SettingsPage extends HookConsumerWidget { title: Text(context.l10n.settings), centerTitle: true, ), - body: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: Container( - constraints: const BoxConstraints(maxWidth: 1366), + body: Scrollbar( + controller: controller, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1366), + child: ScrollConfiguration( + behavior: const ScrollBehavior().copyWith(scrollbars: false), child: ListView( + controller: controller, children: [ - SectionCardWithHeading( - heading: context.l10n.account, - children: [ - if (auth == null) - LayoutBuilder(builder: (context, constrains) { - return ListTile( - leading: Icon( - SpotubeIcons.login, - color: theme.colorScheme.primary, - ), - title: Align( - alignment: Alignment.centerLeft, - child: AutoSizeText( - context.l10n.login_with_spotify, - maxLines: 1, - style: TextStyle( - color: theme.colorScheme.primary, - ), - ), - ), - onTap: constrains.mdAndUp - ? null - : () { - GoRouter.of(context).push("/login"); - }, - trailing: constrains.smAndDown - ? null - : FilledButton( - onPressed: () { - GoRouter.of(context).push("/login"); - }, - style: ButtonStyle( - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(25.0), - ), - ), - ), - child: Text( - context.l10n.connect_with_spotify - .toUpperCase(), - ), - ), - ); - }) - else - Builder(builder: (context) { - return ListTile( - leading: const Icon(SpotubeIcons.logout), - title: SizedBox( - height: 50, - width: 180, - child: Align( - alignment: Alignment.centerLeft, - child: AutoSizeText( - context.l10n.logout_of_this_account, - maxLines: 1, - ), - ), - ), - trailing: FilledButton( - style: ButtonStyle( - backgroundColor: - MaterialStateProperty.all(Colors.red), - foregroundColor: - MaterialStateProperty.all(Colors.white), - ), - onPressed: () async { - ref - .read(AuthenticationNotifier - .provider.notifier) - .logout(); - GoRouter.of(context).pop(); - }, - child: Text(context.l10n.logout), - ), - ); - }), - ], - ), - SectionCardWithHeading( - heading: context.l10n.language_region, - children: [ - AdaptiveSelectTile( - value: preferences.locale, - onChanged: (locale) { - if (locale == null) return; - preferences.setLocale(locale); - }, - title: Text(context.l10n.language), - secondary: const Icon(SpotubeIcons.language), - options: [ - DropdownMenuItem( - value: const Locale("system", "system"), - child: Text(context.l10n.system_default), - ), - for (final locale in L10n.all) - DropdownMenuItem( - value: locale, - child: Builder(builder: (context) { - final isoCodeName = - LanguageLocals.getDisplayLanguage( - locale.languageCode, - ); - return Text( - "${isoCodeName.name} (${isoCodeName.nativeName})", - ); - }), - ), - ], - ), - AdaptiveSelectTile( - breakLayout: mediaQuery.lgAndUp, - secondary: const Icon(SpotubeIcons.shoppingBag), - title: Text(context.l10n.market_place_region), - subtitle: Text(context.l10n.recommendation_country), - value: preferences.recommendationMarket, - onChanged: (value) { - if (value == null) return; - preferences.setRecommendationMarket(value); - }, - options: spotifyMarkets - .map( - (country) => DropdownMenuItem( - value: country.$1, - child: Text(country.$2), - ), - ) - .toList(), - ), - ], - ), - SectionCardWithHeading( - heading: context.l10n.appearance, - children: [ - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.dashboard), - title: Text(context.l10n.layout_mode), - subtitle: Text(context.l10n.override_layout_settings), - value: preferences.layoutMode, - onChanged: (value) { - if (value != null) { - preferences.setLayoutMode(value); - } - }, - options: [ - DropdownMenuItem( - value: LayoutMode.adaptive, - child: Text(context.l10n.adaptive), - ), - DropdownMenuItem( - value: LayoutMode.compact, - child: Text(context.l10n.compact), - ), - DropdownMenuItem( - value: LayoutMode.extended, - child: Text(context.l10n.extended), - ), - ], - ), - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.darkMode), - title: Text(context.l10n.theme), - value: preferences.themeMode, - options: [ - DropdownMenuItem( - value: ThemeMode.dark, - child: Text(context.l10n.dark), - ), - DropdownMenuItem( - value: ThemeMode.light, - child: Text(context.l10n.light), - ), - DropdownMenuItem( - value: ThemeMode.system, - child: Text(context.l10n.system), - ), - ], - onChanged: (value) { - if (value != null) { - preferences.setThemeMode(value); - } - }, - ), - ListTile( - leading: const Icon(SpotubeIcons.palette), - title: Text(context.l10n.accent_color), - contentPadding: const EdgeInsets.symmetric( - horizontal: 15, - vertical: 5, - ), - trailing: ColorTile.compact( - color: preferences.accentColorScheme, - onPressed: pickColorScheme(), - isActive: true, - ), - onTap: pickColorScheme(), - ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.colorSync), - title: Text(context.l10n.sync_album_color), - subtitle: - Text(context.l10n.sync_album_color_description), - value: preferences.albumColorSync, - onChanged: preferences.setAlbumColorSync, - ), - ], - ), - SectionCardWithHeading( - heading: context.l10n.playback, - children: [ - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.audioQuality), - title: Text(context.l10n.audio_quality), - value: preferences.audioQuality, - options: [ - DropdownMenuItem( - value: AudioQuality.high, - child: Text(context.l10n.high), - ), - DropdownMenuItem( - value: AudioQuality.low, - child: Text(context.l10n.low), - ), - ], - onChanged: (value) { - if (value != null) { - preferences.setAudioQuality(value); - } - }, - ), - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.api), - title: Text(context.l10n.youtube_api_type), - value: preferences.youtubeApiType, - options: YoutubeApiType.values - .map((e) => DropdownMenuItem( - value: e, - child: Text(e.label), - )) - .toList(), - onChanged: (value) { - if (value == null) return; - preferences.setYoutubeApiType(value); - }, - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: preferences.youtubeApiType == - YoutubeApiType.youtube - ? const SizedBox.shrink() - : Consumer(builder: (context, ref, child) { - final instanceList = - ref.watch(pipedInstancesFutureProvider); - - return instanceList.when( - data: (data) { - return AdaptiveSelectTile( - secondary: - const Icon(SpotubeIcons.piped), - title: - Text(context.l10n.piped_instance), - subtitle: RichText( - text: TextSpan( - children: [ - TextSpan( - text: context - .l10n.piped_description), - const TextSpan(text: "\n"), - TextSpan( - text: - context.l10n.piped_warning, - style: Theme.of(context) - .textTheme - .labelMedium, - ) - ], - ), - ), - value: preferences.pipedInstance, - showValueWhenUnfolded: false, - options: data - .sortedBy((e) => e.name) - .map( - (e) => DropdownMenuItem( - value: e.apiUrl, - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: - "${e.name.trim()}\n", - style: Theme.of(context) - .textTheme - .labelLarge, - ), - TextSpan( - text: e.locations - .map( - countryCodeToEmoji) - .join(""), - style: GoogleFonts - .notoColorEmoji(), - ), - ], - ), - ), - ), - ) - .toList(), - onChanged: (value) { - if (value != null) { - preferences.setPipedInstance(value); - } - }, - ); - }, - loading: () => const Center( - child: CircularProgressIndicator(), - ), - error: (error, stackTrace) => - Text(error.toString()), - ); - }), - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: preferences.youtubeApiType == - YoutubeApiType.youtube - ? const SizedBox.shrink() - : AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.search), - title: Text(context.l10n.search_mode), - value: preferences.searchMode, - options: SearchMode.values - .map((e) => DropdownMenuItem( - value: e, - child: Text(e.label), - )) - .toList(), - onChanged: (value) { - if (value == null) return; - preferences.setSearchMode(value); - }, - ), - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: preferences.searchMode == - SearchMode.youtubeMusic && - preferences.youtubeApiType == - YoutubeApiType.piped - ? const SizedBox.shrink() - : SwitchListTile( - secondary: const Icon(SpotubeIcons.skip), - title: Text(context.l10n.skip_non_music), - value: preferences.skipNonMusic, - onChanged: (state) { - preferences.setSkipNonMusic(state); - }, - ), - ), - ListTile( - leading: const Icon(SpotubeIcons.playlistRemove), - title: Text(context.l10n.blacklist), - subtitle: Text(context.l10n.blacklist_description), - onTap: () { - GoRouter.of(context).push("/settings/blacklist"); - }, - trailing: const Icon(SpotubeIcons.angleRight), - ), - ], - ), - SectionCardWithHeading( - heading: context.l10n.downloads, - children: [ - ListTile( - leading: const Icon(SpotubeIcons.download), - title: Text(context.l10n.download_location), - subtitle: Text(preferences.downloadLocation), - trailing: FilledButton( - onPressed: pickDownloadLocation, - child: const Icon(SpotubeIcons.folder), - ), - onTap: pickDownloadLocation, - ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.lyrics), - title: Text(context.l10n.download_lyrics), - value: preferences.saveTrackLyrics, - onChanged: (state) { - preferences.setSaveTrackLyrics(state); - }, - ), - ], - ), + const SettingsAccountSection(), + const SettingsLanguageRegionSection(), + const SettingsAppearanceSection(), + const SettingsPlaybackSection(), + const SettingsDownloadsSection(), if (DesktopTools.platform.isDesktop) - SectionCardWithHeading( - heading: context.l10n.desktop, - children: [ - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.close), - title: Text(context.l10n.close_behavior), - value: preferences.closeBehavior, - options: [ - DropdownMenuItem( - value: CloseBehavior.close, - child: Text(context.l10n.close), - ), - DropdownMenuItem( - value: CloseBehavior.minimizeToTray, - child: Text(context.l10n.minimize_to_tray), - ), - ], - onChanged: (value) { - if (value != null) { - preferences.setCloseBehavior(value); - } - }, - ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.tray), - title: Text(context.l10n.show_tray_icon), - value: preferences.showSystemTrayIcon, - onChanged: preferences.setShowSystemTrayIcon, - ), - ], + const SettingsDesktopSection(), + if (!kIsWeb) const SettingsDevelopersSection(), + const SettingsAboutSection(), + Center( + child: FilledButton( + onPressed: preferencesNotifier.reset, + child: Text(context.l10n.restore_defaults), ), - if (!kIsWeb) - SectionCardWithHeading( - heading: context.l10n.developers, - children: [ - ListTile( - leading: const Icon(SpotubeIcons.logs), - title: Text(context.l10n.logs), - trailing: const Icon(SpotubeIcons.angleRight), - onTap: () { - GoRouter.of(context).push("/settings/logs"); - }, - ) - ], - ), - 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, - ), - ), - ), - ), - 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), - ], - ), - ), - ), - if (Env.enableUpdateChecker) - SwitchListTile( - secondary: const Icon(SpotubeIcons.update), - title: Text(context.l10n.check_for_updates), - value: preferences.checkUpdate, - onChanged: (checked) => - preferences.setCheckUpdate(checked), - ), - ListTile( - leading: const Icon(SpotubeIcons.info), - title: Text(context.l10n.about_spotube), - trailing: const Icon(SpotubeIcons.angleRight), - onTap: () { - GoRouter.of(context).push("/settings/about"); - }, - ) - ], ), + const SizedBox(height: 10), ], ), ), ), - ], + ), ), ), ); diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart new file mode 100644 index 00000000..14052c10 --- /dev/null +++ b/lib/pages/track/track.dart @@ -0,0 +1,227 @@ +import 'dart:ui'; + +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/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/extensions/context.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.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 { + final String trackId; + const TrackPage({ + Key? 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 isActive = playlist.activeTrack?.id == trackId; + + final trackQuery = useQueries.tracks.track(ref, trackId); + + final track = trackQuery.data ?? FakeData.track; + + void onPlay() async { + if (isActive) { + audioPlayer.pause(); + } else { + await playlistNotifier.load([track], autoPlay: true); + } + } + + return Scaffold( + appBar: const PageWindowTitleBar( + automaticallyImplyLeading: true, + backgroundColor: Colors.transparent, + ), + extendBodyBehindAppBar: true, + body: Stack( + children: [ + Positioned.fill( + child: Container( + decoration: BoxDecoration( + image: DecorationImage( + image: UniversalImage.imageProvider( + TypeConversionUtils.image_X_UrlString( + track.album!.images, + placeholder: ImagePlaceholder.albumArt, + ), + ), + fit: BoxFit.cover, + colorFilter: ColorFilter.mode( + colorScheme.surface.withOpacity(0.5), + BlendMode.srcOver, + ), + alignment: Alignment.topCenter, + ), + ), + ), + ), + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Skeletonizer( + enabled: trackQuery.isLoading, + child: Container( + alignment: Alignment.topCenter, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + colorScheme.surface, + Colors.transparent, + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.2, 1], + ), + ), + child: SafeArea( + child: Wrap( + spacing: 20, + runSpacing: 20, + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + runAlignment: WrapAlignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: UniversalImage( + path: TypeConversionUtils.image_X_UrlString( + track.album!.images, + placeholder: ImagePlaceholder.albumArt, + ), + height: 200, + width: 200, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: mediaQuery.smAndDown + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + track.name!, + style: textTheme.titleLarge, + ), + const Gap(10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(SpotubeIcons.album), + const Gap(5), + Flexible( + child: LinkText( + track.album!.name!, + '/album/${track.album!.id}', + push: true, + extra: track.album, + ), + ), + ], + ), + const Gap(10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(SpotubeIcons.artist), + const Gap(5), + TypeConversionUtils + .artists_X_ClickableArtists( + track.artists!, + ), + ], + ), + const Gap(10), + ConstrainedBox( + constraints: + const BoxConstraints(maxWidth: 350), + child: Row( + mainAxisSize: mediaQuery.smAndDown + ? MainAxisSize.max + : MainAxisSize.min, + children: [ + const Gap(5), + if (!isActive && + !playlist.tracks.contains(track)) + OutlinedButton.icon( + icon: const Icon(SpotubeIcons.queueAdd), + label: Text(context.l10n.queue), + onPressed: () { + playlistNotifier.addTrack(track); + }, + ), + const Gap(5), + if (!isActive && + !playlist.tracks.contains(track)) + IconButton.outlined( + icon: + const Icon(SpotubeIcons.lightning), + tooltip: context.l10n.play_next, + onPressed: () { + playlistNotifier + .addTracksAtFirst([track]); + }, + ), + const Gap(5), + IconButton.filled( + tooltip: isActive + ? context.l10n.pause_playback + : context.l10n.play, + icon: Icon( + isActive + ? SpotubeIcons.pause + : SpotubeIcons.play, + color: colorScheme.onPrimary, + ), + onPressed: onPlay, + ), + const Gap(5), + if (mediaQuery.smAndDown) + const Spacer() + else + const Gap(20), + TrackHeartButton(track: track), + TrackOptions( + track: track, + userPlaylist: false, + ), + const Gap(5), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/provider/authentication_provider.dart b/lib/provider/authentication_provider.dart index c09cb199..cd77e7bb 100644 --- a/lib/provider/authentication_provider.dart +++ b/lib/provider/authentication_provider.dart @@ -1,9 +1,13 @@ 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'; @@ -21,24 +25,45 @@ class AuthenticationCredentials { }); static Future fromCookie(String cookie) async { - final Map body = 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" - }, - ).then((res) => jsonDecode(res.body)); + 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); - return AuthenticationCredentials( - cookie: cookie, - accessToken: body['accessToken'], - expiration: DateTime.fromMillisecondsSinceEpoch( - body['accessTokenExpirationTimestampMs'], - ), - ); + 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) { diff --git a/lib/provider/discord_provider.dart b/lib/provider/discord_provider.dart new file mode 100644 index 00000000..3aa547a9 --- /dev/null +++ b/lib/provider/discord_provider.dart @@ -0,0 +1,70 @@ +import 'package:dart_discord_rpc/dart_discord_rpc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.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/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; + +class Discord extends ChangeNotifier { + final DiscordRPC? discordRPC; + final bool isEnabled; + + Discord(this.isEnabled) + : discordRPC = (DesktopTools.platform.isWindows || + DesktopTools.platform.isLinux) && + isEnabled + ? DiscordRPC(applicationId: Env.discordAppId) + : null { + discordRPC?.start(autoRegister: 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", + ), + ); + } + + void clear() { + discordRPC?.clearPresence(); + } + + void shutdown() { + discordRPC?.shutDown(); + } + + @override + void dispose() { + clear(); + shutdown(); + super.dispose(); + } +} + +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; + }, +); diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index db443082..dc538938 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/catcher.dart'; +import 'package:catcher_2/catcher_2.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; @@ -9,23 +9,23 @@ 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/models/spotube_track.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/provider/youtube_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/download_manager/download_manager.dart'; -import 'package:spotube/services/youtube/youtube.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}) - : $history = {}, + : $history = {}, $backHistory = {}, dl = DownloadManager() { dl.statusStream.listen((event) async { final (:request, :status) = event; final track = $history.firstWhereOrNull( - (element) => element.ytUri == request.url, + (element) => element.getUrlOfCodec(downloadCodec) == request.url, ); if (track == null) return; @@ -39,7 +39,11 @@ class DownloadManagerProvider extends ChangeNotifier { await oldFile.exists()) { await oldFile.rename(savePath); } - if (status != DownloadStatus.completed) return; + 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); @@ -58,7 +62,7 @@ class DownloadManagerProvider extends ChangeNotifier { album: track.album?.name, albumArtist: track.artists?.map((a) => a.name).join(", "), year: track.album?.releaseDate != null - ? int.tryParse(track.album!.releaseDate!) ?? 1969 + ? int.tryParse(track.album!.releaseDate!.split("-").first) ?? 1969 : 1969, trackNumber: track.trackNumber, discNumber: track.discNumber, @@ -85,9 +89,10 @@ class DownloadManagerProvider extends ChangeNotifier { final Ref ref; - YoutubeEndpoints get yt => ref.read(downloadYoutubeProvider); String get downloadDirectory => ref.read(userPreferencesProvider.select((s) => s.downloadLocation)); + SourceCodecs get downloadCodec => + ref.read(userPreferencesProvider.select((s) => s.downloadMusicCodec)); int get $downloadCount => dl .getAllDownloads() @@ -99,7 +104,7 @@ class DownloadManagerProvider extends ChangeNotifier { ) .length; - final Set $history; + final Set $history; // these are the tracks which metadata hasn't been fetched yet final Set $backHistory; final DownloadManager dl; @@ -122,23 +127,23 @@ class DownloadManagerProvider extends ChangeNotifier { return Uint8List.fromList(bytes); } catch (e, stackTrace) { - Catcher.reportCheckedError(e, stackTrace); + Catcher2.reportCheckedError(e, stackTrace); return null; } } String getTrackFileUrl(Track track) { final name = - "${track.name} - ${TypeConversionUtils.artists_X_String(track.artists ?? [])}.m4a"; - return join(downloadDirectory, name); + "${track.name} - ${TypeConversionUtils.artists_X_String(track.artists ?? [])}.${downloadCodec.name}"; + return join(downloadDirectory, PrimitiveUtils.toSafeFileName(name)); } bool isActive(Track track) { if ($backHistory.contains(track)) return true; - final spotubeTrack = mapToSpotubeTrack(track); + final sourcedTrack = mapToSourcedTrack(track); - if (spotubeTrack == null) return false; + if (sourcedTrack == null) return false; return dl .getAllDownloads() @@ -149,7 +154,7 @@ class DownloadManagerProvider extends ChangeNotifier { download.status.value == DownloadStatus.queued, ) .map((e) => e.request.url) - .contains(spotubeTrack.ytUri); + .contains(sourcedTrack.getUrlOfCodec(downloadCodec)); } /// For singular downloads @@ -165,21 +170,27 @@ class DownloadManagerProvider extends ChangeNotifier { await oldFile.rename("$savePath.old"); } - if (track is SpotubeTrack) { - final downloadTask = await dl.addDownload(track.ytUri, savePath); + if (track is SourcedTrack && track.codec == downloadCodec) { + final downloadTask = + await dl.addDownload(track.getUrlOfCodec(downloadCodec), savePath); if (downloadTask != null) { $history.add(track); } } else { $backHistory.add(track); - final spotubeTrack = - await SpotubeTrack.fetchFromTrack(track, yt).then((d) { + final sourcedTrack = await SourcedTrack.fetchFromTrack( + ref: ref, + track: track, + ).then((d) { $backHistory.remove(track); return d; }); - final downloadTask = await dl.addDownload(spotubeTrack.ytUri, savePath); + final downloadTask = await dl.addDownload( + sourcedTrack.getUrlOfCodec(downloadCodec), + savePath, + ); if (downloadTask != null) { - $history.add(spotubeTrack); + $history.add(sourcedTrack); } } @@ -188,7 +199,7 @@ class DownloadManagerProvider extends ChangeNotifier { Future batchAddToQueue(List tracks) async { $backHistory.addAll( - tracks.where((element) => element is! SpotubeTrack), + tracks.where((element) => element is! SourcedTrack), ); notifyListeners(); for (final track in tracks) { @@ -202,31 +213,31 @@ class DownloadManagerProvider extends ChangeNotifier { ); } } catch (e) { - Catcher.reportCheckedError(e, StackTrace.current); + Catcher2.reportCheckedError(e, StackTrace.current); continue; } } } - Future removeFromQueue(SpotubeTrack track) async { - await dl.removeDownload(track.ytUri); + Future removeFromQueue(SourcedTrack track) async { + await dl.removeDownload(track.getUrlOfCodec(downloadCodec)); $history.remove(track); } - Future pause(SpotubeTrack track) { - return dl.pauseDownload(track.ytUri); + Future pause(SourcedTrack track) { + return dl.pauseDownload(track.getUrlOfCodec(downloadCodec)); } - Future resume(SpotubeTrack track) { - return dl.resumeDownload(track.ytUri); + Future resume(SourcedTrack track) { + return dl.resumeDownload(track.getUrlOfCodec(downloadCodec)); } - Future retry(SpotubeTrack track) { + Future retry(SourcedTrack track) { return addToQueue(track); } - void cancel(SpotubeTrack track) { - dl.cancelDownload(track.ytUri); + void cancel(SourcedTrack track) { + dl.cancelDownload(track.getUrlOfCodec(downloadCodec)); } void cancelAll() { @@ -236,20 +247,20 @@ class DownloadManagerProvider extends ChangeNotifier { } } - SpotubeTrack? mapToSpotubeTrack(Track track) { - if (track is SpotubeTrack) { + SourcedTrack? mapToSourcedTrack(Track track) { + if (track is SourcedTrack) { return track; } else { return $history.firstWhereOrNull((element) => element.id == track.id); } } - ValueNotifier? getStatusNotifier(SpotubeTrack track) { - return dl.getDownload(track.ytUri)?.status; + ValueNotifier? getStatusNotifier(SourcedTrack track) { + return dl.getDownload(track.getUrlOfCodec(downloadCodec))?.status; } - ValueNotifier? getProgressNotifier(SpotubeTrack track) { - return dl.getDownload(track.ytUri)?.progress; + ValueNotifier? getProgressNotifier(SourcedTrack track) { + return dl.getDownload(track.getUrlOfCodec(downloadCodec))?.progress; } } diff --git a/lib/provider/piped_instances_provider.dart b/lib/provider/piped_instances_provider.dart index 290ad2c4..d571f730 100644 --- a/lib/provider/piped_instances_provider.dart +++ b/lib/provider/piped_instances_provider.dart @@ -1,10 +1,17 @@ +import 'package:catcher_2/catcher_2.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:piped_client/piped_client.dart'; -import 'package:spotube/provider/youtube_provider.dart'; +import 'package:spotube/services/sourced_track/sources/piped.dart'; final pipedInstancesFutureProvider = FutureProvider>( (ref) async { - final youtube = ref.watch(youtubeProvider); - return await youtube.piped?.instanceList() ?? []; + try { + final pipedClient = ref.watch(pipedProvider); + + return await pipedClient.instanceList(); + } catch (e, stack) { + Catcher2.reportCheckedError(e, stack); + return []; + } }, ); diff --git a/lib/provider/proxy_playlist/next_fetcher_mixin.dart b/lib/provider/proxy_playlist/next_fetcher_mixin.dart index 0236ec58..1d2cfde8 100644 --- a/lib/provider/proxy_playlist/next_fetcher_mixin.dart +++ b/lib/provider/proxy_playlist/next_fetcher_mixin.dart @@ -1,35 +1,32 @@ import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/local_track.dart'; -import 'package:spotube/models/matched_track.dart'; -import 'package:spotube/models/spotube_track.dart'; +import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/services/supabase.dart'; -import 'package:spotube/services/youtube/youtube.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; + +final logger = getLogger("NextFetcherMixin"); mixin NextFetcher on StateNotifier { - Future> fetchTracks( - UserPreferences preferences, - YoutubeEndpoints youtube, { + Future> fetchTracks( + Ref ref, { int count = 3, int offset = 0, }) async { - /// get [count] [state.tracks] that are not [SpotubeTrack] and [LocalTrack] + /// get [count] [state.tracks] that are not [SourcedTrack] and [LocalTrack] final bareTracks = state.tracks .skip(offset) - .where((element) => element is! SpotubeTrack && element is! LocalTrack) + .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 = SpotubeTrack.fetchFromTrack( - track, - youtube, + final future = SourcedTrack.fetchFromTrack( + ref: ref, + track: track, ); if (i == 0) { return await future; @@ -44,9 +41,9 @@ mixin NextFetcher on StateNotifier { return fetchedTracks; } - /// Merges List of [SpotubeTrack]s with [Track]s and outputs a mixed List + /// Merges List of [SourcedTrack]s with [Track]s and outputs a mixed List Set mergeTracks( - Iterable fetchTracks, + Iterable fetchTracks, Iterable tracks, ) { return tracks.map((track) { @@ -77,53 +74,35 @@ mixin NextFetcher on StateNotifier { /// Returns appropriate Media source for [Track] /// - /// * If [Track] is [SpotubeTrack] then return [SpotubeTrack.ytUri] + /// * 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 SpotubeTrack) { - return track.ytUri; + if (track is SourcedTrack) { + return track.url; } else if (track is LocalTrack) { return track.path; } else { - return "https://youtube.com/unplayable.m4a?id=${track.id}&title=${track.name?.replaceAll( - RegExp(r'\s+', caseSensitive: false), - '-', - )}"; + 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) { - final newSource = makeAppropriateSource(track); - return newSource == source; - }, + (track) => + trackToUnplayableSource(track) == source || + (track is SourcedTrack && track.url == source) || + (track is LocalTrack && track.path == source), ); return track; }) .whereNotNull() .toList(); } - - /// This method must be called after any playback operation as - /// it can increase the latency - Future storeTrack(Track track, SpotubeTrack spotubeTrack) async { - try { - if (track is! SpotubeTrack) { - await supabase.insertTrack( - MatchedTrack( - youtubeId: spotubeTrack.ytTrack.id, - spotifyId: spotubeTrack.id!, - searchMode: spotubeTrack.ytTrack.searchMode, - ), - ); - } - } catch (e, stackTrace) { - debugPrint(e.toString()); - debugPrintStack(stackTrace: stackTrace); - } - } } diff --git a/lib/provider/proxy_playlist/proxy_playlist.dart b/lib/provider/proxy_playlist/proxy_playlist.dart index c0563f21..026b3403 100644 --- a/lib/provider/proxy_playlist/proxy_playlist.dart +++ b/lib/provider/proxy_playlist/proxy_playlist.dart @@ -1,8 +1,9 @@ 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/models/spotube_track.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; class ProxyPlaylist { final Set tracks; @@ -11,11 +12,14 @@ class ProxyPlaylist { ProxyPlaylist(this.tracks, [this.active, this.collections = const {}]); - factory ProxyPlaylist.fromJson(Map json) { + factory ProxyPlaylist.fromJson( + Map json, + Ref ref, + ) { return ProxyPlaylist( List.castFrom>( json['tracks'] ?? >[], - ).map(_makeAppropriateTrack).toSet(), + ).map((t) => _makeAppropriateTrack(t, ref)).toSet(), json['active'] as int?, json['collections'] == null ? {} @@ -28,7 +32,7 @@ class ProxyPlaylist { bool get isFetching => activeTrack != null && - activeTrack is! SpotubeTrack && + activeTrack is! SourcedTrack && activeTrack is! LocalTrack; bool containsCollection(String collection) { @@ -44,9 +48,9 @@ class ProxyPlaylist { return tracks.every(containsTrack); } - static Track _makeAppropriateTrack(Map track) { + static Track _makeAppropriateTrack(Map track, Ref ref) { if (track.containsKey("ytUri")) { - return SpotubeTrack.fromJson(track); + return SourcedTrack.fromJson(track, ref: ref); } else if (track.containsKey("path")) { return LocalTrack.fromJson(track); } else { @@ -54,12 +58,14 @@ class ProxyPlaylist { } } + /// To make sure proper instance method is used for JSON serialization + /// Otherwise default super.toJson() is used static Map _makeAppropriateTrackJson(Track track) { - if (track is LocalTrack) { - return track.toJson(); - } else { - return track.toJson(); - } + return switch (track.runtimeType) { + LocalTrack => track.toJson(), + SourcedTrack => track.toJson(), + _ => track.toJson(), + }; } Map toJson() { diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index b5d42fb2..ca0fb308 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -1,8 +1,10 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; -import 'package:catcher/catcher.dart'; +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'; @@ -10,25 +12,32 @@ 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/matched_track.dart'; + import 'package:spotube/models/skip_segment.dart'; -import 'package:spotube/models/spotube_track.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/user_preferences_provider.dart'; -import 'package:spotube/provider/youtube_provider.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/services/youtube/youtube.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 [SpotubeTrack] on 80% of current track -/// * [x] Mixed Queue containing both [SpotubeTrack] and [LocalTrack] +/// * [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 @@ -50,11 +59,12 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier final Ref ref; late final AudioServices notificationService; + ScrobblerNotifier get scrobbler => ref.read(scrobblerProvider.notifier); UserPreferences get preferences => ref.read(userPreferencesProvider); - YoutubeEndpoints get youtube => ref.read(youtubeProvider); ProxyPlaylist get playlist => state; BlackListNotifier get blacklist => ref.read(BlackListNotifier.provider.notifier); + Discord get discord => ref.read(discordProvider); static final provider = StateNotifierProvider( @@ -68,7 +78,12 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier () async { notificationService = await AudioServices.create(ref, this); - ({String source, List segments})? currentSegments; + // 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 { @@ -81,6 +96,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } notificationService.addTrack(newActiveTrack); + discord.updatePresence(newActiveTrack); state = state.copyWith( active: state.tracks .toList() @@ -89,7 +105,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier updatePalette(); } catch (e, stackTrace) { - Catcher.reportCheckedError(e, stackTrace); + Catcher2.reportCheckedError(e, stackTrace); } }); @@ -108,92 +124,116 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier active: newActiveIndex, ); } catch (e, stackTrace) { - Catcher.reportCheckedError(e, stackTrace); + Catcher2.reportCheckedError(e, stackTrace); } }); - bool isPreSearching = false; - listenTo2Percent(int percent) async { - if (isPreSearching || + if (isPreSearching.value || audioPlayer.currentSource == null || audioPlayer.nextSource == null || isPlayable(audioPlayer.nextSource!)) return; try { - isPreSearching = true; + isPreSearching.value = true; - final oldTrack = - mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull; final track = await ensureSourcePlayable(audioPlayer.nextSource!); if (track != null) { state = state.copyWith(tracks: mergeTracks([track], state.tracks)); } - - if (oldTrack != null && track != null) { - await storeTrack( - oldTrack, - track, - ); - } } catch (e, stackTrace) { - Catcher.reportCheckedError(e, stackTrace); + // Removing tracks that were not found to avoid queue interruption + // TODO: Add a flag to enable/disable skip not found tracks + if (e is TrackNotFoundException) { + final oldTrack = + mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull; + await removeTrack(oldTrack!.id!); + } + Catcher2.reportCheckedError(e, stackTrace); } finally { - isPreSearching = false; + isPreSearching.value = false; } } audioPlayer.percentCompletedStream(2).listen(listenTo2Percent); - bool isFetchingSegments = false; - audioPlayer.positionStream.listen((position) async { + if (state.activeTrack == null || state.activeTrack is LocalTrack) { + isFetchingSegments.value = false; + return; + } try { - if (state.activeTrack == null || state.activeTrack is LocalTrack) { - isFetchingSegments = false; - return; - } - // skipping in very first second breaks stream - if ((preferences.youtubeApiType == YoutubeApiType.piped && - preferences.searchMode == SearchMode.youtubeMusic) || - !preferences.skipNonMusic) return; + final isNotYTMode = state.activeTrack is! YoutubeSourcedTrack && + (state.activeTrack is PipedSourcedTrack && + preferences.searchMode == SearchMode.youtubeMusic); - final notSameSegmentId = - currentSegments?.source != audioPlayer.currentSource; + if (isNotYTMode || !preferences.skipNonMusic) return; - if (currentSegments == null || - (notSameSegmentId && !isFetchingSegments)) { - isFetchingSegments = true; + final isNotSameSegmentId = + currentSegments.value?.source != audioPlayer.currentSource; + + if (currentSegments.value == null || + (isNotSameSegmentId && !isFetchingSegments.value)) { + isFetchingSegments.value = true; try { - currentSegments = ( + currentSegments.value = ( source: audioPlayer.currentSource!, segments: await getAndCacheSkipSegments( - (state.activeTrack as SpotubeTrack).ytTrack.id, + (state.activeTrack as SourcedTrack).sourceInfo.id, ), ); + } catch (e) { + if (audioPlayer.currentSource != null) { + currentSegments.value = ( + source: audioPlayer.currentSource!, + segments: [], + ); + } } finally { - isFetchingSegments = false; + isFetchingSegments.value = false; } } - final (source: _, :segments) = currentSegments!; - if (segments.isEmpty || position < const Duration(seconds: 3)) return; + // 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 segments) { - if ((position.inSeconds >= segment.start && - position.inSeconds < segment.end)) { + 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) { - Catcher.reportCheckedError(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 { + Future ensureSourcePlayable(String source) async { if (isPlayable(source)) return null; final track = mapSourcesToTracks([source]).firstOrNull; @@ -203,13 +243,13 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } final nthFetchedTrack = switch (track.runtimeType) { - SpotubeTrack => track as SpotubeTrack, - _ => await SpotubeTrack.fetchFromTrack(track, youtube), + SourcedTrack => track as SourcedTrack, + _ => await SourcedTrack.fetchFromTrack(ref: ref, track: track), }; await audioPlayer.replaceSource( source, - nthFetchedTrack.ytUri, + nthFetchedTrack.url, ); return nthFetchedTrack; @@ -286,14 +326,15 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier collections: {}, ); await notificationService.addTrack(indexTrack); + discord.updatePresence(indexTrack); } else { - final addableTrack = await SpotubeTrack.fetchFromTrack( - tracks.elementAtOrNull(initialIndex) ?? tracks.first, - youtube, + final addableTrack = await SourcedTrack.fetchFromTrack( + ref: ref, + track: tracks.elementAtOrNull(initialIndex) ?? tracks.first, ).catchError((e, stackTrace) { - return SpotubeTrack.fetchFromTrack( - tracks.elementAtOrNull(initialIndex + 1) ?? tracks.first, - youtube, + return SourcedTrack.fetchFromTrack( + ref: ref, + track: tracks.elementAtOrNull(initialIndex + 1) ?? tracks.first, ); }); @@ -303,10 +344,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier collections: {}, ); await notificationService.addTrack(addableTrack); - await storeTrack( - tracks.elementAt(initialIndex), - addableTrack, - ); + discord.updatePresence(addableTrack); } await audioPlayer.openPlaylist( @@ -319,26 +357,23 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier 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!); - } - - if (oldTrack != null && track != null) { - await storeTrack( - oldTrack, - track, - ); + discord.updatePresence(track ?? oldTrack!); } } @@ -384,9 +419,9 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } Future populateSibling() async { - if (state.activeTrack is SpotubeTrack) { + if (state.activeTrack is SourcedTrack) { final activeTrackWithSiblingsForSure = - await (state.activeTrack as SpotubeTrack).populatedCopy(youtube); + await (state.activeTrack as SourcedTrack).copyWithSibling(); state = state.copyWith( tracks: mergeTracks([activeTrackWithSiblingsForSure], state.tracks), @@ -396,11 +431,11 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } } - Future swapSibling(YoutubeVideoInfo video) async { - if (state.activeTrack is SpotubeTrack) { + Future swapSibling(SourceInfo sibling) async { + if (state.activeTrack is SourcedTrack) { await populateSibling(); final newTrack = - await (state.activeTrack as SpotubeTrack).swappedCopy(video, youtube); + await (state.activeTrack as SourcedTrack).swapWithSibling(sibling); if (newTrack == null) return; state = state.copyWith( tracks: mergeTracks([newTrack], state.tracks), @@ -419,13 +454,16 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier 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), @@ -438,12 +476,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier if (oldTrack != null || track != null) { await notificationService.addTrack(track ?? oldTrack!); - } - if (oldTrack != null && track != null) { - await storeTrack( - oldTrack, - track, - ); + discord.updatePresence(track ?? oldTrack!); } } @@ -469,18 +502,14 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier await audioPlayer.skipToPrevious(); if (oldTrack != null || track != null) { await notificationService.addTrack(track ?? oldTrack!); - } - if (oldTrack != null && track != null) { - await storeTrack( - oldTrack, - track, - ); + discord.updatePresence(track ?? oldTrack!); } } Future stop() async { state = ProxyPlaylist({}); await audioPlayer.stop(); + discord.clear(); } Future updatePalette() async { @@ -508,14 +537,20 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier Future> getAndCacheSkipSegments(String id) async { if (!preferences.skipNonMusic || - (preferences.youtubeApiType == YoutubeApiType.piped && + (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(json)).toList(), + (cached as List) + .map( + (json) => SkipSegment.fromJson( + Map.castFrom(json), + ), + ) + .toList(), ); } @@ -538,11 +573,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier )); if (res.body == "Not Found") { - Catcher.reportCheckedError( - "[SponsorBlock] no skip segments found for $id\n" - "${res.request?.url}", - StackTrace.current, - ); return List.castFrom([]); } @@ -555,7 +585,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier end, ); }).toList(); - getLogger('getSkipSegments').v( + getLogger('getSkipSegments').t( "[SponsorBlock] successfully fetched skip segments for $id", ); @@ -566,7 +596,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier return List.castFrom(segments); } catch (e, stack) { await SkipSegment.box.put(id, []); - Catcher.reportCheckedError(e, stack); + Catcher2.reportCheckedError(e, stack); return List.castFrom([]); } } @@ -587,7 +617,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier final oldCollections = state.collections; await load( state.tracks, - initialIndex: state.active ?? 0, + initialIndex: max(state.active ?? 0, 0), autoPlay: false, ); state = state.copyWith(collections: oldCollections); @@ -595,7 +625,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier @override FutureOr fromJson(Map json) { - return ProxyPlaylist.fromJson(json); + return ProxyPlaylist.fromJson(json, ref); } @override diff --git a/lib/provider/scrobbler_provider.dart b/lib/provider/scrobbler_provider.dart new file mode 100644 index 00000000..bf234e62 --- /dev/null +++ b/lib/provider/scrobbler_provider.dart @@ -0,0 +1,129 @@ +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/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart new file mode 100644 index 00000000..875f36cc --- /dev/null +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -0,0 +1,173 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; +import 'package:spotube/provider/palette_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/services/audio_player/audio_player.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; + +class UserPreferencesNotifier extends PersistedStateNotifier { + final Ref ref; + + UserPreferencesNotifier(this.ref) + : super(UserPreferences.withDefaults(), "preferences"); + + void reset() { + state = UserPreferences.withDefaults(); + } + + void setStreamMusicCodec(SourceCodecs codec) { + state = state.copyWith(streamMusicCodec: codec); + } + + void setDownloadMusicCodec(SourceCodecs codec) { + state = state.copyWith(downloadMusicCodec: codec); + } + + void setThemeMode(ThemeMode mode) { + state = state.copyWith(themeMode: mode); + } + + void setRecommendationMarket(Market country) { + state = state.copyWith(recommendationMarket: country); + } + + void setAccentColorScheme(SpotubeColor color) { + state = state.copyWith(accentColorScheme: color); + } + + 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); + } + + Future _getDefaultDownloadDirectory() async { + if (kIsAndroid) return "/storage/emulated/0/Download/Spotube"; + + if (kIsMacOS) { + return path.join((await getLibraryDirectory()).path, "Caches"); + } + + return getDownloadsDirectory().then((dir) { + return path.join(dir!.path, "Spotube"); + }); + } + + @override + FutureOr onInit() async { + if (state.downloadLocation.isEmpty) { + state = state.copyWith( + downloadLocation: await _getDefaultDownloadDirectory(), + ); + } + + if (DesktopTools.platform.isDesktop) { + await DesktopTools.window.setTitleBarStyle( + state.systemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, + ); + } + + await audioPlayer.setAudioNormalization(state.normalizeAudio); + } + + @override + FutureOr fromJson(Map json) { + return UserPreferences.fromJson(json); + } + + @override + Map toJson() { + return state.toJson(); + } +} + +final userPreferencesProvider = + StateNotifierProvider( + (ref) => UserPreferencesNotifier(ref), +); diff --git a/lib/provider/user_preferences/user_preferences_state.dart b/lib/provider/user_preferences/user_preferences_state.dart new file mode 100644 index 00000000..cf6c0597 --- /dev/null +++ b/lib/provider/user_preferences/user_preferences_state.dart @@ -0,0 +1,140 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; + +part 'user_preferences_state.g.dart'; +part 'user_preferences_state.freezed.dart'; + +@JsonEnum() +enum LayoutMode { + compact, + extended, + adaptive, +} + +@JsonEnum() +enum CloseBehavior { + minimizeToTray, + close, +} + +@JsonEnum() +enum AudioSource { + youtube, + piped, + jiosaavn; + + String get label => name[0].toUpperCase() + name.substring(1); +} + +@JsonEnum() +enum MusicCodec { + m4a._("M4a (Best for downloaded music)"), + weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"); + + final String label; + const MusicCodec._(this.label); +} + +@JsonEnum() +enum SearchMode { + youtube._("YouTube"), + youtubeMusic._("YouTube Music"); + + final String label; + + const SearchMode._(this.label); + + factory SearchMode.fromString(String key) { + return SearchMode.values.firstWhere((e) => e.name == key); + } +} + +@freezed +class UserPreferences with _$UserPreferences { + const factory UserPreferences({ + @Default(SourceQualities.high) SourceQualities audioQuality, + @Default(true) bool albumColorSync, + @Default(false) bool amoledDarkTheme, + @Default(true) bool checkUpdate, + @Default(false) bool normalizeAudio, + @Default(true) bool showSystemTrayIcon, + @Default(false) bool skipNonMusic, + @Default(false) bool systemTitleBar, + @Default(CloseBehavior.minimizeToTray) CloseBehavior closeBehavior, + @Default(SpotubeColor(0xFF2196F3, name: "Blue")) + @JsonKey( + fromJson: UserPreferences._accentColorSchemeFromJson, + toJson: UserPreferences._accentColorSchemeToJson, + readValue: UserPreferences._accentColorSchemeReadValue, + ) + SpotubeColor accentColorScheme, + @Default(LayoutMode.adaptive) LayoutMode layoutMode, + @Default(Locale("system", "system")) + @JsonKey( + fromJson: UserPreferences._localeFromJson, + toJson: UserPreferences._localeToJson, + readValue: UserPreferences._localeReadValue, + ) + Locale locale, + @Default(Market.US) Market recommendationMarket, + @Default(SearchMode.youtube) SearchMode searchMode, + @Default("") String downloadLocation, + @Default("https://pipedapi.kavin.rocks") String pipedInstance, + @Default(ThemeMode.system) ThemeMode themeMode, + @Default(AudioSource.youtube) AudioSource audioSource, + @Default(SourceCodecs.weba) SourceCodecs streamMusicCodec, + @Default(SourceCodecs.m4a) SourceCodecs downloadMusicCodec, + @Default(true) bool discordPresence, + @Default(true) bool endlessPlayback, + }) = _UserPreferences; + factory UserPreferences.fromJson(Map json) => + _$UserPreferencesFromJson(json); + + factory UserPreferences.withDefaults() => UserPreferences.fromJson({}); + + static SpotubeColor _accentColorSchemeFromJson(Map json) { + return SpotubeColor.fromString(json["color"]); + } + + static Map? _accentColorSchemeReadValue( + Map json, String key) { + if (json[key] is String) { + return {"color": json[key]}; + } + + return json[key] as Map?; + } + + static Map _accentColorSchemeToJson(SpotubeColor color) { + return {"color": color.toString()}; + } + + static Locale _localeFromJson(Map json) { + return Locale(json["languageCode"], json["countryCode"]); + } + + static Map _localeToJson(Locale locale) { + return { + "languageCode": locale.languageCode, + "countryCode": locale.countryCode, + }; + } + + static Map? _localeReadValue( + Map json, String key) { + if (json[key] is String) { + final map = jsonDecode(json[key]); + return { + "languageCode": map["lc"], + "countryCode": map["cc"], + }; + } + + return json[key] as Map?; + } +} diff --git a/lib/provider/user_preferences/user_preferences_state.freezed.dart b/lib/provider/user_preferences/user_preferences_state.freezed.dart new file mode 100644 index 00000000..4d08d1a9 --- /dev/null +++ b/lib/provider/user_preferences/user_preferences_state.freezed.dart @@ -0,0 +1,697 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'user_preferences_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + +UserPreferences _$UserPreferencesFromJson(Map json) { + return _UserPreferences.fromJson(json); +} + +/// @nodoc +mixin _$UserPreferences { + SourceQualities get audioQuality => throw _privateConstructorUsedError; + bool get albumColorSync => throw _privateConstructorUsedError; + bool get amoledDarkTheme => throw _privateConstructorUsedError; + bool get checkUpdate => throw _privateConstructorUsedError; + bool get normalizeAudio => throw _privateConstructorUsedError; + bool get showSystemTrayIcon => throw _privateConstructorUsedError; + bool get skipNonMusic => throw _privateConstructorUsedError; + bool get systemTitleBar => throw _privateConstructorUsedError; + CloseBehavior get closeBehavior => throw _privateConstructorUsedError; + @JsonKey( + fromJson: UserPreferences._accentColorSchemeFromJson, + toJson: UserPreferences._accentColorSchemeToJson, + readValue: UserPreferences._accentColorSchemeReadValue) + SpotubeColor get accentColorScheme => throw _privateConstructorUsedError; + LayoutMode get layoutMode => throw _privateConstructorUsedError; + @JsonKey( + fromJson: UserPreferences._localeFromJson, + toJson: UserPreferences._localeToJson, + readValue: UserPreferences._localeReadValue) + Locale get locale => throw _privateConstructorUsedError; + Market get recommendationMarket => throw _privateConstructorUsedError; + SearchMode get searchMode => throw _privateConstructorUsedError; + String get downloadLocation => throw _privateConstructorUsedError; + String get pipedInstance => throw _privateConstructorUsedError; + ThemeMode get themeMode => throw _privateConstructorUsedError; + AudioSource get audioSource => throw _privateConstructorUsedError; + SourceCodecs get streamMusicCodec => throw _privateConstructorUsedError; + SourceCodecs get downloadMusicCodec => throw _privateConstructorUsedError; + bool get discordPresence => throw _privateConstructorUsedError; + bool get endlessPlayback => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $UserPreferencesCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $UserPreferencesCopyWith<$Res> { + factory $UserPreferencesCopyWith( + UserPreferences value, $Res Function(UserPreferences) then) = + _$UserPreferencesCopyWithImpl<$Res, UserPreferences>; + @useResult + $Res call( + {SourceQualities audioQuality, + bool albumColorSync, + bool amoledDarkTheme, + bool checkUpdate, + bool normalizeAudio, + bool showSystemTrayIcon, + bool skipNonMusic, + bool systemTitleBar, + CloseBehavior closeBehavior, + @JsonKey( + fromJson: UserPreferences._accentColorSchemeFromJson, + toJson: UserPreferences._accentColorSchemeToJson, + readValue: UserPreferences._accentColorSchemeReadValue) + SpotubeColor accentColorScheme, + LayoutMode layoutMode, + @JsonKey( + fromJson: UserPreferences._localeFromJson, + toJson: UserPreferences._localeToJson, + readValue: UserPreferences._localeReadValue) + Locale locale, + Market recommendationMarket, + SearchMode searchMode, + String downloadLocation, + String pipedInstance, + ThemeMode themeMode, + AudioSource audioSource, + SourceCodecs streamMusicCodec, + SourceCodecs downloadMusicCodec, + bool discordPresence, + bool endlessPlayback}); +} + +/// @nodoc +class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> + implements $UserPreferencesCopyWith<$Res> { + _$UserPreferencesCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? audioQuality = null, + Object? albumColorSync = null, + Object? amoledDarkTheme = null, + Object? checkUpdate = null, + Object? normalizeAudio = null, + Object? showSystemTrayIcon = null, + Object? skipNonMusic = null, + Object? systemTitleBar = null, + Object? closeBehavior = null, + Object? accentColorScheme = null, + Object? layoutMode = null, + Object? locale = null, + Object? recommendationMarket = null, + Object? searchMode = null, + Object? downloadLocation = null, + Object? pipedInstance = null, + Object? themeMode = null, + Object? audioSource = null, + Object? streamMusicCodec = null, + Object? downloadMusicCodec = null, + Object? discordPresence = null, + Object? endlessPlayback = null, + }) { + return _then(_value.copyWith( + audioQuality: null == audioQuality + ? _value.audioQuality + : audioQuality // ignore: cast_nullable_to_non_nullable + as SourceQualities, + albumColorSync: null == albumColorSync + ? _value.albumColorSync + : albumColorSync // ignore: cast_nullable_to_non_nullable + as bool, + amoledDarkTheme: null == amoledDarkTheme + ? _value.amoledDarkTheme + : amoledDarkTheme // ignore: cast_nullable_to_non_nullable + as bool, + checkUpdate: null == checkUpdate + ? _value.checkUpdate + : checkUpdate // ignore: cast_nullable_to_non_nullable + as bool, + normalizeAudio: null == normalizeAudio + ? _value.normalizeAudio + : normalizeAudio // ignore: cast_nullable_to_non_nullable + as bool, + showSystemTrayIcon: null == showSystemTrayIcon + ? _value.showSystemTrayIcon + : showSystemTrayIcon // ignore: cast_nullable_to_non_nullable + as bool, + skipNonMusic: null == skipNonMusic + ? _value.skipNonMusic + : skipNonMusic // ignore: cast_nullable_to_non_nullable + as bool, + systemTitleBar: null == systemTitleBar + ? _value.systemTitleBar + : systemTitleBar // ignore: cast_nullable_to_non_nullable + as bool, + closeBehavior: null == closeBehavior + ? _value.closeBehavior + : closeBehavior // ignore: cast_nullable_to_non_nullable + as CloseBehavior, + accentColorScheme: null == accentColorScheme + ? _value.accentColorScheme + : accentColorScheme // ignore: cast_nullable_to_non_nullable + as SpotubeColor, + layoutMode: null == layoutMode + ? _value.layoutMode + : layoutMode // ignore: cast_nullable_to_non_nullable + as LayoutMode, + locale: null == locale + ? _value.locale + : locale // ignore: cast_nullable_to_non_nullable + as Locale, + recommendationMarket: null == recommendationMarket + ? _value.recommendationMarket + : recommendationMarket // ignore: cast_nullable_to_non_nullable + as Market, + searchMode: null == searchMode + ? _value.searchMode + : searchMode // ignore: cast_nullable_to_non_nullable + as SearchMode, + downloadLocation: null == downloadLocation + ? _value.downloadLocation + : downloadLocation // ignore: cast_nullable_to_non_nullable + as String, + pipedInstance: null == pipedInstance + ? _value.pipedInstance + : pipedInstance // ignore: cast_nullable_to_non_nullable + as String, + themeMode: null == themeMode + ? _value.themeMode + : themeMode // ignore: cast_nullable_to_non_nullable + as ThemeMode, + audioSource: null == audioSource + ? _value.audioSource + : audioSource // ignore: cast_nullable_to_non_nullable + as AudioSource, + streamMusicCodec: null == streamMusicCodec + ? _value.streamMusicCodec + : streamMusicCodec // ignore: cast_nullable_to_non_nullable + as SourceCodecs, + downloadMusicCodec: null == downloadMusicCodec + ? _value.downloadMusicCodec + : downloadMusicCodec // ignore: cast_nullable_to_non_nullable + as SourceCodecs, + discordPresence: null == discordPresence + ? _value.discordPresence + : discordPresence // ignore: cast_nullable_to_non_nullable + as bool, + endlessPlayback: null == endlessPlayback + ? _value.endlessPlayback + : endlessPlayback // ignore: cast_nullable_to_non_nullable + as bool, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$UserPreferencesImplCopyWith<$Res> + implements $UserPreferencesCopyWith<$Res> { + factory _$$UserPreferencesImplCopyWith(_$UserPreferencesImpl value, + $Res Function(_$UserPreferencesImpl) then) = + __$$UserPreferencesImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {SourceQualities audioQuality, + bool albumColorSync, + bool amoledDarkTheme, + bool checkUpdate, + bool normalizeAudio, + bool showSystemTrayIcon, + bool skipNonMusic, + bool systemTitleBar, + CloseBehavior closeBehavior, + @JsonKey( + fromJson: UserPreferences._accentColorSchemeFromJson, + toJson: UserPreferences._accentColorSchemeToJson, + readValue: UserPreferences._accentColorSchemeReadValue) + SpotubeColor accentColorScheme, + LayoutMode layoutMode, + @JsonKey( + fromJson: UserPreferences._localeFromJson, + toJson: UserPreferences._localeToJson, + readValue: UserPreferences._localeReadValue) + Locale locale, + Market recommendationMarket, + SearchMode searchMode, + String downloadLocation, + String pipedInstance, + ThemeMode themeMode, + AudioSource audioSource, + SourceCodecs streamMusicCodec, + SourceCodecs downloadMusicCodec, + bool discordPresence, + bool endlessPlayback}); +} + +/// @nodoc +class __$$UserPreferencesImplCopyWithImpl<$Res> + extends _$UserPreferencesCopyWithImpl<$Res, _$UserPreferencesImpl> + implements _$$UserPreferencesImplCopyWith<$Res> { + __$$UserPreferencesImplCopyWithImpl( + _$UserPreferencesImpl _value, $Res Function(_$UserPreferencesImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? audioQuality = null, + Object? albumColorSync = null, + Object? amoledDarkTheme = null, + Object? checkUpdate = null, + Object? normalizeAudio = null, + Object? showSystemTrayIcon = null, + Object? skipNonMusic = null, + Object? systemTitleBar = null, + Object? closeBehavior = null, + Object? accentColorScheme = null, + Object? layoutMode = null, + Object? locale = null, + Object? recommendationMarket = null, + Object? searchMode = null, + Object? downloadLocation = null, + Object? pipedInstance = null, + Object? themeMode = null, + Object? audioSource = null, + Object? streamMusicCodec = null, + Object? downloadMusicCodec = null, + Object? discordPresence = null, + Object? endlessPlayback = null, + }) { + return _then(_$UserPreferencesImpl( + audioQuality: null == audioQuality + ? _value.audioQuality + : audioQuality // ignore: cast_nullable_to_non_nullable + as SourceQualities, + albumColorSync: null == albumColorSync + ? _value.albumColorSync + : albumColorSync // ignore: cast_nullable_to_non_nullable + as bool, + amoledDarkTheme: null == amoledDarkTheme + ? _value.amoledDarkTheme + : amoledDarkTheme // ignore: cast_nullable_to_non_nullable + as bool, + checkUpdate: null == checkUpdate + ? _value.checkUpdate + : checkUpdate // ignore: cast_nullable_to_non_nullable + as bool, + normalizeAudio: null == normalizeAudio + ? _value.normalizeAudio + : normalizeAudio // ignore: cast_nullable_to_non_nullable + as bool, + showSystemTrayIcon: null == showSystemTrayIcon + ? _value.showSystemTrayIcon + : showSystemTrayIcon // ignore: cast_nullable_to_non_nullable + as bool, + skipNonMusic: null == skipNonMusic + ? _value.skipNonMusic + : skipNonMusic // ignore: cast_nullable_to_non_nullable + as bool, + systemTitleBar: null == systemTitleBar + ? _value.systemTitleBar + : systemTitleBar // ignore: cast_nullable_to_non_nullable + as bool, + closeBehavior: null == closeBehavior + ? _value.closeBehavior + : closeBehavior // ignore: cast_nullable_to_non_nullable + as CloseBehavior, + accentColorScheme: null == accentColorScheme + ? _value.accentColorScheme + : accentColorScheme // ignore: cast_nullable_to_non_nullable + as SpotubeColor, + layoutMode: null == layoutMode + ? _value.layoutMode + : layoutMode // ignore: cast_nullable_to_non_nullable + as LayoutMode, + locale: null == locale + ? _value.locale + : locale // ignore: cast_nullable_to_non_nullable + as Locale, + recommendationMarket: null == recommendationMarket + ? _value.recommendationMarket + : recommendationMarket // ignore: cast_nullable_to_non_nullable + as Market, + searchMode: null == searchMode + ? _value.searchMode + : searchMode // ignore: cast_nullable_to_non_nullable + as SearchMode, + downloadLocation: null == downloadLocation + ? _value.downloadLocation + : downloadLocation // ignore: cast_nullable_to_non_nullable + as String, + pipedInstance: null == pipedInstance + ? _value.pipedInstance + : pipedInstance // ignore: cast_nullable_to_non_nullable + as String, + themeMode: null == themeMode + ? _value.themeMode + : themeMode // ignore: cast_nullable_to_non_nullable + as ThemeMode, + audioSource: null == audioSource + ? _value.audioSource + : audioSource // ignore: cast_nullable_to_non_nullable + as AudioSource, + streamMusicCodec: null == streamMusicCodec + ? _value.streamMusicCodec + : streamMusicCodec // ignore: cast_nullable_to_non_nullable + as SourceCodecs, + downloadMusicCodec: null == downloadMusicCodec + ? _value.downloadMusicCodec + : downloadMusicCodec // ignore: cast_nullable_to_non_nullable + as SourceCodecs, + discordPresence: null == discordPresence + ? _value.discordPresence + : discordPresence // ignore: cast_nullable_to_non_nullable + as bool, + endlessPlayback: null == endlessPlayback + ? _value.endlessPlayback + : endlessPlayback // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$UserPreferencesImpl implements _UserPreferences { + const _$UserPreferencesImpl( + {this.audioQuality = SourceQualities.high, + this.albumColorSync = true, + this.amoledDarkTheme = false, + this.checkUpdate = true, + this.normalizeAudio = false, + this.showSystemTrayIcon = true, + this.skipNonMusic = false, + this.systemTitleBar = false, + this.closeBehavior = CloseBehavior.minimizeToTray, + @JsonKey( + fromJson: UserPreferences._accentColorSchemeFromJson, + toJson: UserPreferences._accentColorSchemeToJson, + readValue: UserPreferences._accentColorSchemeReadValue) + this.accentColorScheme = const SpotubeColor(0xFF2196F3, name: "Blue"), + this.layoutMode = LayoutMode.adaptive, + @JsonKey( + fromJson: UserPreferences._localeFromJson, + toJson: UserPreferences._localeToJson, + readValue: UserPreferences._localeReadValue) + this.locale = const Locale("system", "system"), + this.recommendationMarket = Market.US, + this.searchMode = SearchMode.youtube, + this.downloadLocation = "", + this.pipedInstance = "https://pipedapi.kavin.rocks", + this.themeMode = ThemeMode.system, + this.audioSource = AudioSource.youtube, + this.streamMusicCodec = SourceCodecs.weba, + this.downloadMusicCodec = SourceCodecs.m4a, + this.discordPresence = true, + this.endlessPlayback = true}); + + factory _$UserPreferencesImpl.fromJson(Map json) => + _$$UserPreferencesImplFromJson(json); + + @override + @JsonKey() + final SourceQualities audioQuality; + @override + @JsonKey() + final bool albumColorSync; + @override + @JsonKey() + final bool amoledDarkTheme; + @override + @JsonKey() + final bool checkUpdate; + @override + @JsonKey() + final bool normalizeAudio; + @override + @JsonKey() + final bool showSystemTrayIcon; + @override + @JsonKey() + final bool skipNonMusic; + @override + @JsonKey() + final bool systemTitleBar; + @override + @JsonKey() + final CloseBehavior closeBehavior; + @override + @JsonKey( + fromJson: UserPreferences._accentColorSchemeFromJson, + toJson: UserPreferences._accentColorSchemeToJson, + readValue: UserPreferences._accentColorSchemeReadValue) + final SpotubeColor accentColorScheme; + @override + @JsonKey() + final LayoutMode layoutMode; + @override + @JsonKey( + fromJson: UserPreferences._localeFromJson, + toJson: UserPreferences._localeToJson, + readValue: UserPreferences._localeReadValue) + final Locale locale; + @override + @JsonKey() + final Market recommendationMarket; + @override + @JsonKey() + final SearchMode searchMode; + @override + @JsonKey() + final String downloadLocation; + @override + @JsonKey() + final String pipedInstance; + @override + @JsonKey() + final ThemeMode themeMode; + @override + @JsonKey() + final AudioSource audioSource; + @override + @JsonKey() + final SourceCodecs streamMusicCodec; + @override + @JsonKey() + final SourceCodecs downloadMusicCodec; + @override + @JsonKey() + final bool discordPresence; + @override + @JsonKey() + final bool endlessPlayback; + + @override + String toString() { + return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$UserPreferencesImpl && + (identical(other.audioQuality, audioQuality) || + other.audioQuality == audioQuality) && + (identical(other.albumColorSync, albumColorSync) || + other.albumColorSync == albumColorSync) && + (identical(other.amoledDarkTheme, amoledDarkTheme) || + other.amoledDarkTheme == amoledDarkTheme) && + (identical(other.checkUpdate, checkUpdate) || + other.checkUpdate == checkUpdate) && + (identical(other.normalizeAudio, normalizeAudio) || + other.normalizeAudio == normalizeAudio) && + (identical(other.showSystemTrayIcon, showSystemTrayIcon) || + other.showSystemTrayIcon == showSystemTrayIcon) && + (identical(other.skipNonMusic, skipNonMusic) || + other.skipNonMusic == skipNonMusic) && + (identical(other.systemTitleBar, systemTitleBar) || + other.systemTitleBar == systemTitleBar) && + (identical(other.closeBehavior, closeBehavior) || + other.closeBehavior == closeBehavior) && + (identical(other.accentColorScheme, accentColorScheme) || + other.accentColorScheme == accentColorScheme) && + (identical(other.layoutMode, layoutMode) || + other.layoutMode == layoutMode) && + (identical(other.locale, locale) || other.locale == locale) && + (identical(other.recommendationMarket, recommendationMarket) || + other.recommendationMarket == recommendationMarket) && + (identical(other.searchMode, searchMode) || + other.searchMode == searchMode) && + (identical(other.downloadLocation, downloadLocation) || + other.downloadLocation == downloadLocation) && + (identical(other.pipedInstance, pipedInstance) || + other.pipedInstance == pipedInstance) && + (identical(other.themeMode, themeMode) || + other.themeMode == themeMode) && + (identical(other.audioSource, audioSource) || + other.audioSource == audioSource) && + (identical(other.streamMusicCodec, streamMusicCodec) || + other.streamMusicCodec == streamMusicCodec) && + (identical(other.downloadMusicCodec, downloadMusicCodec) || + other.downloadMusicCodec == downloadMusicCodec) && + (identical(other.discordPresence, discordPresence) || + other.discordPresence == discordPresence) && + (identical(other.endlessPlayback, endlessPlayback) || + other.endlessPlayback == endlessPlayback)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hashAll([ + runtimeType, + audioQuality, + albumColorSync, + amoledDarkTheme, + checkUpdate, + normalizeAudio, + showSystemTrayIcon, + skipNonMusic, + systemTitleBar, + closeBehavior, + accentColorScheme, + layoutMode, + locale, + recommendationMarket, + searchMode, + downloadLocation, + pipedInstance, + themeMode, + audioSource, + streamMusicCodec, + downloadMusicCodec, + discordPresence, + endlessPlayback + ]); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$UserPreferencesImplCopyWith<_$UserPreferencesImpl> get copyWith => + __$$UserPreferencesImplCopyWithImpl<_$UserPreferencesImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$UserPreferencesImplToJson( + this, + ); + } +} + +abstract class _UserPreferences implements UserPreferences { + const factory _UserPreferences( + {final SourceQualities audioQuality, + final bool albumColorSync, + final bool amoledDarkTheme, + final bool checkUpdate, + final bool normalizeAudio, + final bool showSystemTrayIcon, + final bool skipNonMusic, + final bool systemTitleBar, + final CloseBehavior closeBehavior, + @JsonKey( + fromJson: UserPreferences._accentColorSchemeFromJson, + toJson: UserPreferences._accentColorSchemeToJson, + readValue: UserPreferences._accentColorSchemeReadValue) + final SpotubeColor accentColorScheme, + final LayoutMode layoutMode, + @JsonKey( + fromJson: UserPreferences._localeFromJson, + toJson: UserPreferences._localeToJson, + readValue: UserPreferences._localeReadValue) + final Locale locale, + final Market recommendationMarket, + final SearchMode searchMode, + final String downloadLocation, + final String pipedInstance, + final ThemeMode themeMode, + final AudioSource audioSource, + final SourceCodecs streamMusicCodec, + final SourceCodecs downloadMusicCodec, + final bool discordPresence, + final bool endlessPlayback}) = _$UserPreferencesImpl; + + factory _UserPreferences.fromJson(Map json) = + _$UserPreferencesImpl.fromJson; + + @override + SourceQualities get audioQuality; + @override + bool get albumColorSync; + @override + bool get amoledDarkTheme; + @override + bool get checkUpdate; + @override + bool get normalizeAudio; + @override + bool get showSystemTrayIcon; + @override + bool get skipNonMusic; + @override + bool get systemTitleBar; + @override + CloseBehavior get closeBehavior; + @override + @JsonKey( + fromJson: UserPreferences._accentColorSchemeFromJson, + toJson: UserPreferences._accentColorSchemeToJson, + readValue: UserPreferences._accentColorSchemeReadValue) + SpotubeColor get accentColorScheme; + @override + LayoutMode get layoutMode; + @override + @JsonKey( + fromJson: UserPreferences._localeFromJson, + toJson: UserPreferences._localeToJson, + readValue: UserPreferences._localeReadValue) + Locale get locale; + @override + Market get recommendationMarket; + @override + SearchMode get searchMode; + @override + String get downloadLocation; + @override + String get pipedInstance; + @override + ThemeMode get themeMode; + @override + AudioSource get audioSource; + @override + SourceCodecs get streamMusicCodec; + @override + SourceCodecs get downloadMusicCodec; + @override + bool get discordPresence; + @override + bool get endlessPlayback; + @override + @JsonKey(ignore: true) + _$$UserPreferencesImplCopyWith<_$UserPreferencesImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/provider/user_preferences/user_preferences_state.g.dart b/lib/provider/user_preferences/user_preferences_state.g.dart new file mode 100644 index 00000000..ce488247 --- /dev/null +++ b/lib/provider/user_preferences/user_preferences_state.g.dart @@ -0,0 +1,382 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_preferences_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$UserPreferencesImpl _$$UserPreferencesImplFromJson( + Map json) => + _$UserPreferencesImpl( + audioQuality: + $enumDecodeNullable(_$SourceQualitiesEnumMap, json['audioQuality']) ?? + SourceQualities.high, + albumColorSync: json['albumColorSync'] as bool? ?? true, + amoledDarkTheme: json['amoledDarkTheme'] as bool? ?? false, + checkUpdate: json['checkUpdate'] as bool? ?? true, + normalizeAudio: json['normalizeAudio'] as bool? ?? false, + showSystemTrayIcon: json['showSystemTrayIcon'] as bool? ?? true, + skipNonMusic: json['skipNonMusic'] as bool? ?? false, + systemTitleBar: json['systemTitleBar'] as bool? ?? false, + closeBehavior: + $enumDecodeNullable(_$CloseBehaviorEnumMap, json['closeBehavior']) ?? + CloseBehavior.minimizeToTray, + accentColorScheme: UserPreferences._accentColorSchemeReadValue( + json, 'accentColorScheme') == + null + ? const SpotubeColor(0xFF2196F3, name: "Blue") + : UserPreferences._accentColorSchemeFromJson( + UserPreferences._accentColorSchemeReadValue( + json, 'accentColorScheme') as Map), + layoutMode: + $enumDecodeNullable(_$LayoutModeEnumMap, json['layoutMode']) ?? + LayoutMode.adaptive, + locale: UserPreferences._localeReadValue(json, 'locale') == null + ? const Locale("system", "system") + : UserPreferences._localeFromJson( + UserPreferences._localeReadValue(json, 'locale') + as Map), + recommendationMarket: + $enumDecodeNullable(_$MarketEnumMap, json['recommendationMarket']) ?? + Market.US, + searchMode: + $enumDecodeNullable(_$SearchModeEnumMap, json['searchMode']) ?? + SearchMode.youtube, + downloadLocation: json['downloadLocation'] as String? ?? "", + pipedInstance: + json['pipedInstance'] as String? ?? "https://pipedapi.kavin.rocks", + themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']) ?? + ThemeMode.system, + audioSource: + $enumDecodeNullable(_$AudioSourceEnumMap, json['audioSource']) ?? + AudioSource.youtube, + streamMusicCodec: $enumDecodeNullable( + _$SourceCodecsEnumMap, json['streamMusicCodec']) ?? + SourceCodecs.weba, + downloadMusicCodec: $enumDecodeNullable( + _$SourceCodecsEnumMap, json['downloadMusicCodec']) ?? + SourceCodecs.m4a, + discordPresence: json['discordPresence'] as bool? ?? true, + endlessPlayback: json['endlessPlayback'] as bool? ?? true, + ); + +Map _$$UserPreferencesImplToJson( + _$UserPreferencesImpl instance) => + { + 'audioQuality': _$SourceQualitiesEnumMap[instance.audioQuality]!, + 'albumColorSync': instance.albumColorSync, + 'amoledDarkTheme': instance.amoledDarkTheme, + 'checkUpdate': instance.checkUpdate, + 'normalizeAudio': instance.normalizeAudio, + 'showSystemTrayIcon': instance.showSystemTrayIcon, + 'skipNonMusic': instance.skipNonMusic, + 'systemTitleBar': instance.systemTitleBar, + 'closeBehavior': _$CloseBehaviorEnumMap[instance.closeBehavior]!, + 'accentColorScheme': + UserPreferences._accentColorSchemeToJson(instance.accentColorScheme), + 'layoutMode': _$LayoutModeEnumMap[instance.layoutMode]!, + 'locale': UserPreferences._localeToJson(instance.locale), + 'recommendationMarket': _$MarketEnumMap[instance.recommendationMarket]!, + 'searchMode': _$SearchModeEnumMap[instance.searchMode]!, + 'downloadLocation': instance.downloadLocation, + 'pipedInstance': instance.pipedInstance, + 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, + 'audioSource': _$AudioSourceEnumMap[instance.audioSource]!, + 'streamMusicCodec': _$SourceCodecsEnumMap[instance.streamMusicCodec]!, + 'downloadMusicCodec': _$SourceCodecsEnumMap[instance.downloadMusicCodec]!, + 'discordPresence': instance.discordPresence, + 'endlessPlayback': instance.endlessPlayback, + }; + +const _$SourceQualitiesEnumMap = { + SourceQualities.high: 'high', + SourceQualities.medium: 'medium', + SourceQualities.low: 'low', +}; + +const _$CloseBehaviorEnumMap = { + CloseBehavior.minimizeToTray: 'minimizeToTray', + CloseBehavior.close: 'close', +}; + +const _$LayoutModeEnumMap = { + LayoutMode.compact: 'compact', + LayoutMode.extended: 'extended', + LayoutMode.adaptive: 'adaptive', +}; + +const _$MarketEnumMap = { + Market.AD: 'AD', + Market.AE: 'AE', + Market.AF: 'AF', + Market.AG: 'AG', + Market.AI: 'AI', + Market.AL: 'AL', + Market.AM: 'AM', + Market.AO: 'AO', + Market.AQ: 'AQ', + Market.AR: 'AR', + Market.AS: 'AS', + Market.AT: 'AT', + Market.AU: 'AU', + Market.AW: 'AW', + Market.AX: 'AX', + Market.AZ: 'AZ', + Market.BA: 'BA', + Market.BB: 'BB', + Market.BD: 'BD', + Market.BE: 'BE', + Market.BF: 'BF', + Market.BG: 'BG', + Market.BH: 'BH', + Market.BI: 'BI', + Market.BJ: 'BJ', + Market.BL: 'BL', + Market.BM: 'BM', + Market.BN: 'BN', + Market.BO: 'BO', + Market.BQ: 'BQ', + Market.BR: 'BR', + Market.BS: 'BS', + Market.BT: 'BT', + Market.BV: 'BV', + Market.BW: 'BW', + Market.BY: 'BY', + Market.BZ: 'BZ', + Market.CA: 'CA', + Market.CC: 'CC', + Market.CD: 'CD', + Market.CF: 'CF', + Market.CG: 'CG', + Market.CH: 'CH', + Market.CI: 'CI', + Market.CK: 'CK', + Market.CL: 'CL', + Market.CM: 'CM', + Market.CN: 'CN', + Market.CO: 'CO', + Market.CR: 'CR', + Market.CU: 'CU', + Market.CV: 'CV', + Market.CW: 'CW', + Market.CX: 'CX', + Market.CY: 'CY', + Market.CZ: 'CZ', + Market.DE: 'DE', + Market.DJ: 'DJ', + Market.DK: 'DK', + Market.DM: 'DM', + Market.DO: 'DO', + Market.DZ: 'DZ', + Market.EC: 'EC', + Market.EE: 'EE', + Market.EG: 'EG', + Market.EH: 'EH', + Market.ER: 'ER', + Market.ES: 'ES', + Market.ET: 'ET', + Market.FI: 'FI', + Market.FJ: 'FJ', + Market.FK: 'FK', + Market.FM: 'FM', + Market.FO: 'FO', + Market.FR: 'FR', + Market.GA: 'GA', + Market.GB: 'GB', + Market.GD: 'GD', + Market.GE: 'GE', + Market.GF: 'GF', + Market.GG: 'GG', + Market.GH: 'GH', + Market.GI: 'GI', + Market.GL: 'GL', + Market.GM: 'GM', + Market.GN: 'GN', + Market.GP: 'GP', + Market.GQ: 'GQ', + Market.GR: 'GR', + Market.GS: 'GS', + Market.GT: 'GT', + Market.GU: 'GU', + Market.GW: 'GW', + Market.GY: 'GY', + Market.HK: 'HK', + Market.HM: 'HM', + Market.HN: 'HN', + Market.HR: 'HR', + Market.HT: 'HT', + Market.HU: 'HU', + Market.ID: 'ID', + Market.IE: 'IE', + Market.IL: 'IL', + Market.IM: 'IM', + Market.IN: 'IN', + Market.IO: 'IO', + Market.IQ: 'IQ', + Market.IR: 'IR', + Market.IS: 'IS', + Market.IT: 'IT', + Market.JE: 'JE', + Market.JM: 'JM', + Market.JO: 'JO', + Market.JP: 'JP', + Market.KE: 'KE', + Market.KG: 'KG', + Market.KH: 'KH', + Market.KI: 'KI', + Market.KM: 'KM', + Market.KN: 'KN', + Market.KP: 'KP', + Market.KR: 'KR', + Market.KW: 'KW', + Market.KY: 'KY', + Market.KZ: 'KZ', + Market.LA: 'LA', + Market.LB: 'LB', + Market.LC: 'LC', + Market.LI: 'LI', + Market.LK: 'LK', + Market.LR: 'LR', + Market.LS: 'LS', + Market.LT: 'LT', + Market.LU: 'LU', + Market.LV: 'LV', + Market.LY: 'LY', + Market.MA: 'MA', + Market.MC: 'MC', + Market.MD: 'MD', + Market.ME: 'ME', + Market.MF: 'MF', + Market.MG: 'MG', + Market.MH: 'MH', + Market.MK: 'MK', + Market.ML: 'ML', + Market.MM: 'MM', + Market.MN: 'MN', + Market.MO: 'MO', + Market.MP: 'MP', + Market.MQ: 'MQ', + Market.MR: 'MR', + Market.MS: 'MS', + Market.MT: 'MT', + Market.MU: 'MU', + Market.MV: 'MV', + Market.MW: 'MW', + Market.MX: 'MX', + Market.MY: 'MY', + Market.MZ: 'MZ', + Market.NA: 'NA', + Market.NC: 'NC', + Market.NE: 'NE', + Market.NF: 'NF', + Market.NG: 'NG', + Market.NI: 'NI', + Market.NL: 'NL', + Market.NO: 'NO', + Market.NP: 'NP', + Market.NR: 'NR', + Market.NU: 'NU', + Market.NZ: 'NZ', + Market.OM: 'OM', + Market.PA: 'PA', + Market.PE: 'PE', + Market.PF: 'PF', + Market.PG: 'PG', + Market.PH: 'PH', + Market.PK: 'PK', + Market.PL: 'PL', + Market.PM: 'PM', + Market.PN: 'PN', + Market.PR: 'PR', + Market.PS: 'PS', + Market.PT: 'PT', + Market.PW: 'PW', + Market.PY: 'PY', + Market.QA: 'QA', + Market.RE: 'RE', + Market.RO: 'RO', + Market.RS: 'RS', + Market.RU: 'RU', + Market.RW: 'RW', + Market.SA: 'SA', + Market.SB: 'SB', + Market.SC: 'SC', + Market.SD: 'SD', + Market.SE: 'SE', + Market.SG: 'SG', + Market.SH: 'SH', + Market.SI: 'SI', + Market.SJ: 'SJ', + Market.SK: 'SK', + Market.SL: 'SL', + Market.SM: 'SM', + Market.SN: 'SN', + Market.SO: 'SO', + Market.SR: 'SR', + Market.SS: 'SS', + Market.ST: 'ST', + Market.SV: 'SV', + Market.SX: 'SX', + Market.SY: 'SY', + Market.SZ: 'SZ', + Market.TC: 'TC', + Market.TD: 'TD', + Market.TF: 'TF', + Market.TG: 'TG', + Market.TH: 'TH', + Market.TJ: 'TJ', + Market.TK: 'TK', + Market.TL: 'TL', + Market.TM: 'TM', + Market.TN: 'TN', + Market.TO: 'TO', + Market.TR: 'TR', + Market.TT: 'TT', + Market.TV: 'TV', + Market.TW: 'TW', + Market.TZ: 'TZ', + Market.UA: 'UA', + Market.UG: 'UG', + Market.UM: 'UM', + Market.US: 'US', + Market.UY: 'UY', + Market.UZ: 'UZ', + Market.VA: 'VA', + Market.VC: 'VC', + Market.VE: 'VE', + Market.VG: 'VG', + Market.VI: 'VI', + Market.VN: 'VN', + Market.VU: 'VU', + Market.WF: 'WF', + Market.WS: 'WS', + Market.XK: 'XK', + Market.YE: 'YE', + Market.YT: 'YT', + Market.ZA: 'ZA', + Market.ZM: 'ZM', + Market.ZW: 'ZW', +}; + +const _$SearchModeEnumMap = { + SearchMode.youtube: 'youtube', + SearchMode.youtubeMusic: 'youtubeMusic', +}; + +const _$ThemeModeEnumMap = { + ThemeMode.system: 'system', + ThemeMode.light: 'light', + ThemeMode.dark: 'dark', +}; + +const _$AudioSourceEnumMap = { + AudioSource.youtube: 'youtube', + AudioSource.piped: 'piped', + AudioSource.jiosaavn: 'jiosaavn', +}; + +const _$SourceCodecsEnumMap = { + SourceCodecs.m4a: 'm4a', + SourceCodecs.weba: 'weba', +}; diff --git a/lib/provider/user_preferences_provider.dart b/lib/provider/user_preferences_provider.dart deleted file mode 100644 index e1df5bfe..00000000 --- a/lib/provider/user_preferences_provider.dart +++ /dev/null @@ -1,333 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; -import 'package:spotube/models/matched_track.dart'; -import 'package:spotube/provider/palette_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; - -import 'package:spotube/utils/persisted_change_notifier.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:path/path.dart' as path; - -enum LayoutMode { - compact, - extended, - adaptive, -} - -enum AudioQuality { - high, - low, -} - -enum CloseBehavior { - minimizeToTray, - close, -} - -enum YoutubeApiType { - youtube, - piped; - - String get label => name[0].toUpperCase() + name.substring(1); -} - -class UserPreferences extends PersistedChangeNotifier { - ThemeMode themeMode; - String recommendationMarket; - bool saveTrackLyrics; - bool checkUpdate; - AudioQuality audioQuality; - - SpotubeColor accentColorScheme; - bool albumColorSync; - - String downloadLocation; - - LayoutMode layoutMode; - - CloseBehavior closeBehavior; - - bool showSystemTrayIcon; - - Locale locale; - - String pipedInstance; - - SearchMode searchMode; - - bool skipNonMusic; - - YoutubeApiType youtubeApiType; - - final Ref ref; - - UserPreferences( - this.ref, { - required this.recommendationMarket, - required this.themeMode, - required this.layoutMode, - required this.accentColorScheme, - this.albumColorSync = true, - this.saveTrackLyrics = false, - this.checkUpdate = true, - this.audioQuality = AudioQuality.high, - this.downloadLocation = "", - this.closeBehavior = CloseBehavior.close, - this.showSystemTrayIcon = true, - this.locale = const Locale("system", "system"), - this.pipedInstance = "https://pipedapi.kavin.rocks", - this.searchMode = SearchMode.youtube, - this.skipNonMusic = true, - this.youtubeApiType = YoutubeApiType.youtube, - }) : super() { - if (downloadLocation.isEmpty && !kIsWeb) { - _getDefaultDownloadDirectory().then( - (value) { - downloadLocation = value; - }, - ); - } - } - - void setThemeMode(ThemeMode mode) { - themeMode = mode; - notifyListeners(); - updatePersistence(); - } - - void setSaveTrackLyrics(bool shouldSave) { - saveTrackLyrics = shouldSave; - notifyListeners(); - updatePersistence(); - } - - void setRecommendationMarket(String country) { - recommendationMarket = country; - notifyListeners(); - updatePersistence(); - } - - void setAccentColorScheme(SpotubeColor color) { - accentColorScheme = color; - notifyListeners(); - updatePersistence(); - } - - void setAlbumColorSync(bool sync) { - albumColorSync = sync; - if (!sync) { - ref.read(paletteProvider.notifier).state = null; - } else { - ref.read(ProxyPlaylistNotifier.notifier).updatePalette(); - } - notifyListeners(); - updatePersistence(); - } - - void setCheckUpdate(bool check) { - checkUpdate = check; - notifyListeners(); - updatePersistence(); - } - - void setAudioQuality(AudioQuality quality) { - audioQuality = quality; - notifyListeners(); - updatePersistence(); - } - - void setDownloadLocation(String downloadDir) { - if (downloadDir.isEmpty) return; - downloadLocation = downloadDir; - notifyListeners(); - updatePersistence(); - } - - void setLayoutMode(LayoutMode mode) { - layoutMode = mode; - notifyListeners(); - updatePersistence(); - } - - void setCloseBehavior(CloseBehavior behavior) { - closeBehavior = behavior; - notifyListeners(); - updatePersistence(); - } - - void setShowSystemTrayIcon(bool show) { - showSystemTrayIcon = show; - notifyListeners(); - updatePersistence(); - } - - void setLocale(Locale locale) { - this.locale = locale; - notifyListeners(); - updatePersistence(); - } - - void setPipedInstance(String instance) { - pipedInstance = instance; - notifyListeners(); - updatePersistence(); - } - - void setSearchMode(SearchMode mode) { - searchMode = mode; - notifyListeners(); - updatePersistence(); - } - - void setSkipNonMusic(bool skip) { - skipNonMusic = skip; - notifyListeners(); - updatePersistence(); - } - - void setYoutubeApiType(YoutubeApiType type) { - youtubeApiType = type; - notifyListeners(); - updatePersistence(); - } - - Future _getDefaultDownloadDirectory() async { - if (kIsAndroid) return "/storage/emulated/0/Download/Spotube"; - - if (kIsMacOS) { - return path.join((await getLibraryDirectory()).path, "Caches"); - } - - return getDownloadsDirectory().then((dir) { - return path.join(dir!.path, "Spotube"); - }); - } - - @override - FutureOr loadFromLocal(Map map) async { - saveTrackLyrics = map["saveTrackLyrics"] ?? false; - recommendationMarket = map["recommendationMarket"] ?? recommendationMarket; - checkUpdate = map["checkUpdate"] ?? checkUpdate; - - themeMode = ThemeMode.values[map["themeMode"] ?? 0]; - accentColorScheme = map["accentColorScheme"] != null - ? SpotubeColor.fromString(map["accentColorScheme"]) - : accentColorScheme; - albumColorSync = map["albumColorSync"] ?? albumColorSync; - audioQuality = map["audioQuality"] != null - ? AudioQuality.values[map["audioQuality"]] - : audioQuality; - - if (!kIsWeb) { - downloadLocation = - map["downloadLocation"] ?? await _getDefaultDownloadDirectory(); - } - - layoutMode = LayoutMode.values.firstWhere( - (mode) => mode.name == map["layoutMode"], - orElse: () => kIsDesktop ? LayoutMode.extended : LayoutMode.compact, - ); - - closeBehavior = map["closeBehavior"] != null - ? CloseBehavior.values[map["closeBehavior"]] - : closeBehavior; - - showSystemTrayIcon = map["showSystemTrayIcon"] ?? showSystemTrayIcon; - - final localeMap = map["locale"] != null ? jsonDecode(map["locale"]) : null; - locale = - localeMap != null ? Locale(localeMap?["lc"], localeMap?["cc"]) : locale; - - pipedInstance = map["pipedInstance"] ?? pipedInstance; - - searchMode = SearchMode.values.firstWhere( - (mode) => mode.name == map["searchMode"], - orElse: () => SearchMode.youtube, - ); - - skipNonMusic = map["skipNonMusic"] ?? skipNonMusic; - - youtubeApiType = YoutubeApiType.values.firstWhere( - (type) => type.name == map["youtubeApiType"], - orElse: () => YoutubeApiType.youtube, - ); - } - - @override - FutureOr> toMap() { - return { - "saveTrackLyrics": saveTrackLyrics, - "recommendationMarket": recommendationMarket, - "themeMode": themeMode.index, - "accentColorScheme": accentColorScheme.toString(), - "albumColorSync": albumColorSync, - "checkUpdate": checkUpdate, - "audioQuality": audioQuality.index, - "downloadLocation": downloadLocation, - "layoutMode": layoutMode.name, - "closeBehavior": closeBehavior.index, - "showSystemTrayIcon": showSystemTrayIcon, - "locale": - jsonEncode({"lc": locale.languageCode, "cc": locale.countryCode}), - "pipedInstance": pipedInstance, - "searchMode": searchMode.name, - "skipNonMusic": skipNonMusic, - "youtubeApiType": youtubeApiType.name, - }; - } - - UserPreferences copyWith({ - ThemeMode? themeMode, - SpotubeColor? accentColorScheme, - bool? albumColorSync, - bool? checkUpdate, - AudioQuality? audioQuality, - String? downloadLocation, - LayoutMode? layoutMode, - CloseBehavior? closeBehavior, - bool? showSystemTrayIcon, - Locale? locale, - String? pipedInstance, - SearchMode? searchMode, - bool? skipNonMusic, - YoutubeApiType? youtubeApiType, - String? recommendationMarket, - bool? saveTrackLyrics, - }) { - return UserPreferences( - ref, - themeMode: themeMode ?? this.themeMode, - accentColorScheme: accentColorScheme ?? this.accentColorScheme, - albumColorSync: albumColorSync ?? this.albumColorSync, - checkUpdate: checkUpdate ?? this.checkUpdate, - audioQuality: audioQuality ?? this.audioQuality, - downloadLocation: downloadLocation ?? this.downloadLocation, - layoutMode: layoutMode ?? this.layoutMode, - closeBehavior: closeBehavior ?? this.closeBehavior, - showSystemTrayIcon: showSystemTrayIcon ?? this.showSystemTrayIcon, - locale: locale ?? this.locale, - pipedInstance: pipedInstance ?? this.pipedInstance, - searchMode: searchMode ?? this.searchMode, - skipNonMusic: skipNonMusic ?? this.skipNonMusic, - youtubeApiType: youtubeApiType ?? this.youtubeApiType, - recommendationMarket: recommendationMarket ?? this.recommendationMarket, - saveTrackLyrics: saveTrackLyrics ?? this.saveTrackLyrics, - ); - } -} - -final userPreferencesProvider = ChangeNotifierProvider( - (ref) => UserPreferences( - ref, - accentColorScheme: SpotubeColor(Colors.blue.value, name: "Blue"), - recommendationMarket: 'US', - themeMode: ThemeMode.system, - layoutMode: LayoutMode.adaptive, - ), -); diff --git a/lib/provider/youtube_provider.dart b/lib/provider/youtube_provider.dart deleted file mode 100644 index 20b5ba2b..00000000 --- a/lib/provider/youtube_provider.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/services/youtube/youtube.dart'; - -final youtubeProvider = Provider((ref) { - final preferences = ref.watch(userPreferencesProvider); - return YoutubeEndpoints(preferences); -}); - -// this provider overrides the API provider to use piped.video for downloading -final downloadYoutubeProvider = Provider((ref) { - final preferences = ref.watch(userPreferencesProvider); - return YoutubeEndpoints( - preferences.copyWith( - youtubeApiType: YoutubeApiType.piped, - ), - ); -}); diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index 3a54f9ba..b3957964 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -1,13 +1,13 @@ -import 'package:catcher/catcher.dart'; +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:async'; import 'package:media_kit/media_kit.dart' as mk; -import 'package:spotube/models/spotube_track.dart'; 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'; part 'audio_players_streams_mixin.dart'; part 'audio_player_impl.dart'; @@ -16,12 +16,16 @@ abstract class AudioPlayerInterface { final MkPlayerWithState _mkPlayer; // final ja.AudioPlayer? _justAudio; - AudioPlayerInterface() : _mkPlayer = MkPlayerWithState() - // _mkPlayer = _mkSupportedPlatform ? MkPlayerWithState() : null, + AudioPlayerInterface() + : _mkPlayer = MkPlayerWithState( + configuration: const mk.PlayerConfiguration( + title: "Spotube", + ), + ) // _justAudio = !_mkSupportedPlatform ? ja.AudioPlayer() : null { _mkPlayer.stream.error.listen((event) { - Catcher.reportCheckedError(event, StackTrace.current); + Catcher2.reportCheckedError(event, StackTrace.current); }); } @@ -111,7 +115,7 @@ abstract class AudioPlayerInterface { // } } - Future get loopMode async { + PlaybackLoopMode get loopMode { return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.loopMode); // if (mkSupportedPlatform) { // return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.loopMode); diff --git a/lib/services/audio_player/audio_player_impl.dart b/lib/services/audio_player/audio_player_impl.dart index cca8c36c..2af94dd7 100644 --- a/lib/services/audio_player/audio_player_impl.dart +++ b/lib/services/audio_player/audio_player_impl.dart @@ -121,11 +121,13 @@ class SpotubeAudioPlayer extends AudioPlayerInterface // } } - List resolveTracksForSource(List tracks) { - return tracks.where((e) => sources.contains(e.ytUri)).toList(); + // 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) { + bool tracksExistsInPlaylist(List tracks) { return resolveTracksForSource(tracks).length == tracks.length; } @@ -142,6 +144,7 @@ class SpotubeAudioPlayer extends AudioPlayerInterface String? get currentSource { // if (mkSupportedPlatform) { + if (_mkPlayer.playlist.index == -1) return null; return _mkPlayer.playlist.medias .elementAtOrNull(_mkPlayer.playlist.index) ?.uri; @@ -156,6 +159,12 @@ class SpotubeAudioPlayer extends AudioPlayerInterface String? get nextSource { // if (mkSupportedPlatform) { + + if (loopMode == PlaybackLoopMode.all && + _mkPlayer.playlist.index == _mkPlayer.playlist.medias.length - 1) { + return sources.first; + } + return _mkPlayer.playlist.medias .elementAtOrNull(_mkPlayer.playlist.index + 1) ?.uri; @@ -169,6 +178,10 @@ class SpotubeAudioPlayer extends AudioPlayerInterface } String? get previousSource { + if (loopMode == PlaybackLoopMode.all && _mkPlayer.playlist.index == 0) { + return sources.last; + } + // if (mkSupportedPlatform) { return _mkPlayer.playlist.medias .elementAtOrNull(_mkPlayer.playlist.index - 1) @@ -302,4 +315,8 @@ class SpotubeAudioPlayer extends AudioPlayerInterface // await _justAudio!.setLoopMode(loop.toLoopMode()); // } } + + Future setAudioNormalization(bool normalize) async { + await _mkPlayer.setAudioNormalization(normalize); + } } diff --git a/lib/services/audio_player/mk_state_player.dart b/lib/services/audio_player/mk_state_player.dart index 84765727..a556afec 100644 --- a/lib/services/audio_player/mk_state_player.dart +++ b/lib/services/audio_player/mk_state_player.dart @@ -1,10 +1,9 @@ import 'dart:async'; -import 'package:catcher/catcher.dart'; +import 'package:catcher_2/catcher_2.dart'; import 'package:collection/collection.dart'; import 'package:media_kit/media_kit.dart'; // ignore: implementation_imports -import 'package:media_kit/src/models/playable.dart'; import 'package:spotube/services/audio_player/playback_state.dart'; /// MediaKit [Player] by default doesn't have a state stream. @@ -46,7 +45,6 @@ class MkPlayerWithState extends Player { if (!isCompleted) return; _playerStateStream.add(AudioPlaybackState.completed); - if (loopMode == PlaylistMode.single) { await super.open(_playlist!.medias[_playlist!.index], play: true); } else { @@ -54,7 +52,7 @@ class MkPlayerWithState extends Player { await Future.delayed(const Duration(milliseconds: 250), play); } } catch (e, stackTrace) { - Catcher.reportCheckedError(e, stackTrace); + Catcher2.reportCheckedError(e, stackTrace); } }), stream.playlist.listen((event) { @@ -63,7 +61,7 @@ class MkPlayerWithState extends Player { } }), stream.error.listen((event) { - Catcher.reportCheckedError('[MediaKitError] \n$event', null); + Catcher2.reportCheckedError('[MediaKitError] \n$event', null); }), ]; } @@ -98,7 +96,10 @@ class MkPlayerWithState extends Player { if (shuffle) { _tempMedias = _playlist!.medias; final active = _playlist!.medias[_playlist!.index]; - final newMedias = _playlist!.medias.toList()..shuffle(); + final newMedias = _playlist!.medias.toList() + ..shuffle() + ..remove(active) + ..insert(0, active); playlist = _playlist!.copyWith( medias: newMedias, index: newMedias.indexOf(active), @@ -124,7 +125,9 @@ class MkPlayerWithState extends Player { _loopModeStream.add(playlistMode); } + @override Future stop() async { + await super.stop(); await pause(); await seek(Duration.zero); @@ -159,17 +162,27 @@ class MkPlayerWithState extends Player { @override Future next() async { - if (_playlist == null || _playlist!.index + 1 >= _playlist!.medias.length) { + if (_playlist == null) { return; } final isLast = _playlist!.index == _playlist!.medias.length - 1; - if (loopMode == PlaylistMode.loop && isLast) { - playlist = _playlist!.copyWith(index: 0); - return super.open(_playlist!.medias[_playlist!.index], play: true); - } else if (!isLast) { + 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); } } @@ -190,7 +203,7 @@ class MkPlayerWithState extends Player { @override Future jump(int index) async { if (_playlist == null || index < 0 || index >= _playlist!.medias.length) { - return null; + return; } playlist = _playlist!.copyWith(index: index); @@ -233,30 +246,30 @@ class MkPlayerWithState extends Player { final isOldUrlPlaying = _playlist!.medias[_playlist!.index].uri == oldUrl; - for (var i = 0; i < _playlist!.medias.length - 1; i++) { - final media = _playlist!.medias[i]; - if (media.uri == oldUrl) { - if (isOldUrlPlaying) { - pause(); - } - final newMedias = _playlist!.medias.toList(); - newMedias[i] = Media(newUrl, extras: media.extras); - playlist = _playlist!.copyWith(medias: newMedias); - if (isOldUrlPlaying) { - super.open( - newMedias[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); - } - break; + // 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 @@ -316,4 +329,14 @@ class MkPlayerWithState extends Player { 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 6d6c9d43..a6ecac3f 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -2,60 +2,42 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/models/spotube_track.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_services/linux_audio_service.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'; class AudioServices { final MobileAudioService? mobile; final WindowsAudioService? smtc; - final LinuxAudioService? mpris; - AudioServices(this.mobile, this.smtc, this.mpris); + AudioServices(this.mobile, this.smtc); static Future create( Ref ref, ProxyPlaylistNotifier playback, ) async { - final mobile = - DesktopTools.platform.isMobile || DesktopTools.platform.isMacOS - ? await AudioService.init( - builder: () => MobileAudioService(playback), - config: const AudioServiceConfig( - androidNotificationChannelId: 'com.krtirtho.Spotube', - androidNotificationChannelName: 'Spotube', - androidNotificationOngoing: true, - ), - ) - : null; + final mobile = DesktopTools.platform.isMobile || + DesktopTools.platform.isMacOS || + DesktopTools.platform.isLinux + ? await AudioService.init( + builder: () => MobileAudioService(playback), + config: const AudioServiceConfig( + androidNotificationChannelId: 'com.krtirtho.Spotube', + androidNotificationChannelName: 'Spotube', + androidNotificationOngoing: true, + ), + ) + : null; final smtc = DesktopTools.platform.isWindows ? WindowsAudioService(ref, playback) : null; - final mpris = - DesktopTools.platform.isLinux ? LinuxAudioService(ref, playback) : null; - if (mpris != null) { - playback.addListener((state) { - mpris.player.updateProperties(); - }); - audioPlayer.playerStateStream.listen((state) { - mpris.player.updateProperties(); - }); - audioPlayer.positionStream.listen((state) async { - await mpris.player.emitPropertiesChanged( - "org.mpris.MediaPlayer2.Player", - changedProperties: { - "Position": (await mpris.player.getPosition()).returnValues.first, - }, - ); - }); - } - - return AudioServices(mobile, smtc, mpris); + return AudioServices( + mobile, + smtc, + ); } Future addTrack(Track track) async { @@ -65,8 +47,8 @@ class AudioServices { album: track.album?.name ?? "", title: track.name!, artist: TypeConversionUtils.artists_X_String(track.artists ?? []), - duration: track is SpotubeTrack - ? track.ytTrack.duration + duration: track is SourcedTrack + ? track.sourceInfo.duration : Duration(milliseconds: track.durationMs ?? 0), artUri: Uri.parse(TypeConversionUtils.image_X_UrlString( track.album?.images ?? [], @@ -86,6 +68,5 @@ class AudioServices { void dispose() { smtc?.dispose(); - mpris?.dispose(); } } diff --git a/lib/services/audio_services/linux_audio_service.dart b/lib/services/audio_services/linux_audio_service.dart index 28370c86..436627e6 100644 --- a/lib/services/audio_services/linux_audio_service.dart +++ b/lib/services/audio_services/linux_audio_service.dart @@ -3,13 +3,12 @@ import 'dart:io'; import 'package:dbus/dbus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/models/spotube_track.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'; -import 'package:window_manager/window_manager.dart'; final dbus = DBusClient.session(); @@ -86,8 +85,7 @@ class _MprisMediaPlayer2 extends DBusObject { /// Implementation of org.mpris.MediaPlayer2.Quit() Future doQuit() async { - await windowManager.close(); - return DBusMethodSuccessResponse(); + exit(0); } @override @@ -322,8 +320,8 @@ class _MprisMediaPlayer2Player extends DBusObject { ), "xesam:title": DBusString(playlist.activeTrack!.name!), "xesam:url": DBusString( - playlist.activeTrack is SpotubeTrack - ? (playlist.activeTrack as SpotubeTrack).ytUri + playlist.activeTrack is SourcedTrack + ? (playlist.activeTrack as SourcedTrack).url : playlist.activeTrack!.previewUrl ?? "", ), "xesam:genre": const DBusString("Unknown"), diff --git a/lib/services/audio_services/mobile_audio_service.dart b/lib/services/audio_services/mobile_audio_service.dart index e9bf7a3e..833df89c 100644 --- a/lib/services/audio_services/mobile_audio_service.dart +++ b/lib/services/audio_services/mobile_audio_service.dart @@ -17,17 +17,42 @@ class MobileAudioService extends BaseAudioHandler { AudioSession.instance.then((s) { session = s; session?.configure(const AudioSessionConfiguration.music()); + + bool wasPausedByBeginEvent = false; + s.interruptionEventStream.listen((event) async { - switch (event.type) { - case AudioInterruptionType.duck: - await audioPlayer.setVolume(event.begin ? 0.5 : 1.0); - break; - case AudioInterruptionType.pause: - case AudioInterruptionType.unknown: - await audioPlayer.pause(); - break; + if (event.begin) { + switch (event.type) { + case AudioInterruptionType.duck: + await audioPlayer.setVolume(0.5); + break; + case AudioInterruptionType.pause: + case AudioInterruptionType.unknown: + { + wasPausedByBeginEvent = audioPlayer.isPlaying; + await audioPlayer.pause(); + break; + } + } + } else { + switch (event.type) { + case AudioInterruptionType.duck: + await audioPlayer.setVolume(1.0); + break; + case AudioInterruptionType.pause when wasPausedByBeginEvent: + case AudioInterruptionType.unknown when wasPausedByBeginEvent: + await audioPlayer.resume(); + wasPausedByBeginEvent = false; + break; + default: + break; + } } }); + + s.becomingNoisyEventStream.listen((_) { + audioPlayer.pause(); + }); }); audioPlayer.playerStateStream.listen((state) async { playbackState.add(await _transformEvent()); diff --git a/lib/services/audio_services/windows_audio_service.dart b/lib/services/audio_services/windows_audio_service.dart index 0cd8f9bb..fde88145 100644 --- a/lib/services/audio_services/windows_audio_service.dart +++ b/lib/services/audio_services/windows_audio_service.dart @@ -1,8 +1,7 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:smtc_windows/smtc_windows.dart' - if (dart.library.html) 'package:spotube/services/audio_services/smtc_windows_web.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/services/audio_player/audio_player.dart'; @@ -52,11 +51,9 @@ class WindowsAudioService { break; case AudioPlaybackState.stopped: await smtc.setPlaybackStatus(PlaybackStatus.Stopped); - await smtc.disableSmtc(); break; case AudioPlaybackState.completed: await smtc.setPlaybackStatus(PlaybackStatus.Changing); - await smtc.disableSmtc(); break; default: break; diff --git a/lib/services/cli/cli.dart b/lib/services/cli/cli.dart new file mode 100644 index 00000000..61af710e --- /dev/null +++ b/lib/services/cli/cli.dart @@ -0,0 +1,46 @@ +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(); + + parser.addFlag( + 'verbose', + abbr: 'v', + help: 'Verbose mode', + defaultsTo: !kReleaseMode, + callback: (verbose) { + if (verbose) { + logEnv['VERBOSE'] = 'true'; + logEnv['DEBUG'] = 'true'; + logEnv['ERROR'] = 'true'; + } + }, + ); + parser.addFlag( + "version", + help: "Print version and exit", + negatable: false, + ); + + parser.addFlag("help", abbr: "h", negatable: false); + + final arguments = parser.parse(args); + + if (arguments["help"] == true) { + print(parser.usage); + exit(0); + } + + if (arguments["version"] == true) { + final package = await PackageInfo.fromPlatform(); + print("Spotube v${package.version}"); + exit(0); + } + + return arguments; +} diff --git a/lib/services/connectivity_adapter.dart b/lib/services/connectivity_adapter.dart new file mode 100644 index 00000000..c628f2f7 --- /dev/null +++ b/lib/services/connectivity_adapter.dart @@ -0,0 +1,114 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:fl_query/fl_query.dart'; +import 'package:flutter/widgets.dart'; + +class FlQueryInternetConnectionCheckerAdapter extends ConnectivityAdapter + with WidgetsBindingObserver { + final _connectionStreamController = StreamController.broadcast(); + final Dio dio; + + FlQueryInternetConnectionCheckerAdapter() + : dio = Dio(), + super() { + 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; + } + }); + } + + @override + didChangeAppLifecycleState(AppLifecycleState state) async { + if (state == AppLifecycleState.resumed) { + await isConnected; + } + } + + final vpnNames = [ + 'tun', + 'tap', + 'ppp', + 'pptp', + 'l2tp', + 'ipsec', + 'vpn', + 'wireguard', + 'openvpn', + 'softether', + 'proton', + 'strongswan', + 'cisco', + 'forticlient', + 'fortinet', + 'hideme', + 'hidemy', + 'hideman', + 'hidester', + 'lightway', + ]; + + Future isVpnActive() async { + final interfaces = await NetworkInterface.list( + includeLoopback: false, + type: InternetAddressType.any, + ); + + if (interfaces.isEmpty) { + return false; + } + + return interfaces.any( + (interface) => + vpnNames.any((name) => interface.name.toLowerCase().contains(name)), + ); + } + + Future doesConnectTo(String address) async { + try { + final result = await InternetAddress.lookup(address); + if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) { + return true; + } + return false; + } on SocketException catch (_) { + try { + final response = await dio.head('https://$address'); + return (response.statusCode ?? 500) <= 400; + } on DioException catch (_) { + return false; + } + } + } + + Future _isConnected() async { + return await doesConnectTo('google.com') || + await doesConnectTo('www.baidu.com') || // for China + await isVpnActive(); // when VPN is active that means we are connected + } + + @override + Future get isConnected async { + final connected = await _isConnected(); + 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 a0c54fb9..e27b701b 100644 --- a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart +++ b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:spotify/spotify.dart'; +import 'package:spotube/models/spotify_friends.dart'; class CustomSpotifyEndpoints { static const _baseUrl = 'https://api.spotify.com/v1'; @@ -43,8 +44,8 @@ class CustomSpotifyEndpoints { String imageStyle = "gradient_overlay", String includeExternal = "audio", String? locale, - String? market, - String? country, + Market? market, + Market? country, }) async { if (accessToken.isEmpty) { throw Exception('[CustomSpotifyEndpoints.getView]: accessToken is empty'); @@ -58,8 +59,8 @@ class CustomSpotifyEndpoints { 'include_external': includeExternal, 'timestamp': DateTime.now().toUtc().toIso8601String(), if (locale != null) 'locale': locale, - if (market != null) 'market': market, - if (country != null) 'country': country, + if (market != null) 'market': market.name, + if (country != null) 'country': country.name, }.entries.map((e) => '${e.key}=${e.value}').join('&'); final res = await _client.get( @@ -124,7 +125,7 @@ class CustomSpotifyEndpoints { Iterable? seedGenres, Iterable? seedTracks, int limit = 20, - String? market, + Market? market, Map? max, Map? min, Map? target, @@ -143,7 +144,7 @@ class CustomSpotifyEndpoints { 'seed_genres': seedGenres, 'seed_tracks': seedTracks }.forEach((key, list) => _addList(parameters, key, list!)); - if (market != null) parameters['market'] = market; + if (market != null) parameters['market'] = market.name; for (var map in [min, max, target]) { _addTunableTrackMap(parameters, map); } @@ -162,4 +163,71 @@ class CustomSpotifyEndpoints { result["tracks"].map((track) => Track.fromJson(track)).toList(), ); } + + Future getFriendActivity() async { + final res = await _client.get( + 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)); + } + + Future artist({required String id}) async { + final pathQuery = "$_baseUrl/artists/$id"; + + final res = await _client.get( + Uri.parse(pathQuery), + headers: { + "content-type": "application/json", + if (accessToken.isNotEmpty) "authorization": "Bearer $accessToken", + "accept": "application/json", + }, + ); + final data = jsonDecode(res.body); + + return Artist.fromJson(_purifyArtistResponse(data)); + } + + Future> relatedArtists({required String id}) async { + final pathQuery = "$_baseUrl/artists/$id/related-artists"; + + final res = await _client.get( + Uri.parse(pathQuery), + headers: { + "content-type": "application/json", + if (accessToken.isNotEmpty) "authorization": "Bearer $accessToken", + "accept": "application/json", + }, + ); + + final data = jsonDecode(res.body); + + return List.castFrom( + data["artists"] + .map((artist) => Artist.fromJson(_purifyArtistResponse(artist))) + .toList(), + ); + } + + Map _purifyArtistResponse(Map data) { + if (data["popularity"] != null) { + data["popularity"] = data["popularity"].toInt(); + } + if (data["followers"]?["total"] != null) { + data["followers"]["total"] = data["followers"]["total"].toInt(); + } + if (data["images"] != null) { + data["images"] = data["images"].map((e) { + e["height"] = e["height"].toInt(); + e["width"] = e["width"].toInt(); + return e; + }).toList(); + } + + return data; + } } diff --git a/lib/services/download_manager/chunked_download.dart b/lib/services/download_manager/chunked_download.dart index 672acfb3..9e5e0a98 100644 --- a/lib/services/download_manager/chunked_download.dart +++ b/lib/services/download_manager/chunked_download.dart @@ -2,7 +2,9 @@ import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; +import 'package:spotube/models/logger.dart'; + +final logger = getLogger("ChunkedDownload"); /// Downloading by spiting as file in chunks extension ChunkDownload on Dio { @@ -67,11 +69,11 @@ extension ChunkDownload on Dio { } await raf.close(); - debugPrint("Downloaded file path: ${headFile.path}"); + logger.d("Downloaded file path: ${headFile.path}"); headFile = await headFile.rename(savePath); - debugPrint("Renamed file path: ${headFile.path}"); + 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 cd496e0a..d7a42430 100644 --- a/lib/services/download_manager/download_manager.dart +++ b/lib/services/download_manager/download_manager.dart @@ -1,15 +1,19 @@ import 'dart:async'; import 'dart:collection'; import 'dart:io'; -import 'package:catcher/core/catcher.dart'; +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/utils/primitive_utils.dart'; export './download_request.dart'; export './download_status.dart'; @@ -21,6 +25,7 @@ typedef DownloadStatusEvent = ({ }); class DownloadManager { + final logger = getLogger("DownloadManager"); final Map _cache = {}; final Queue _queue = Queue(); var dio = Dio(); @@ -72,19 +77,30 @@ class DownloadManager { } setStatus(task, DownloadStatus.downloading); - debugPrint("[DownloadManager] $url"); + logger.d("[DownloadManager] $url"); final file = File(savePath.toString()); - partialFilePath = savePath + partialExtension; + + final tmpDirPath = await Directory( + path.join( + (await getTemporaryDirectory()).path, + "spotube-downloads", + ), + ).create(recursive: true); + + partialFilePath = path.join( + tmpDirPath.path, + path.basename(savePath) + partialExtension, + ); partialFile = File(partialFilePath); final fileExist = await file.exists(); final partialFileExist = await partialFile.exists(); if (fileExist) { - debugPrint("[DownloadManager] File Exists"); + logger.d("[DownloadManager] File Exists"); setStatus(task, DownloadStatus.completed); } else if (partialFileExist) { - debugPrint("[DownloadManager] Partial File Exists"); + logger.d("[DownloadManager] Partial File Exists"); final partialFileLength = await partialFile.length(); @@ -108,7 +124,9 @@ class DownloadManager { await ioSink.addStream(partialChunkFile.openRead()); await partialChunkFile.delete(); await ioSink.close(); - await partialFile.rename(savePath); + + await partialFile.copy(savePath); + await partialFile.delete(); setStatus(task, DownloadStatus.completed); } @@ -122,12 +140,13 @@ class DownloadManager { ); if (response.statusCode == HttpStatus.ok) { - await partialFile.rename(savePath); + await partialFile.copy(savePath); + await partialFile.delete(); setStatus(task, DownloadStatus.completed); } } } catch (e, stackTrace) { - Catcher.reportCheckedError(e, stackTrace); + Catcher2.reportCheckedError(e, stackTrace); var task = getDownload(url)!; if (task.status.value != DownloadStatus.canceled && @@ -204,7 +223,7 @@ class DownloadManager { } Future pauseDownload(String url) async { - debugPrint("[DownloadManager] Pause Download"); + logger.d("[DownloadManager] Pause Download"); var task = getDownload(url)!; setStatus(task, DownloadStatus.paused); task.request.cancelToken.cancel(); @@ -213,7 +232,7 @@ class DownloadManager { } Future cancelDownload(String url) async { - debugPrint("[DownloadManager] Cancel Download"); + logger.d("[DownloadManager] Cancel Download"); var task = getDownload(url)!; setStatus(task, DownloadStatus.canceled); _queue.remove(task.request); @@ -221,7 +240,7 @@ class DownloadManager { } Future resumeDownload(String url) async { - debugPrint("[DownloadManager] Resume Download"); + logger.d("[DownloadManager] Resume Download"); var task = getDownload(url)!; setStatus(task, DownloadStatus.downloading); task.request.cancelToken = CancelToken(); @@ -387,7 +406,7 @@ class DownloadManager { while (_queue.isNotEmpty && runningTasks < maxConcurrentTasks) { runningTasks++; - debugPrint('Concurrent workers: $runningTasks'); + logger.d('Concurrent workers: $runningTasks'); var currentRequest = _queue.removeFirst(); await download( @@ -402,6 +421,6 @@ class DownloadManager { /// This function is used for get file name with extension from url String getFileNameFromUrl(String url) { - return url.split('/').last; + return PrimitiveUtils.toSafeFileName(url.split('/').last); } } diff --git a/lib/services/mutations/album.dart b/lib/services/mutations/album.dart index 920e11c2..144b6a8f 100644 --- a/lib/services/mutations/album.dart +++ b/lib/services/mutations/album.dart @@ -1,6 +1,6 @@ import 'package:fl_query/fl_query.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/hooks/use_spotify_mutation.dart'; +import 'package:spotube/hooks/spotify/use_spotify_mutation.dart'; class AlbumMutations { const AlbumMutations(); @@ -9,6 +9,8 @@ class AlbumMutations { WidgetRef ref, String albumId, { List? refreshQueries, + List? refreshInfiniteQueries, + MutationOnDataFn? onData, }) { return useSpotifyMutation( "toggle-album-like/$albumId", @@ -22,6 +24,8 @@ class AlbumMutations { }, ref: ref, refreshQueries: refreshQueries, + refreshInfiniteQueries: refreshInfiniteQueries, + onData: onData, ); } } diff --git a/lib/services/mutations/playlist.dart b/lib/services/mutations/playlist.dart index ee06ad9d..f480c565 100644 --- a/lib/services/mutations/playlist.dart +++ b/lib/services/mutations/playlist.dart @@ -1,7 +1,17 @@ import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; +import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/hooks/use_spotify_mutation.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(); @@ -11,8 +21,8 @@ class PlaylistMutations { String playlistId, { List? refreshQueries, List? refreshInfiniteQueries, + ValueChanged? onData, }) { - final queryClient = useQueryClient(); return useSpotifyMutation( "toggle-playlist-like/$playlistId", (isLiked, spotify) async { @@ -25,10 +35,12 @@ class PlaylistMutations { }, ref: ref, refreshQueries: refreshQueries, - refreshInfiniteQueries: refreshInfiniteQueries, - onData: (data, recoveryData) async { - await queryClient - .refreshInfiniteQueryAllPages("current-user-playlists"); + refreshInfiniteQueries: [ + ...?refreshInfiniteQueries, + "current-user-playlists", + ], + onData: (data, recoveryData) { + onData?.call(data); }, ); } @@ -47,4 +59,89 @@ class PlaylistMutations { 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 index 2245c497..f8208b5e 100644 --- a/lib/services/mutations/track.dart +++ b/lib/services/mutations/track.dart @@ -1,6 +1,6 @@ import 'package:fl_query/fl_query.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/hooks/use_spotify_mutation.dart'; +import 'package:spotube/hooks/spotify/use_spotify_mutation.dart'; class TrackMutations { const TrackMutations(); diff --git a/lib/services/queries/album.dart b/lib/services/queries/album.dart index a0c968eb..0cc10256 100644 --- a/lib/services/queries/album.dart +++ b/lib/services/queries/album.dart @@ -1,37 +1,71 @@ -import 'package:catcher/catcher.dart'; +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/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/use_spotify_query.dart'; -import 'package:spotube/provider/user_preferences_provider.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(); - Query, dynamic> ofMine(WidgetRef ref) { - return useSpotifyQuery, dynamic>( + InfiniteQuery, dynamic, int> ofMine(WidgetRef ref) { + return useSpotifyInfiniteQuery, dynamic, int>( "current-user-albums", - (spotify) { - return spotify.me.savedAlbums().all(); + (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, ); } - Query, dynamic> tracksOf( + 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, - String albumId, + AlbumSimple album, ) { - return useSpotifyQuery, dynamic>( - "album-tracks/$albumId", - (spotify) { - return spotify.albums - .getTracks(albumId) - .all() - .then((value) => value.toList()); - }, - ref: ref, + final spotify = ref.watch(spotifyProvider); + + return useInfiniteQueryJob( + job: tracksOfJob(album.id!), + args: (spotify: spotify, album: album), ); } @@ -58,12 +92,12 @@ class AlbumQueries { (pageParam, spotify) async { try { final albums = await spotify.browse - .getNewReleases(country: market) + .newReleases(country: market) .getPage(50, pageParam); return albums; } catch (e, stack) { - Catcher.reportCheckedError(e, stack); + Catcher2.reportCheckedError(e, stack); rethrow; } }, diff --git a/lib/services/queries/artist.dart b/lib/services/queries/artist.dart index 27a58572..5ccc4955 100644 --- a/lib/services/queries/artist.dart +++ b/lib/services/queries/artist.dart @@ -1,8 +1,13 @@ 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/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/use_spotify_query.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'; +import 'package:spotube/services/wikipedia/wikipedia.dart'; +import 'package:wikipedia_api/wikipedia_api.dart'; class ArtistQueries { const ArtistQueries(); @@ -11,9 +16,10 @@ class ArtistQueries { WidgetRef ref, String artist, ) { + final customSpotify = ref.watch(customSpotifyEndpointProvider); return useSpotifyQuery( "artist-profile/$artist", - (spotify) => spotify.artists.get(artist), + (spotify) => customSpotify.artist(id: artist), ref: ref, ); } @@ -51,11 +57,12 @@ class ArtistQueries { return page.items?.toList() ?? []; } + following.addAll(page.items ?? []); while (page?.isLast != true) { - following.addAll(page?.items ?? []); page = await spotify.me .following(FollowingType.artist) .getPage(50, page?.after ?? ''); + following.addAll(page.items ?? []); } return following; @@ -71,11 +78,11 @@ class ArtistQueries { return useSpotifyQuery( "user-follows-artists-query/$artist", (spotify) async { - final result = await spotify.me.isFollowing( + final result = await spotify.me.checkFollowing( FollowingType.artist, [artist], ); - return result.first; + return result[artist]; }, ref: ref, ); @@ -85,10 +92,12 @@ class ArtistQueries { WidgetRef ref, String artist, ) { + final preferences = ref.watch(userPreferencesProvider); return useSpotifyQuery, dynamic>( "artist-top-track-query/$artist", (spotify) { - return spotify.artists.getTopTracks(artist, "US"); + return spotify.artists + .topTracks(artist, preferences.recommendationMarket); }, ref: ref, ); @@ -118,12 +127,28 @@ class ArtistQueries { WidgetRef ref, String artist, ) { + final customSpotify = ref.watch(customSpotifyEndpointProvider); return useSpotifyQuery, dynamic>( "artist-related-artist-query/$artist", (spotify) { - return spotify.artists.getRelatedArtists(artist); + return customSpotify.relatedArtists(id: 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 index 91513fd7..d520b909 100644 --- a/lib/services/queries/category.dart +++ b/lib/services/queries/category.dart @@ -4,16 +4,41 @@ 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/use_spotify_infinite_query.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_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, - String recommendationMarket, + Market recommendationMarket, ) { ref.watch(userPreferencesProvider.select((s) => s.locale)); final locale = useContext().l10n.localeName; @@ -53,7 +78,7 @@ class CategoryQueries { (pageParam, spotify) async { final playlists = await Pages( spotify, - "v1/browse/categories/$category/playlists?country=$market&locale=$locale", + "v1/browse/categories/$category/playlists?country=${market.name}&locale=$locale", (json) => json == null ? null : PlaylistSimple.fromJson(json), 'playlists', (json) => PlaylistsFeatured.fromJson(json), diff --git a/lib/services/queries/lyrics.dart b/lib/services/queries/lyrics.dart index 989a2e97..618f960f 100644 --- a/lib/services/queries/lyrics.dart +++ b/lib/services/queries/lyrics.dart @@ -6,9 +6,9 @@ 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/use_spotify_query.dart'; +import 'package:spotube/hooks/spotify/use_spotify_query.dart'; import 'package:spotube/models/lyrics.dart'; -import 'package:spotube/models/spotube_track.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:http/http.dart' as http; @@ -44,7 +44,7 @@ class LyricsQueries { return useQuery( "synced-lyrics/${track?.id}}", () async { - if (track == null || track is! SpotubeTrack) { + if (track == null || track is! SourcedTrack) { throw "No track currently"; } final timedLyrics = await ServiceUtils.getTimedLyrics(track); @@ -63,8 +63,8 @@ class LyricsQueries { /// Special thanks to [raptag](https://github.com/raptag) for discovering this /// jem - Query spotifySynced(WidgetRef ref, Track? track) { - return useSpotifyQuery( + Query spotifySynced(WidgetRef ref, Track? track) { + return useSpotifyQuery( "spotify-synced-lyrics/${track?.id}}", (spotify) async { if (track == null) { diff --git a/lib/services/queries/playlist.dart b/lib/services/queries/playlist.dart index 25da6199..836f9d72 100644 --- a/lib/services/queries/playlist.dart +++ b/lib/services/queries/playlist.dart @@ -1,4 +1,4 @@ -import 'package:catcher/catcher.dart'; +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'; @@ -7,11 +7,11 @@ 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/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/use_spotify_query.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_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; typedef RecommendationParameters = ({ RecommendationAttribute acousticness, @@ -119,8 +119,9 @@ class PlaylistQueries { return useSpotifyQuery( "playlist-is-followed/$playlistId/$userId", (spotify) async { - final result = await spotify.playlists.followedBy(playlistId, [userId]); - return result.first; + final result = + await spotify.playlists.followedByUsers(playlistId, [userId]); + return result[userId] ?? false; }, ref: ref, ); @@ -142,36 +143,103 @@ class PlaylistQueries { ); } - Future> tracksOf(String playlistId, SpotifyApi spotify) { - if (playlistId == "user-liked-tracks") { - return spotify.tracks.me.saved.all().then( - (tracks) => tracks.map((e) => e.track!).toList(), - ); - } - return spotify.playlists.getTracksByPlaylistId(playlistId).all().then( - (value) => value.toList(), - ); + 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, + ); } - Query, dynamic> tracksOfQuery( + 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 useSpotifyQuery, dynamic>( + return useSpotifyInfiniteQuery, dynamic, int>( "playlist-tracks/$playlistId", - (spotify) => tracksOf(playlistId, spotify), - jsonConfig: playlistId == "user-liked-tracks" - ? 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(), - ) - : null, + (page, spotify) => tracksOf(page, spotify, playlistId), + initialPage: 0, + nextPage: tracksOfQueryNextPage, ref: ref, ); } @@ -187,7 +255,7 @@ class PlaylistQueries { await spotify.playlists.featured.getPage(5, pageParam); return playlists; } catch (e, stack) { - Catcher.reportCheckedError(e, stack); + Catcher2.reportCheckedError(e, stack); rethrow; } }, @@ -207,7 +275,7 @@ class PlaylistQueries { ({List tracks, List artists, List genres})? seeds, RecommendationParameters? parameters, int limit = 20, - String? market, + Market? market, }) { final marketOfPreference = ref.watch( userPreferencesProvider.select((s) => s.recommendationMarket), diff --git a/lib/services/queries/queries.dart b/lib/services/queries/queries.dart index cc3ce132..30c23268 100644 --- a/lib/services/queries/queries.dart +++ b/lib/services/queries/queries.dart @@ -4,6 +4,7 @@ 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'; @@ -17,6 +18,7 @@ class Queries { 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 index 7eb3e139..3c6ee064 100644 --- a/lib/services/queries/search.dart +++ b/lib/services/queries/search.dart @@ -1,36 +1,60 @@ 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/hooks/use_spotify_infinite_query.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 query, + String queryStr, SearchType searchType, ) { - return useSpotifyInfiniteQuery, dynamic, int>( - "search-query/${searchType.key}", - (page, spotify) { - if (query.trim().isEmpty) return []; - final queryString = query; - return spotify.search.get( - queryString, - types: [searchType], - ).getPage(10, page); - }, - enabled: false, - ref: ref, - 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; - }, + 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 new file mode 100644 index 00000000..52bab984 --- /dev/null +++ b/lib/services/queries/tracks.dart @@ -0,0 +1,16 @@ +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 index 63d58afd..82af600f 100644 --- a/lib/services/queries/user.dart +++ b/lib/services/queries/user.dart @@ -1,13 +1,18 @@ 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/use_spotify_query.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 { @@ -26,6 +31,22 @@ class UserQueries { } 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 index b56f07d9..4864ffe1 100644 --- a/lib/services/queries/views.dart +++ b/lib/services/queries/views.dart @@ -5,7 +5,7 @@ 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_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; class ViewsQueries { const ViewsQueries(); diff --git a/lib/services/sourced_track/enums.dart b/lib/services/sourced_track/enums.dart new file mode 100644 index 00000000..e47ee6bd --- /dev/null +++ b/lib/services/sourced_track/enums.dart @@ -0,0 +1,18 @@ +import 'package:spotube/services/sourced_track/models/source_info.dart'; +import 'package:spotube/services/sourced_track/models/source_map.dart'; + +enum SourceCodecs { + m4a._("M4a (Best for downloaded music)"), + weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"); + + final String label; + const SourceCodecs._(this.label); +} + +enum SourceQualities { + high, + medium, + low, +} + +typedef SiblingType = ({T info, SourceMap? source}); diff --git a/lib/services/sourced_track/exceptions.dart b/lib/services/sourced_track/exceptions.dart new file mode 100644 index 00000000..517d6ba4 --- /dev/null +++ b/lib/services/sourced_track/exceptions.dart @@ -0,0 +1,7 @@ +import 'package:spotify/spotify.dart'; + +class TrackNotFoundException implements Exception { + factory TrackNotFoundException(Track track) { + throw Exception("Failed to find any results for ${track.name}"); + } +} diff --git a/lib/services/sourced_track/models/source_info.dart b/lib/services/sourced_track/models/source_info.dart new file mode 100644 index 00000000..4ba90355 --- /dev/null +++ b/lib/services/sourced_track/models/source_info.dart @@ -0,0 +1,33 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'source_info.g.dart'; + +@JsonSerializable() +class SourceInfo { + final String id; + final String title; + final String artist; + final String artistUrl; + final String? album; + + final String thumbnail; + final String pageUrl; + + final Duration duration; + + SourceInfo({ + required this.id, + required this.title, + required this.artist, + required this.thumbnail, + required this.pageUrl, + required this.duration, + required this.artistUrl, + this.album, + }); + + factory SourceInfo.fromJson(Map json) => + _$SourceInfoFromJson(json); + + Map toJson() => _$SourceInfoToJson(this); +} diff --git a/lib/services/sourced_track/models/source_info.g.dart b/lib/services/sourced_track/models/source_info.g.dart new file mode 100644 index 00000000..1ec9f75f --- /dev/null +++ b/lib/services/sourced_track/models/source_info.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'source_info.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SourceInfo _$SourceInfoFromJson(Map json) => SourceInfo( + id: json['id'] as String, + title: json['title'] as String, + artist: json['artist'] as String, + thumbnail: json['thumbnail'] as String, + pageUrl: json['pageUrl'] as String, + duration: Duration(microseconds: json['duration'] as int), + artistUrl: json['artistUrl'] as String, + album: json['album'] as String?, + ); + +Map _$SourceInfoToJson(SourceInfo instance) => + { + 'id': instance.id, + 'title': instance.title, + 'artist': instance.artist, + 'artistUrl': instance.artistUrl, + 'album': instance.album, + 'thumbnail': instance.thumbnail, + 'pageUrl': instance.pageUrl, + 'duration': instance.duration.inMicroseconds, + }; diff --git a/lib/services/sourced_track/models/source_map.dart b/lib/services/sourced_track/models/source_map.dart new file mode 100644 index 00000000..f99f95e4 --- /dev/null +++ b/lib/services/sourced_track/models/source_map.dart @@ -0,0 +1,58 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; + +part 'source_map.g.dart'; + +@JsonSerializable() +class SourceQualityMap { + final String high; + final String medium; + final String low; + + const SourceQualityMap({ + required this.high, + required this.medium, + required this.low, + }); + + factory SourceQualityMap.fromJson(Map json) => + _$SourceQualityMapFromJson(json); + + Map toJson() => _$SourceQualityMapToJson(this); + + operator [](SourceQualities key) { + switch (key) { + case SourceQualities.high: + return high; + case SourceQualities.medium: + return medium; + case SourceQualities.low: + return low; + } + } +} + +@JsonSerializable() +class SourceMap { + final SourceQualityMap? weba; + final SourceQualityMap? m4a; + + const SourceMap({ + this.weba, + this.m4a, + }); + + factory SourceMap.fromJson(Map json) => + _$SourceMapFromJson(json); + + Map toJson() => _$SourceMapToJson(this); + + operator [](SourceCodecs key) { + switch (key) { + case SourceCodecs.weba: + return weba; + case SourceCodecs.m4a: + return m4a; + } + } +} diff --git a/lib/services/sourced_track/models/source_map.g.dart b/lib/services/sourced_track/models/source_map.g.dart new file mode 100644 index 00000000..e1085aa8 --- /dev/null +++ b/lib/services/sourced_track/models/source_map.g.dart @@ -0,0 +1,35 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'source_map.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SourceQualityMap _$SourceQualityMapFromJson(Map json) => + SourceQualityMap( + high: json['high'] as String, + medium: json['medium'] as String, + low: json['low'] as String, + ); + +Map _$SourceQualityMapToJson(SourceQualityMap instance) => + { + 'high': instance.high, + 'medium': instance.medium, + 'low': instance.low, + }; + +SourceMap _$SourceMapFromJson(Map json) => SourceMap( + weba: json['weba'] == null + ? null + : SourceQualityMap.fromJson(json['weba'] as Map), + m4a: json['m4a'] == null + ? null + : SourceQualityMap.fromJson(json['m4a'] as Map), + ); + +Map _$SourceMapToJson(SourceMap instance) => { + 'weba': instance.weba, + 'm4a': instance.m4a, + }; diff --git a/lib/services/sourced_track/models/video_info.dart b/lib/services/sourced_track/models/video_info.dart new file mode 100644 index 00000000..031a8943 --- /dev/null +++ b/lib/services/sourced_track/models/video_info.dart @@ -0,0 +1,114 @@ +import 'package:piped_client/piped_client.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; + +class YoutubeVideoInfo { + final SearchMode searchMode; + final String title; + final Duration duration; + final String thumbnailUrl; + final String id; + final int likes; + final int dislikes; + final int views; + final String channelName; + final String channelId; + final DateTime publishedAt; + + YoutubeVideoInfo({ + required this.searchMode, + required this.title, + required this.duration, + required this.thumbnailUrl, + required this.id, + required this.likes, + required this.dislikes, + required this.views, + required this.channelName, + required this.publishedAt, + required this.channelId, + }); + + YoutubeVideoInfo.fromJson(Map json) + : title = json['title'], + searchMode = SearchMode.fromString(json['searchMode']), + duration = Duration(seconds: json['duration']), + thumbnailUrl = json['thumbnailUrl'], + id = json['id'], + likes = json['likes'], + dislikes = json['dislikes'], + views = json['views'], + channelName = json['channelName'], + channelId = json['channelId'], + publishedAt = DateTime.tryParse(json['publishedAt']) ?? DateTime.now(); + + Map toJson() => { + 'title': title, + 'duration': duration.inSeconds, + 'thumbnailUrl': thumbnailUrl, + 'id': id, + 'likes': likes, + 'dislikes': dislikes, + 'views': views, + 'channelName': channelName, + 'channelId': channelId, + 'publishedAt': publishedAt.toIso8601String(), + 'searchMode': searchMode.name, + }; + + factory YoutubeVideoInfo.fromVideo(Video video) { + return YoutubeVideoInfo( + searchMode: SearchMode.youtube, + title: video.title, + duration: video.duration ?? Duration.zero, + thumbnailUrl: video.thumbnails.mediumResUrl, + id: video.id.value, + likes: video.engagement.likeCount ?? 0, + dislikes: video.engagement.dislikeCount ?? 0, + views: video.engagement.viewCount, + channelName: video.author, + channelId: '/c/${video.channelId.value}', + publishedAt: video.uploadDate ?? DateTime(2003, 9, 9), + ); + } + + factory YoutubeVideoInfo.fromSearchItemStream( + PipedSearchItemStream searchItem, + SearchMode searchMode, + ) { + return YoutubeVideoInfo( + searchMode: searchMode, + title: searchItem.title, + duration: searchItem.duration, + thumbnailUrl: searchItem.thumbnail, + id: searchItem.id, + likes: 0, + dislikes: 0, + views: searchItem.views, + channelName: searchItem.uploaderName, + channelId: searchItem.uploaderUrl ?? "", + publishedAt: searchItem.uploadedDate != null + ? DateTime.tryParse(searchItem.uploadedDate!) ?? DateTime(2003, 9, 9) + : DateTime(2003, 9, 9), + ); + } + + factory YoutubeVideoInfo.fromStreamResponse( + PipedStreamResponse stream, SearchMode searchMode) { + return YoutubeVideoInfo( + searchMode: searchMode, + title: stream.title, + duration: stream.duration, + thumbnailUrl: stream.thumbnailUrl, + id: stream.id, + likes: stream.likes, + dislikes: stream.dislikes, + views: stream.views, + channelName: stream.uploader, + publishedAt: stream.uploadedDate != null + ? DateTime.tryParse(stream.uploadedDate!) ?? DateTime(2003, 9, 9) + : DateTime(2003, 9, 9), + channelId: stream.uploaderUrl, + ); + } +} diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart new file mode 100644 index 00000000..3ceafbf7 --- /dev/null +++ b/lib/services/sourced_track/sourced_track.dart @@ -0,0 +1,170 @@ +import 'package:collection/collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package: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/sourced_track/enums.dart'; +import 'package:spotube/services/sourced_track/models/source_info.dart'; +import 'package:spotube/services/sourced_track/models/source_map.dart'; +import 'package:spotube/services/sourced_track/sources/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'; + +abstract class SourcedTrack extends Track { + final SourceMap source; + final List siblings; + final SourceInfo sourceInfo; + final Ref ref; + + SourcedTrack({ + required this.ref, + required this.source, + required this.siblings, + required this.sourceInfo, + required Track track, + }) { + id = track.id; + name = track.name; + artists = track.artists; + album = track.album; + durationMs = track.durationMs; + discNumber = track.discNumber; + explicit = track.explicit; + externalIds = track.externalIds; + href = track.href; + isPlayable = track.isPlayable; + linkedFrom = track.linkedFrom; + popularity = track.popularity; + previewUrl = track.previewUrl; + trackNumber = track.trackNumber; + type = track.type; + uri = track.uri; + } + + static SourcedTrack fromJson( + Map json, { + required Ref ref, + }) { + final preferences = ref.read(userPreferencesProvider); + + final sourceInfo = SourceInfo.fromJson(json); + final source = SourceMap.fromJson(json); + final track = Track.fromJson(json); + final siblings = (json["siblings"] as List) + .map((sibling) => SourceInfo.fromJson(sibling)) + .toList() + .cast(); + + return switch (preferences.audioSource) { + AudioSource.youtube => YoutubeSourcedTrack( + ref: ref, + source: source, + siblings: siblings, + sourceInfo: sourceInfo, + track: track, + ), + AudioSource.piped => PipedSourcedTrack( + ref: ref, + source: source, + siblings: siblings, + sourceInfo: sourceInfo, + track: track, + ), + AudioSource.jiosaavn => JioSaavnSourcedTrack( + ref: ref, + source: source, + siblings: siblings, + sourceInfo: sourceInfo, + track: track, + ), + }; + } + + static String getSearchTerm(Track track) { + final artists = (track.artists ?? []) + .map((ar) => ar.name) + .toList() + .whereNotNull() + .toList(); + + final title = ServiceUtils.getTitle( + track.name!, + artists: artists, + onlyCleanArtist: true, + ).trim(); + + return "$title - ${artists.join(", ")}"; + } + + static Future fetchFromTrack({ + required Track track, + required Ref ref, + }) async { + try { + final preferences = ref.read(userPreferencesProvider); + + return switch (preferences.audioSource) { + AudioSource.piped => + await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref), + AudioSource.youtube => + await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref), + AudioSource.jiosaavn => + await JioSaavnSourcedTrack.fetchFromTrack(track: track, ref: ref), + }; + } catch (e) { + return YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref); + } + } + + static Future> fetchSiblings({ + required Track track, + required Ref ref, + }) { + final preferences = ref.read(userPreferencesProvider); + + return switch (preferences.audioSource) { + AudioSource.piped => + PipedSourcedTrack.fetchSiblings(track: track, ref: ref), + AudioSource.youtube => + YoutubeSourcedTrack.fetchSiblings(track: track, ref: ref), + AudioSource.jiosaavn => + JioSaavnSourcedTrack.fetchSiblings(track: track, ref: ref), + }; + } + + Future copyWithSibling(); + + Future swapWithSibling(SourceInfo sibling); + + Future swapWithSiblingOfIndex(int index) { + return swapWithSibling(siblings[index]); + } + + String get url { + final preferences = ref.read(userPreferencesProvider); + + final codec = preferences.audioSource == AudioSource.jiosaavn + ? SourceCodecs.m4a + : preferences.streamMusicCodec; + + return getUrlOfCodec(codec); + } + + String getUrlOfCodec(SourceCodecs codec) { + final preferences = ref.read(userPreferencesProvider); + + return source[codec]?[preferences.audioQuality] ?? + // this will ensure playback doesn't break + source[codec == SourceCodecs.m4a ? SourceCodecs.weba : SourceCodecs.m4a] + [preferences.audioQuality]; + } + + SourceCodecs get codec { + final preferences = ref.read(userPreferencesProvider); + + return preferences.audioSource == AudioSource.jiosaavn + ? SourceCodecs.m4a + : preferences.streamMusicCodec; + } +} diff --git a/lib/services/sourced_track/sources/jiosaavn.dart b/lib/services/sourced_track/sources/jiosaavn.dart new file mode 100644 index 00000000..7455f4d7 --- /dev/null +++ b/lib/services/sourced_track/sources/jiosaavn.dart @@ -0,0 +1,206 @@ +import 'package:collection/collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/source_match.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; +import 'package:spotube/services/sourced_track/exceptions.dart'; +import 'package:spotube/services/sourced_track/models/source_info.dart'; +import 'package:spotube/services/sourced_track/models/source_map.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:jiosaavn/jiosaavn.dart'; +import 'package:spotube/extensions/string.dart'; + +final jiosaavnClient = JioSaavnClient(); + +class JioSaavnSourceInfo extends SourceInfo { + JioSaavnSourceInfo({ + required super.id, + required super.title, + required super.artist, + required super.thumbnail, + required super.pageUrl, + required super.duration, + required super.artistUrl, + required super.album, + }); +} + +class JioSaavnSourcedTrack extends SourcedTrack { + JioSaavnSourcedTrack({ + required super.ref, + required super.source, + required super.siblings, + required super.sourceInfo, + required super.track, + }); + + static Future fetchFromTrack({ + required Track track, + required Ref ref, + }) async { + final cachedSource = await SourceMatch.box.get(track.id); + + if (cachedSource == null || + cachedSource.sourceType != SourceType.jiosaavn) { + final siblings = await fetchSiblings(ref: ref, track: track); + + if (siblings.isEmpty) { + throw TrackNotFoundException(track); + } + + await SourceMatch.box.put( + track.id!, + SourceMatch( + id: track.id!, + sourceType: SourceType.jiosaavn, + createdAt: DateTime.now(), + sourceId: siblings.first.info.id, + ), + ); + + return JioSaavnSourcedTrack( + ref: ref, + siblings: siblings.map((s) => s.info).skip(1).toList(), + source: siblings.first.source!, + sourceInfo: siblings.first.info, + track: track, + ); + } + + final [item] = + await jiosaavnClient.songs.detailsById([cachedSource.sourceId]); + + final (:info, :source) = toSiblingType(item); + + return JioSaavnSourcedTrack( + ref: ref, + siblings: [], + source: source!, + sourceInfo: info, + track: track, + ); + } + + static SiblingType toSiblingType(SongResponse result) { + final SiblingType sibling = ( + info: JioSaavnSourceInfo( + artist: [ + result.primaryArtists, + if (result.featuredArtists.isNotEmpty) ", ", + result.featuredArtists + ].join("").unescapeHtml(), + artistUrl: + "https://www.jiosaavn.com/artist/${result.primaryArtistsId.split(",").firstOrNull ?? ""}", + duration: Duration(seconds: int.parse(result.duration)), + id: result.id, + pageUrl: result.url, + thumbnail: result.image?.last.link ?? "", + title: result.name!.unescapeHtml(), + album: result.album.name, + ), + source: SourceMap( + m4a: SourceQualityMap( + high: result.downloadUrl! + .firstWhere((element) => element.quality == "320kbps") + .link, + medium: result.downloadUrl! + .firstWhere((element) => element.quality == "160kbps") + .link, + low: result.downloadUrl! + .firstWhere((element) => element.quality == "96kbps") + .link, + ), + ), + ); + + return sibling; + } + + static Future> fetchSiblings({ + required Track track, + required Ref ref, + }) async { + final query = SourcedTrack.getSearchTerm(track); + + final SongSearchResponse(:results) = + await jiosaavnClient.search.songs(query, limit: 20); + + final trackArtistNames = track.artists?.map((ar) => ar.name).toList(); + return results + .where( + (s) { + final sameName = s.name?.unescapeHtml() == track.name; + final artistNames = [ + s.primaryArtists, + if (s.featuredArtists.isNotEmpty) ", ", + s.featuredArtists + ].join("").unescapeHtml(); + final sameArtists = artistNames.split(", ").any( + (artist) => + trackArtistNames?.any((ar) => artist == ar) ?? false, + ); + + return sameName && sameArtists; + }, + ) + .map(toSiblingType) + .toList(); + } + + @override + Future copyWithSibling() async { + if (siblings.isNotEmpty) { + return this; + } + final fetchedSiblings = await fetchSiblings(ref: ref, track: this); + + return JioSaavnSourcedTrack( + ref: ref, + siblings: fetchedSiblings + .where((s) => s.info.id != sourceInfo.id) + .map((s) => s.info) + .toList(), + source: source, + sourceInfo: sourceInfo, + track: this, + ); + } + + @override + Future swapWithSibling(SourceInfo sibling) async { + if (sibling.id == sourceInfo.id) { + return null; + } + + // a sibling source that was fetched from the search results + final isStepSibling = siblings.none((s) => s.id == sibling.id); + + final newSourceInfo = isStepSibling + ? sibling + : siblings.firstWhere((s) => s.id == sibling.id); + final newSiblings = siblings.where((s) => s.id != sibling.id).toList() + ..insert(0, sourceInfo); + + final [item] = await jiosaavnClient.songs.detailsById([newSourceInfo.id]); + + final (:info, :source) = toSiblingType(item); + + await SourceMatch.box.put( + id!, + SourceMatch( + id: id!, + sourceType: SourceType.jiosaavn, + createdAt: DateTime.now(), + sourceId: info.id, + ), + ); + + return JioSaavnSourcedTrack( + ref: ref, + siblings: newSiblings, + source: source!, + sourceInfo: info, + track: this, + ); + } +} diff --git a/lib/services/sourced_track/sources/piped.dart b/lib/services/sourced_track/sources/piped.dart new file mode 100644 index 00000000..8a1ec1bc --- /dev/null +++ b/lib/services/sourced_track/sources/piped.dart @@ -0,0 +1,284 @@ +import 'package:collection/collection.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/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; +import 'package:spotube/services/sourced_track/exceptions.dart'; +import 'package:spotube/services/sourced_track/models/source_info.dart'; +import 'package:spotube/services/sourced_track/models/source_map.dart'; +import 'package:spotube/services/sourced_track/models/video_info.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:spotube/services/sourced_track/sources/youtube.dart'; +import 'package:spotube/utils/service_utils.dart'; + +final pipedProvider = Provider( + (ref) { + final instance = + ref.watch(userPreferencesProvider.select((s) => s.pipedInstance)); + return PipedClient(instance: instance); + }, +); + +class PipedSourceInfo extends SourceInfo { + PipedSourceInfo({ + required super.id, + required super.title, + required super.artist, + required super.thumbnail, + required super.pageUrl, + required super.duration, + required super.artistUrl, + required super.album, + }); +} + +class PipedSourcedTrack extends SourcedTrack { + PipedSourcedTrack({ + required super.ref, + required super.source, + required super.siblings, + required super.sourceInfo, + required super.track, + }); + + static Future fetchFromTrack({ + required Track track, + required Ref ref, + }) async { + final cachedSource = await SourceMatch.box.get(track.id); + final preferences = ref.read(userPreferencesProvider); + final pipedClient = ref.read(pipedProvider); + + if (cachedSource == null) { + final siblings = await fetchSiblings(ref: ref, track: track); + if (siblings.isEmpty) { + throw TrackNotFoundException(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, + ), + ); + + return PipedSourcedTrack( + ref: ref, + siblings: siblings.map((s) => s.info).skip(1).toList(), + source: siblings.first.source as SourceMap, + sourceInfo: siblings.first.info, + track: track, + ); + } else { + final manifest = await pipedClient.streams(cachedSource.sourceId); + + return PipedSourcedTrack( + ref: ref, + siblings: [], + source: toSourceMap(manifest), + sourceInfo: PipedSourceInfo( + id: manifest.id, + artist: manifest.uploader, + artistUrl: manifest.uploaderUrl, + pageUrl: "https://www.youtube.com/watch?v=${manifest.id}", + thumbnail: manifest.thumbnailUrl, + title: manifest.title, + duration: manifest.duration, + album: null, + ), + track: track, + ); + } + } + + static SourceMap toSourceMap(PipedStreamResponse manifest) { + final m4a = manifest.audioStreams + .where((audio) => audio.format == PipedAudioStreamFormat.m4a) + .sorted((a, b) => a.bitrate.compareTo(b.bitrate)); + + final weba = manifest.audioStreams + .where((audio) => audio.format == PipedAudioStreamFormat.webm) + .sorted((a, b) => a.bitrate.compareTo(b.bitrate)); + + return SourceMap( + m4a: SourceQualityMap( + high: m4a.first.url.toString(), + medium: (m4a.elementAtOrNull(m4a.length ~/ 2) ?? m4a[1]).url.toString(), + low: m4a.last.url.toString(), + ), + weba: SourceQualityMap( + high: weba.first.url.toString(), + medium: + (weba.elementAtOrNull(weba.length ~/ 2) ?? weba[1]).url.toString(), + low: weba.last.url.toString(), + ), + ); + } + + static Future toSiblingType( + int index, + YoutubeVideoInfo item, + PipedClient pipedClient, + ) async { + SourceMap? sourceMap; + if (index == 0) { + final manifest = await pipedClient.streams(item.id); + sourceMap = toSourceMap(manifest); + } + + final SiblingType sibling = ( + info: PipedSourceInfo( + id: item.id, + artist: item.channelName, + artistUrl: "https://www.youtube.com/${item.channelId}", + pageUrl: "https://www.youtube.com/watch?v=${item.id}", + thumbnail: item.thumbnailUrl, + title: item.title, + duration: item.duration, + album: null, + ), + source: sourceMap, + ); + + return sibling; + } + + static Future> fetchSiblings({ + required Track track, + required Ref ref, + }) async { + final pipedClient = ref.read(pipedProvider); + final preference = ref.read(userPreferencesProvider); + final query = SourcedTrack.getSearchTerm(track); + + final PipedSearchResult(items: searchResults) = await pipedClient.search( + "$query - Topic", + preference.searchMode == SearchMode.youtube + ? PipedFilter.video + : PipedFilter.musicSongs, + ); + + final isYouTubeMusic = preference.searchMode == SearchMode.youtubeMusic; + + if (isYouTubeMusic) { + final artists = (track.artists ?? []) + .map((ar) => ar.name) + .toList() + .whereNotNull() + .toList(); + + return await Future.wait( + searchResults + .map( + (result) => YoutubeVideoInfo.fromSearchItemStream( + result as PipedSearchItemStream, + preference.searchMode, + ), + ) + .sorted((a, b) => b.views.compareTo(a.views)) + .where( + (item) => artists.any( + (artist) => + artist.toLowerCase() == item.channelName.toLowerCase(), + ), + ) + .mapIndexed((i, r) => toSiblingType(i, r, pipedClient)), + ); + } + + if (ServiceUtils.onlyContainsEnglish(query)) { + return await Future.wait( + searchResults + .whereType() + .map( + (result) => YoutubeVideoInfo.fromSearchItemStream( + result, + preference.searchMode, + ), + ) + .mapIndexed((i, r) => toSiblingType(i, r, pipedClient)), + ); + } + + final rankedSiblings = YoutubeSourcedTrack.rankResults( + searchResults + .map( + (result) => YoutubeVideoInfo.fromSearchItemStream( + result as PipedSearchItemStream, + preference.searchMode, + ), + ) + .toList(), + track, + ); + + return await Future.wait( + rankedSiblings.mapIndexed((i, r) => toSiblingType(i, r, pipedClient)), + ); + } + + @override + Future copyWithSibling() async { + if (siblings.isNotEmpty) { + return this; + } + final fetchedSiblings = await fetchSiblings(ref: ref, track: this); + + return PipedSourcedTrack( + ref: ref, + siblings: fetchedSiblings + .where((s) => s.info.id != sourceInfo.id) + .map((s) => s.info) + .toList(), + source: source, + sourceInfo: sourceInfo, + track: this, + ); + } + + @override + Future swapWithSibling(SourceInfo sibling) async { + if (sibling.id == sourceInfo.id) { + return null; + } + + // a sibling source that was fetched from the search results + final isStepSibling = siblings.none((s) => s.id == sibling.id); + + final newSourceInfo = isStepSibling + ? sibling + : siblings.firstWhere((s) => s.id == sibling.id); + final newSiblings = siblings.where((s) => s.id != sibling.id).toList() + ..insert(0, sourceInfo); + + final pipedClient = ref.read(pipedProvider); + + final manifest = await pipedClient.streams(newSourceInfo.id); + + await SourceMatch.box.put( + id!, + SourceMatch( + id: id!, + sourceType: SourceType.jiosaavn, + createdAt: DateTime.now(), + sourceId: newSourceInfo.id, + ), + ); + + return PipedSourcedTrack( + ref: ref, + siblings: newSiblings, + source: toSourceMap(manifest), + sourceInfo: newSourceInfo, + track: this, + ); + } +} diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart new file mode 100644 index 00000000..f363937c --- /dev/null +++ b/lib/services/sourced_track/sources/youtube.dart @@ -0,0 +1,286 @@ +import 'package:collection/collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/source_match.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; +import 'package:spotube/services/sourced_track/exceptions.dart'; +import 'package:spotube/services/sourced_track/models/source_info.dart'; +import 'package:spotube/services/sourced_track/models/source_map.dart'; +import 'package:spotube/services/sourced_track/models/video_info.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:spotube/utils/service_utils.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; + +final youtubeClient = YoutubeExplode(); +final officialMusicRegex = RegExp( + r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)", + caseSensitive: false, +); + +class YoutubeSourceInfo extends SourceInfo { + YoutubeSourceInfo({ + required super.id, + required super.title, + required super.artist, + required super.thumbnail, + required super.pageUrl, + required super.duration, + required super.artistUrl, + required super.album, + }); +} + +class YoutubeSourcedTrack extends SourcedTrack { + YoutubeSourcedTrack({ + required super.source, + required super.siblings, + required super.sourceInfo, + required super.track, + required super.ref, + }); + + static Future fetchFromTrack({ + required Track track, + required Ref ref, + }) async { + final cachedSource = await SourceMatch.box.get(track.id); + + if (cachedSource == null || cachedSource.sourceType != SourceType.youtube) { + final siblings = await fetchSiblings(ref: ref, track: track); + if (siblings.isEmpty) { + throw TrackNotFoundException(track); + } + + await SourceMatch.box.put( + track.id!, + SourceMatch( + id: track.id!, + sourceType: SourceType.youtube, + createdAt: DateTime.now(), + sourceId: siblings.first.info.id, + ), + ); + + return YoutubeSourcedTrack( + ref: ref, + siblings: siblings.map((s) => s.info).skip(1).toList(), + source: siblings.first.source as SourceMap, + sourceInfo: siblings.first.info, + track: track, + ); + } + final item = await youtubeClient.videos.get(cachedSource.sourceId); + final manifest = await youtubeClient.videos.streamsClient.getManifest( + cachedSource.sourceId, + ); + return YoutubeSourcedTrack( + ref: ref, + siblings: [], + source: toSourceMap(manifest), + sourceInfo: YoutubeSourceInfo( + id: item.id.value, + artist: item.author, + artistUrl: "https://www.youtube.com/channel/${item.channelId}", + pageUrl: item.url, + thumbnail: item.thumbnails.highResUrl, + title: item.title, + duration: item.duration ?? Duration.zero, + album: null, + ), + track: track, + ); + } + + static SourceMap toSourceMap(StreamManifest manifest) { + var m4a = manifest.audioOnly + .where((audio) => audio.codec.mimeType == "audio/mp4") + .sortByBitrate(); + + var weba = manifest.audioOnly + .where((audio) => audio.codec.mimeType == "audio/webm") + .sortByBitrate(); + + m4a = m4a.isEmpty ? weba.toList() : m4a; + weba = weba.isEmpty ? m4a.toList() : weba; + + return SourceMap( + m4a: SourceQualityMap( + high: m4a.first.url.toString(), + medium: (m4a.elementAtOrNull(m4a.length ~/ 2) ?? m4a[1]).url.toString(), + low: m4a.last.url.toString(), + ), + weba: SourceQualityMap( + high: weba.first.url.toString(), + medium: + (weba.elementAtOrNull(weba.length ~/ 2) ?? weba[1]).url.toString(), + low: weba.last.url.toString(), + ), + ); + } + + static Future toSiblingType( + int index, + YoutubeVideoInfo item, + ) async { + SourceMap? sourceMap; + if (index == 0) { + final manifest = + await youtubeClient.videos.streamsClient.getManifest(item.id); + sourceMap = toSourceMap(manifest); + } + + final SiblingType sibling = ( + info: YoutubeSourceInfo( + id: item.id, + artist: item.channelName, + artistUrl: "https://www.youtube.com/channel/${item.channelId}", + pageUrl: "https://www.youtube.com/watch?v=${item.id}", + thumbnail: item.thumbnailUrl, + title: item.title, + duration: item.duration, + album: null, + ), + source: sourceMap, + ); + + return sibling; + } + + static List rankResults( + List results, Track track) { + final artists = (track.artists ?? []) + .map((ar) => ar.name) + .toList() + .whereNotNull() + .toList(); + + return results + .sorted((a, b) => b.views.compareTo(a.views)) + .map((sibling) { + int score = 0; + + for (final artist in artists) { + final isSameChannelArtist = + sibling.channelName.toLowerCase() == artist.toLowerCase(); + final channelContainsArtist = sibling.channelName + .toLowerCase() + .contains(artist.toLowerCase()); + + if (isSameChannelArtist || channelContainsArtist) { + score += 1; + } + + final titleContainsArtist = + sibling.title.toLowerCase().contains(artist.toLowerCase()); + + if (titleContainsArtist) { + score += 1; + } + } + + final titleContainsTrackName = + sibling.title.toLowerCase().contains(track.name!.toLowerCase()); + + final hasOfficialFlag = + officialMusicRegex.hasMatch(sibling.title.toLowerCase()); + + if (titleContainsTrackName) { + score += 3; + } + + if (hasOfficialFlag) { + score += 1; + } + + if (hasOfficialFlag && titleContainsTrackName) { + score += 2; + } + + return (sibling: sibling, score: score); + }) + .sorted((a, b) => b.score.compareTo(a.score)) + .map((e) => e.sibling) + .toList(); + } + + static Future> fetchSiblings({ + required Track track, + required Ref ref, + }) async { + final query = SourcedTrack.getSearchTerm(track); + + final searchResults = await youtubeClient.search.search( + "$query - Topic", + filter: TypeFilters.video, + ); + + if (ServiceUtils.onlyContainsEnglish(query)) { + return await Future.wait(searchResults + .map(YoutubeVideoInfo.fromVideo) + .mapIndexed(toSiblingType)); + } + + final rankedSiblings = rankResults( + searchResults.map(YoutubeVideoInfo.fromVideo).toList(), + track, + ); + + return await Future.wait(rankedSiblings.mapIndexed(toSiblingType)); + } + + @override + Future swapWithSibling(SourceInfo sibling) async { + if (sibling.id == sourceInfo.id) { + return null; + } + + // a sibling source that was fetched from the search results + final isStepSibling = siblings.none((s) => s.id == sibling.id); + + final newSourceInfo = isStepSibling + ? sibling + : siblings.firstWhere((s) => s.id == sibling.id); + final newSiblings = siblings.where((s) => s.id != sibling.id).toList() + ..insert(0, sourceInfo); + + final manifest = + await youtubeClient.videos.streamsClient.getManifest(newSourceInfo.id); + + await SourceMatch.box.put( + id!, + SourceMatch( + id: id!, + sourceType: SourceType.jiosaavn, + createdAt: DateTime.now(), + sourceId: newSourceInfo.id, + ), + ); + + return YoutubeSourcedTrack( + ref: ref, + siblings: newSiblings, + source: toSourceMap(manifest), + sourceInfo: newSourceInfo, + track: this, + ); + } + + @override + Future copyWithSibling() async { + if (siblings.isNotEmpty) { + return this; + } + final fetchedSiblings = await fetchSiblings(ref: ref, track: this); + + return YoutubeSourcedTrack( + ref: ref, + siblings: fetchedSiblings + .where((s) => s.info.id != sourceInfo.id) + .map((s) => s.info) + .toList(), + source: source, + sourceInfo: sourceInfo, + track: this, + ); + } +} diff --git a/lib/services/supabase.dart b/lib/services/supabase.dart deleted file mode 100644 index 93b94380..00000000 --- a/lib/services/supabase.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:spotube/collections/env.dart'; -import 'package:spotube/models/matched_track.dart'; -import 'package:supabase/supabase.dart'; - -class SupabaseService { - static final api = SupabaseClient( - Env.supabaseUrl, - Env.supabaseAnonKey, - ); - - Future insertTrack(MatchedTrack track) async { - await api.from("tracks").insert(track.toJson()); - } -} - -final supabase = SupabaseService(); diff --git a/lib/services/wikipedia/wikipedia.dart b/lib/services/wikipedia/wikipedia.dart new file mode 100644 index 00000000..b571f30f --- /dev/null +++ b/lib/services/wikipedia/wikipedia.dart @@ -0,0 +1,3 @@ +import 'package:wikipedia_api/wikipedia_api.dart'; + +final wikipedia = WikipediaApi(); diff --git a/lib/services/youtube/youtube.dart b/lib/services/youtube/youtube.dart deleted file mode 100644 index fbf559d4..00000000 --- a/lib/services/youtube/youtube.dart +++ /dev/null @@ -1,238 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:piped_client/piped_client.dart'; -import 'package:spotube/collections/routes.dart'; -import 'package:spotube/components/shared/dialogs/piped_down_dialog.dart'; -import 'package:spotube/models/matched_track.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/utils/primitive_utils.dart'; -import 'package:youtube_explode_dart/youtube_explode_dart.dart'; - -class YoutubeVideoInfo { - final SearchMode searchMode; - final String title; - final Duration duration; - final String thumbnailUrl; - final String id; - final int likes; - final int dislikes; - final int views; - final String channelName; - final String channelId; - final DateTime publishedAt; - - YoutubeVideoInfo({ - required this.searchMode, - required this.title, - required this.duration, - required this.thumbnailUrl, - required this.id, - required this.likes, - required this.dislikes, - required this.views, - required this.channelName, - required this.publishedAt, - required this.channelId, - }); - - YoutubeVideoInfo.fromJson(Map json) - : title = json['title'], - searchMode = SearchMode.fromString(json['searchMode']), - duration = Duration(seconds: json['duration']), - thumbnailUrl = json['thumbnailUrl'], - id = json['id'], - likes = json['likes'], - dislikes = json['dislikes'], - views = json['views'], - channelName = json['channelName'], - channelId = json['channelId'], - publishedAt = DateTime.tryParse(json['publishedAt']) ?? DateTime.now(); - - Map toJson() => { - 'title': title, - 'duration': duration.inSeconds, - 'thumbnailUrl': thumbnailUrl, - 'id': id, - 'likes': likes, - 'dislikes': dislikes, - 'views': views, - 'channelName': channelName, - 'channelId': channelId, - 'publishedAt': publishedAt.toIso8601String(), - 'searchMode': searchMode.name, - }; - - factory YoutubeVideoInfo.fromVideo(Video video) { - return YoutubeVideoInfo( - searchMode: SearchMode.youtube, - title: video.title, - duration: video.duration ?? Duration.zero, - thumbnailUrl: video.thumbnails.mediumResUrl, - id: video.id.value, - likes: video.engagement.likeCount ?? 0, - dislikes: video.engagement.dislikeCount ?? 0, - views: video.engagement.viewCount, - channelName: video.author, - channelId: '/c/${video.channelId.value}', - publishedAt: video.uploadDate ?? DateTime(2003, 9, 9), - ); - } - - factory YoutubeVideoInfo.fromSearchItemStream( - PipedSearchItemStream searchItem, - SearchMode searchMode, - ) { - return YoutubeVideoInfo( - searchMode: searchMode, - title: searchItem.title, - duration: searchItem.duration, - thumbnailUrl: searchItem.thumbnail, - id: searchItem.id, - likes: 0, - dislikes: 0, - views: searchItem.views, - channelName: searchItem.uploaderName, - channelId: searchItem.uploaderUrl ?? "", - publishedAt: searchItem.uploadedDate != null - ? DateTime.tryParse(searchItem.uploadedDate!) ?? DateTime(2003, 9, 9) - : DateTime(2003, 9, 9), - ); - } - - factory YoutubeVideoInfo.fromStreamResponse( - PipedStreamResponse stream, SearchMode searchMode) { - return YoutubeVideoInfo( - searchMode: searchMode, - title: stream.title, - duration: stream.duration, - thumbnailUrl: stream.thumbnailUrl, - id: stream.id, - likes: stream.likes, - dislikes: stream.dislikes, - views: stream.views, - channelName: stream.uploader, - publishedAt: stream.uploadedDate != null - ? DateTime.tryParse(stream.uploadedDate!) ?? DateTime(2003, 9, 9) - : DateTime(2003, 9, 9), - channelId: stream.uploaderUrl, - ); - } -} - -class YoutubeEndpoints { - PipedClient? piped; - YoutubeExplode? youtube; - - final UserPreferences preferences; - - YoutubeEndpoints(this.preferences) { - switch (preferences.youtubeApiType) { - case YoutubeApiType.youtube: - youtube = YoutubeExplode(); - break; - case YoutubeApiType.piped: - piped = PipedClient(instance: preferences.pipedInstance); - break; - } - } - - Future showPipedErrorDialog(Exception e) async { - if (e is DioException && (e.response?.statusCode ?? 0) >= 500) { - final context = rootNavigatorKey?.currentContext; - if (context != null) { - await showDialog( - context: context, - builder: (context) => const PipedDownDialog(), - ); - } - } - } - - Future> search(String query) async { - if (youtube != null) { - final res = await youtube!.search( - query, - filter: TypeFilters.video, - ); - - return res.map(YoutubeVideoInfo.fromVideo).toList(); - } else { - try { - final res = await piped!.search( - query, - switch (preferences.searchMode) { - SearchMode.youtube => PipedFilter.video, - SearchMode.youtubeMusic => PipedFilter.musicSongs, - }, - ); - return res.items - .whereType() - .map( - (e) => YoutubeVideoInfo.fromSearchItemStream( - e, - preferences.searchMode, - ), - ) - .toList(); - } on Exception catch (e) { - await showPipedErrorDialog(e); - rethrow; - } - } - } - - String _pipedStreamResponseToStreamUrl(PipedStreamResponse stream) { - return switch (preferences.audioQuality) { - AudioQuality.high => stream - .highestBitrateAudioStreamOfFormat(PipedAudioStreamFormat.m4a)! - .url, - AudioQuality.low => stream - .lowestBitrateAudioStreamOfFormat(PipedAudioStreamFormat.m4a)! - .url, - }; - } - - Future streamingUrl(String id) async { - if (youtube != null) { - final res = await PrimitiveUtils.raceMultiple( - () => youtube!.videos.streams.getManifest(id), - ); - final audioOnlyManifests = res.audioOnly.where((info) { - return info.codec.mimeType == "audio/mp4"; - }); - - return switch (preferences.audioQuality) { - AudioQuality.high => - audioOnlyManifests.withHighestBitrate().url.toString(), - AudioQuality.low => - audioOnlyManifests.sortByBitrate().last.url.toString(), - }; - } else { - return _pipedStreamResponseToStreamUrl(await piped!.streams(id)); - } - } - - Future<(YoutubeVideoInfo info, String streamingUrl)> video( - String id, - SearchMode searchMode, - ) async { - if (youtube != null) { - final res = await youtube!.videos.get(id); - return ( - YoutubeVideoInfo.fromVideo(res), - await streamingUrl(id), - ); - } else { - try { - final res = await piped!.streams(id); - return ( - YoutubeVideoInfo.fromStreamResponse(res, searchMode), - _pipedStreamResponseToStreamUrl(res), - ); - } on Exception catch (e) { - await showPipedErrorDialog(e); - rethrow; - } - } - } -} diff --git a/lib/themes/theme.dart b/lib/themes/theme.dart index 7f107d56..51e98269 100644 --- a/lib/themes/theme.dart +++ b/lib/themes/theme.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; -ThemeData theme(Color seed, Brightness brightness) { +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, ); return ThemeData( @@ -50,7 +52,9 @@ ThemeData theme(Color seed, Brightness brightness) { ), sliderTheme: SliderThemeData(overlayShape: SliderComponentShape.noOverlay), searchBarTheme: SearchBarThemeData( + textStyle: const MaterialStatePropertyAll(TextStyle(fontSize: 15)), constraints: const BoxConstraints(maxWidth: double.infinity), + padding: const MaterialStatePropertyAll(EdgeInsets.all(8)), backgroundColor: MaterialStatePropertyAll( Color.lerp( scheme.surfaceVariant, @@ -65,5 +69,11 @@ ThemeData theme(Color seed, Brightness brightness) { ), ), ), + scrollbarTheme: const ScrollbarThemeData( + thickness: MaterialStatePropertyAll(14), + ), + checkboxTheme: CheckboxThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + ), ); } diff --git a/lib/utils/duration.dart b/lib/utils/duration.dart index 858503fb..35678a96 100644 --- a/lib/utils/duration.dart +++ b/lib/utils/duration.dart @@ -1,4 +1,4 @@ -import 'package:catcher/catcher.dart'; +import 'package:catcher_2/catcher_2.dart'; /// Parses duration string formatted by Duration.toString() to [Duration]. /// The string should be of form hours:minutes:seconds.microseconds @@ -53,7 +53,7 @@ Duration? tryParseDuration(String input) { try { return parseDuration(input); } catch (e, stack) { - Catcher.reportCheckedError(e, stack); + Catcher2.reportCheckedError(e, stack); return null; } } diff --git a/lib/utils/persisted_state_notifier.dart b/lib/utils/persisted_state_notifier.dart index 2937bff9..218cd64a 100644 --- a/lib/utils/persisted_state_notifier.dart +++ b/lib/utils/persisted_state_notifier.dart @@ -59,32 +59,32 @@ abstract class PersistedStateNotifier extends StateNotifier { static Future read(String key) async { final localStorage = await SharedPreferences.getInstance(); - if (kIsMacOS || kIsIOS) { + if (kIsMacOS || kIsIOS || (kIsLinux && !kIsFlatpak)) { + return localStorage.getString(key); + } + + try { + await localStorage.setBool(kIsUsingEncryption, true); + return await secureStorage.read(key: key); + } catch (e) { + await localStorage.setBool(kIsUsingEncryption, false); return localStorage.getString(key); - } else { - try { - await localStorage.setBool(kIsUsingEncryption, true); - return await secureStorage.read(key: key); - } catch (e) { - await localStorage.setBool(kIsUsingEncryption, false); - return localStorage.getString(key); - } } } static Future write(String key, String value) async { final localStorage = await SharedPreferences.getInstance(); - if (kIsMacOS || kIsIOS) { + if (kIsMacOS || kIsIOS || (kIsLinux && !kIsFlatpak)) { await localStorage.setString(key, value); return; - } else { - try { - await localStorage.setBool(kIsUsingEncryption, true); - await secureStorage.write(key: key, value: value); - } catch (e) { - await localStorage.setBool(kIsUsingEncryption, false); - await localStorage.setString(key, value); - } + } + + try { + await localStorage.setBool(kIsUsingEncryption, true); + await secureStorage.write(key: key, value: value); + } catch (e) { + await localStorage.setBool(kIsUsingEncryption, false); + await localStorage.setString(key, value); } } diff --git a/lib/utils/primitive_utils.dart b/lib/utils/primitive_utils.dart index 2298f14f..801c2e5a 100644 --- a/lib/utils/primitive_utils.dart +++ b/lib/utils/primitive_utils.dart @@ -31,17 +31,6 @@ abstract class PrimitiveUtils { } } - static String zeroPadNumStr(int input) { - return input < 10 ? "0$input" : input.toString(); - } - - static String toReadableDuration(Duration duration) { - final hours = duration.inHours; - final minutes = duration.inMinutes % 60; - final seconds = duration.inSeconds % 60; - return "${hours > 0 ? "${zeroPadNumStr(hours)}:" : ""}${zeroPadNumStr(minutes)}:${zeroPadNumStr(seconds)}"; - } - static Future raceMultiple( Future Function() inner, { Duration timeout = const Duration(milliseconds: 2500), @@ -57,4 +46,8 @@ abstract class PrimitiveUtils { }), ); } + + static String toSafeFileName(String str) { + return str.replaceAll(RegExp(r'[/\?%*:|"<>]'), ' '); + } } diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart index 2d44b984..9e3b5893 100644 --- a/lib/utils/service_utils.dart +++ b/lib/utils/service_utils.dart @@ -8,7 +8,7 @@ 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/models/lyrics.dart'; -import 'package:spotube/models/spotube_track.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/primitive_utils.dart'; import 'package:collection/collection.dart'; @@ -171,7 +171,7 @@ abstract class ServiceUtils { static const baseUri = "https://www.rentanadviser.com/subtitles"; @Deprecated("In favor spotify lyrics api, this isn't needed anymore") - static Future getTimedLyrics(SpotubeTrack track) async { + static Future getTimedLyrics(SourcedTrack track) async { final artistNames = track.artists?.map((artist) => artist.name!).toList() ?? []; final query = getTitle( @@ -199,7 +199,7 @@ abstract class ServiceUtils { false; final hasTrackName = title.contains(track.name!.toLowerCase()); final isNotLive = !PrimitiveUtils.containsTextInBracket(title, "live"); - final exactYtMatch = title == track.ytTrack.title.toLowerCase(); + final exactYtMatch = title == track.sourceInfo.title.toLowerCase(); if (exactYtMatch) points = 7; for (final criteria in [hasTrackName, hasAllArtists, isNotLive]) { if (criteria) points++; @@ -257,11 +257,34 @@ abstract class ServiceUtils { } static void navigate(BuildContext context, String location, {Object? extra}) { + if (GoRouterState.of(context).matchedLocation == location) return; GoRouter.of(context).go(location, extra: extra); } static void push(BuildContext context, String location, {Object? extra}) { - GoRouter.of(context).push(location, extra: extra); + final router = GoRouter.of(context); + final routerState = GoRouterState.of(context); + final routerStack = router.routerDelegate.currentConfiguration.matches + .map((e) => e.matchedLocation); + + if (routerState.matchedLocation == location || + routerStack.contains(location)) return; + router.push(location, extra: extra); + } + + static DateTime parseSpotifyAlbumDate(AlbumSimple? album) { + if (album == null || album.releaseDate == null) { + return DateTime.parse("1975-01-01"); + } + + switch (album.releaseDatePrecision ?? DatePrecision.year) { + case DatePrecision.day: + return DateTime.parse(album.releaseDate!); + case DatePrecision.month: + return DateTime.parse("${album.releaseDate}-01"); + case DatePrecision.year: + return DateTime.parse("${album.releaseDate}-01-01"); + } } static List sortTracks(List tracks, SortBy sortBy) { @@ -278,12 +301,12 @@ abstract class ServiceUtils { case SortBy.ascending: return a.name?.compareTo(b.name ?? "") ?? 0; case SortBy.oldest: - final aDate = DateTime.parse(a.album?.releaseDate ?? "2069-01-01"); - final bDate = DateTime.parse(b.album?.releaseDate ?? "2069-01-01"); + final aDate = parseSpotifyAlbumDate(a.album); + final bDate = parseSpotifyAlbumDate(b.album); return aDate.compareTo(bDate); case SortBy.newest: - final aDate = DateTime.parse(a.album?.releaseDate ?? "2069-01-01"); - final bDate = DateTime.parse(b.album?.releaseDate ?? "2069-01-01"); + final aDate = parseSpotifyAlbumDate(a.album); + final bDate = parseSpotifyAlbumDate(b.album); return bDate.compareTo(aDate); case SortBy.descending: return b.name?.compareTo(a.name ?? "") ?? 0; diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart index 5694d3fe..662b611c 100644 --- a/lib/utils/type_conversion_utils.dart +++ b/lib/utils/type_conversion_utils.dart @@ -8,9 +8,6 @@ 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/models/matched_track.dart'; -import 'package:spotube/models/spotube_track.dart'; -import 'package:spotube/services/youtube/youtube.dart'; import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -67,7 +64,7 @@ abstract class TypeConversionUtils { if (onRouteChange != null) { onRouteChange("/artist/${artist.value.id}"); } else { - ServiceUtils.navigate( + ServiceUtils.push( context, "/artist/${artist.value.id}", ); @@ -122,52 +119,35 @@ abstract class TypeConversionUtils { return track; } - static SpotubeTrack localTrack_X_Track( + static Track localTrack_X_Track( File file, { Metadata? metadata, String? art, }) { - final track = SpotubeTrack( - YoutubeVideoInfo( - searchMode: SearchMode.youtube, - id: "dQw4w9WgXcQ", - title: basenameWithoutExtension(file.path), - duration: Duration(milliseconds: metadata?.durationMs?.toInt() ?? 0), - dislikes: 0, - likes: 0, - thumbnailUrl: art ?? "", - views: 0, - channelName: metadata?.albumArtist ?? "Spotube", - channelId: metadata?.albumArtist ?? "Spotube", - publishedAt: - metadata?.year != null ? DateTime(metadata!.year!) : DateTime(2003), - ), - file.path, - [], - ); + final track = Track(); track.album = Album() - ..name = metadata?.album ?? "Spotube" + ..name = metadata?.album ?? "Unknown" ..images = [if (art != null) Image()..url = art] ..genres = [if (metadata?.genre != null) metadata!.genre!] ..artists = [ Artist() - ..name = metadata?.albumArtist ?? "Spotube" - ..id = metadata?.albumArtist ?? "Spotube" + ..name = metadata?.albumArtist ?? "Unknown" + ..id = metadata?.albumArtist ?? "Unknown" ..type = "artist", ] ..id = metadata?.album ..releaseDate = metadata?.year?.toString(); track.artists = [ Artist() - ..name = metadata?.artist ?? "Spotube" - ..id = metadata?.artist ?? "Spotube" + ..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) * 1000; + track.durationMs = (metadata?.durationMs?.toInt() ?? 0); return track; } diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 7e1d5828..8f100774 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -68,6 +68,8 @@ set_target_properties(${BINARY_NAME} # them to the application. include(flutter/generated_plugins.cmake) +target_link_libraries(${BINARY_NAME} PRIVATE ${MIMALLOC_LIB}) + # === Installation === # By default, "installing" just makes a relocatable bundle in the build diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index eef22b2f..c69c17c0 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,8 +6,10 @@ #include "generated_plugin_registrant.h" -#include +#include +#include #include +#include #include #include #include @@ -18,12 +20,18 @@ #include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) catcher_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "CatcherPlugin"); - catcher_plugin_register_with_registrar(catcher_registrar); + 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) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); g_autoptr(FlPluginRegistrar) local_notifier_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "LocalNotifierPlugin"); local_notifier_plugin_register_with_registrar(local_notifier_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 1d7de67a..a4487f4d 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,8 +3,10 @@ # list(APPEND FLUTTER_PLUGIN_LIST - catcher + dart_discord_rpc + file_selector_linux flutter_secure_storage_linux + gtk local_notifier media_kit_libs_linux screen_retriever diff --git a/linux/my_application.cc b/linux/my_application.cc index 759285af..d1ac5d12 100644 --- a/linux/my_application.cc +++ b/linux/my_application.cc @@ -17,6 +17,13 @@ G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) // Implements GApplication::activate. static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); + + GList* windows = gtk_application_get_windows(GTK_APPLICATION(application)); + if (windows) { + gtk_window_present(GTK_WINDOW(windows->data)); + return; + } + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); @@ -78,7 +85,7 @@ static gboolean my_application_local_command_line(GApplication* application, gch g_application_activate(application); *exit_status = 0; - return TRUE; + return FALSE; } // Implements GObject::dispose. @@ -98,7 +105,7 @@ static void my_application_init(MyApplication* self) {} MyApplication* my_application_new() { return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, - "flags", G_APPLICATION_NON_UNIQUE, + "com.github.KRTirtho.Spotube", APPLICATION_ID, + "flags", G_APPLICATION_HANDLES_COMMAND_LINE | G_APPLICATION_HANDLES_OPEN, nullptr)); } diff --git a/linux/packaging/appimage/make_config.yaml b/linux/packaging/appimage/make_config.yaml index 68e36df7..c7332ea2 100644 --- a/linux/packaging/appimage/make_config.yaml +++ b/linux/packaging/appimage/make_config.yaml @@ -11,3 +11,6 @@ keywords: generic_name: Music Streaming Application categories: - Music + +supported_mime_type: + - x-scheme-handler/spotify diff --git a/linux/packaging/deb/make_config.yaml b/linux/packaging/deb/make_config.yaml index b8c9ec04..f4c279b4 100644 --- a/linux/packaging/deb/make_config.yaml +++ b/linux/packaging/deb/make_config.yaml @@ -11,12 +11,13 @@ installed_size: 24400 dependencies: - mpv - - libappindicator3-1 - - gir1.2-appindicator3-0.1 + - libappindicator3-1 | libayatana-appindicator3-1 + - gir1.2-appindicator3-0.1 | gir1.2-ayatanaappindicator3-0.1 - libsecret-1-0 - libnotify-bin - libjsoncpp25 - - network-manager + - libmpv1 | libmpv2 + - xdg-user-dirs essential: false icon: assets/spotube-logo.png @@ -31,3 +32,6 @@ keywords: generic_name: Music Streaming Application categories: - Music + +supported_mime_type: + - x-scheme-handler/spotify diff --git a/linux/packaging/rpm/make_config.yaml b/linux/packaging/rpm/make_config.yaml index 0379ee9a..1f952d0e 100644 --- a/linux/packaging/rpm/make_config.yaml +++ b/linux/packaging/rpm/make_config.yaml @@ -12,7 +12,7 @@ requires: - jsoncpp - libsecret - libnotify - - NetworkManager + - xdg-user-dirs display_name: Spotube @@ -27,5 +27,7 @@ generic_name: Music Streaming Application categories: - Music - startup_notify: true + +supported_mime_type: + - x-scheme-handler/spotify diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 80a2d2ed..a7965e14 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,11 +5,11 @@ import FlutterMacOS import Foundation +import app_links import audio_service import audio_session -import catcher -import connectivity_plus import device_info_plus +import file_selector_macos import flutter_secure_storage_macos import local_notifier import media_kit_libs_macos_audio @@ -25,11 +25,11 @@ 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")) - CatcherPlugin.register(with: registry.registrar(forPlugin: "CatcherPlugin")) - ConnectivityPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin")) MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin")) diff --git a/macos/Podfile b/macos/Podfile index fe733905..049abe29 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.13' +platform :osx, '10.14' # 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 99c0177d..65fe3535 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,101 +1,146 @@ PODS: + - app_links (1.0.0): + - FlutterMacOS - audio_service (0.14.1): - FlutterMacOS - audio_session (0.0.1): - FlutterMacOS - - audioplayers_darwin (0.0.1): + - device_info_plus (0.0.1): - FlutterMacOS - - bitsdojo_window_macos (0.0.1): + - file_selector_macos (0.0.1): - FlutterMacOS - - connectivity_plus_macos (0.0.1): + - flutter_secure_storage_macos (6.1.1): - FlutterMacOS - - ReachabilitySwift - FlutterMacOS (1.0.0) - FMDB (2.7.5): - FMDB/standard (= 2.7.5) - FMDB/standard (2.7.5) - - macos_ui (0.1.0): + - local_notifier (0.1.0): - FlutterMacOS - - metadata_god (0.0.1): + - media_kit_libs_macos_audio (1.0.4): - FlutterMacOS - - package_info_plus_macos (0.0.1): + - media_kit_native_event_loop (1.0.0): - FlutterMacOS - - path_provider_macos (0.0.1): + - metadata_god (0.0.1) + - package_info_plus (0.0.1): - FlutterMacOS - - ReachabilitySwift (5.0.0) - - shared_preferences_macos (0.0.1): + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - screen_retriever (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter - FlutterMacOS - sqflite (0.0.2): - FlutterMacOS - FMDB (>= 2.7.5) + - system_theme (0.0.1): + - FlutterMacOS + - system_tray (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`) - - audioplayers_darwin (from `Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/macos`) - - bitsdojo_window_macos (from `Flutter/ephemeral/.symlinks/plugins/bitsdojo_window_macos/macos`) - - connectivity_plus_macos (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus_macos/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_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - - macos_ui (from `Flutter/ephemeral/.symlinks/plugins/macos_ui/macos`) + - local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`) + - media_kit_libs_macos_audio (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_audio/macos`) + - media_kit_native_event_loop (from `Flutter/ephemeral/.symlinks/plugins/media_kit_native_event_loop/macos`) - metadata_god (from `Flutter/ephemeral/.symlinks/plugins/metadata_god/macos`) - - package_info_plus_macos (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus_macos/macos`) - - path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`) - - shared_preferences_macos (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_macos/macos`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - 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`) + - system_theme (from `Flutter/ephemeral/.symlinks/plugins/system_theme/macos`) + - system_tray (from `Flutter/ephemeral/.symlinks/plugins/system_tray/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 - - ReachabilitySwift EXTERNAL SOURCES: + app_links: + :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos audio_service: :path: Flutter/ephemeral/.symlinks/plugins/audio_service/macos audio_session: :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos - audioplayers_darwin: - :path: Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/macos - bitsdojo_window_macos: - :path: Flutter/ephemeral/.symlinks/plugins/bitsdojo_window_macos/macos - connectivity_plus_macos: - :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus_macos/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_secure_storage_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos FlutterMacOS: :path: Flutter/ephemeral - macos_ui: - :path: Flutter/ephemeral/.symlinks/plugins/macos_ui/macos + local_notifier: + :path: Flutter/ephemeral/.symlinks/plugins/local_notifier/macos + media_kit_libs_macos_audio: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_audio/macos + media_kit_native_event_loop: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_native_event_loop/macos metadata_god: :path: Flutter/ephemeral/.symlinks/plugins/metadata_god/macos - package_info_plus_macos: - :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus_macos/macos - path_provider_macos: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos - shared_preferences_macos: - :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_macos/macos + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + screen_retriever: + :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin sqflite: :path: Flutter/ephemeral/.symlinks/plugins/sqflite/macos + system_theme: + :path: Flutter/ephemeral/.symlinks/plugins/system_theme/macos + system_tray: + :path: Flutter/ephemeral/.symlinks/plugins/system_tray/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 audio_service: b88ff778e0e3915efd4cd1a5ad6f0beef0c950a9 audio_session: dea1f41890dbf1718f04a56f1d6150fd50039b72 - audioplayers_darwin: dcad41de4fbd0099cb3749f7ab3b0cb8f70b810c - bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00 - connectivity_plus_macos: f6e86fd000e971d361e54b5afcadc8c8fa773308 - FlutterMacOS: ae6af50a8ea7d6103d888583d46bd8328a7e9811 + device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f + file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 + flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a - macos_ui: 125c911559d646194386d84c017ad6819122e2db - metadata_god: 55a71136c95eb75ec28142f6fbfc2bcff6f881b1 - package_info_plus_macos: f010621b07802a241d96d01876d6705f15e77c1c - path_provider_macos: 3c0c3b4b0d4a76d2bf989a913c2de869c5641a19 - ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 - shared_preferences_macos: a64dc611287ed6cbe28fd1297898db1336975727 + 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 + screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 + shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 sqflite: a5789cceda41d54d23f31d6de539d65bb14100ea - url_launcher_macos: 597e05b8e514239626bcf4a850fcf9ef5c856ec3 + system_theme: c7b9f6659a5caa26c9bc2284da096781e9a6fcbc + system_tray: e53c972838c69589ff2e77d6d3abfd71332f9e5d + url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 + window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 + window_size: 339dafa0b27a95a62a843042038fa6c3c48de195 -PODFILE CHECKSUM: a884f6dd3f7494f3892ee6c81feea3a3abbf9153 +PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 -COCOAPODS: 1.11.3 +COCOAPODS: 1.14.3 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 9b86152a..e2e72334 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXAggregateTarget section */ @@ -208,7 +208,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 33CC10EC2044A3C60003C045 = { @@ -228,7 +228,7 @@ }; }; buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; + compatibilityVersion = "Xcode 12.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -261,6 +261,7 @@ /* Begin PBXShellScriptBuildPhase section */ 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -409,7 +410,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.13; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -426,12 +427,15 @@ CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 88NVGSJ5N3; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Spotube; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.13; + MACOSX_DEPLOYMENT_TARGET = 10.14; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -489,7 +493,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.13; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -536,7 +540,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.13; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -553,12 +557,15 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 88NVGSJ5N3; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Spotube; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.13; + MACOSX_DEPLOYMENT_TARGET = 10.14; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -574,12 +581,15 @@ CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = 88NVGSJ5N3; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Spotube; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.13; + MACOSX_DEPLOYMENT_TARGET = 10.14; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 741e68bc..1407feb3 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ Bool { - return true + return false } } diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 6fc950ef..e9de2261 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -1,23 +1,20 @@ - - com.apple.security.app-sandbox - - com.apple.security.cs.allow-jit - - com.apple.security.network.server - - com.apple.security.network.client - - - - - - - \ No newline at end of file + + 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 + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 9728552b..1a8bb655 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -1,39 +1,52 @@ - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconFile - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsArbitraryLoadsForMedia - - - - + + CFBundleURLTypes + + + CFBundleURLName + + Spotify + CFBundleURLSchemes + + + spotify + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsForMedia + + + + \ No newline at end of file diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 4a447fde..f05277de 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -1,21 +1,18 @@ - - com.apple.security.app-sandbox - - com.apple.security.network.server - - com.apple.security.network.client - - - - - - - \ No newline at end of file + + 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 + + + diff --git a/macos/Runner/RunnerDebug.entitlements b/macos/Runner/RunnerDebug.entitlements index 3ba6c126..e9de2261 100644 --- a/macos/Runner/RunnerDebug.entitlements +++ b/macos/Runner/RunnerDebug.entitlements @@ -4,8 +4,14 @@ 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 diff --git a/macos/packaging/pkg/make_config.yaml b/macos/packaging/pkg/make_config.yaml new file mode 100644 index 00000000..06d92076 --- /dev/null +++ b/macos/packaging/pkg/make_config.yaml @@ -0,0 +1 @@ +install-path: /Applications diff --git a/pubspec.lock b/pubspec.lock index 0e298616..0d1b2993 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "0c80aeab9bc807ab10022cd3b2f4cf2ecdf231949dc1ddd9442406a003f19201" + sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a url: "https://pub.dev" source: hosted - version: "52.0.0" + version: "61.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: cd8ee83568a77f3ae6b913a36093a1c9b1264e7cb7f834d9ddd2311dade9c1f4 + sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 url: "https://pub.dev" source: hosted - version: "5.4.0" + version: "5.13.0" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "4e392b5eba997df356ca6021f28431ce1cfeb16758699553a94b13add874a3bb" + url: "https://pub.dev" + source: hosted + version: "3.5.0" app_package_maker: dependency: transitive description: @@ -85,18 +93,18 @@ packages: dependency: transitive description: name: archive - sha256: "80e5141fafcb3361653ce308776cfd7d45e6e9fbb429e14eec571382c0c5fecb" + sha256: ca12e6c9ac022f33fd89128e7007fb5e97ab6e814d4fa05dd8d4f2db1e3c69cb url: "https://pub.dev" source: hosted - version: "3.3.2" + version: "3.4.5" args: dependency: "direct main" description: name: args - sha256: c372bb384f273f0c2a8aaaa226dad84dc27c8519a691b888725dec59518ad53a + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" async: dependency: "direct main" description: @@ -109,18 +117,26 @@ packages: dependency: "direct main" description: name: audio_service - sha256: "7e86d7ce23caad605199f7b25e548fe7b618fb0c150fa0585f47a910fe7e7a67" + sha256: a4d989f1225ea9621898d60f23236dcbfc04876fa316086c23c5c4af075dbac4 url: "https://pub.dev" source: hosted - version: "0.18.9" + version: "0.18.12" + audio_service_mpris: + dependency: "direct main" + description: + name: audio_service_mpris + sha256: "31be5de2db0c71b217157afce1974ac6d0ad329bd91deb1f19ad094d29340d8e" + url: "https://pub.dev" + source: hosted + version: "0.1.0" audio_service_platform_interface: dependency: transitive description: name: audio_service_platform_interface - sha256: "2c3a1d52803931e836b9693547a71c0c3585ad54219d2214219ed5cfcc3c1af4" + sha256: "8431a455dac9916cc9ee6f7da5620a666436345c906ad2ebb7fa41d18b3c1bf4" url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.1.1" audio_service_web: dependency: transitive description: @@ -133,10 +149,10 @@ packages: dependency: "direct main" description: name: audio_session - sha256: e4acc4e9eaa32436dfc5d7aed7f0a370f2d7bb27ee27de30d6c4f220c2a05c73 + sha256: "8a2bc5e30520e18f3fb0e366793d78057fb64cd5287862c76af0c8771f2a52ad" url: "https://pub.dev" source: hosted - version: "0.1.13" + version: "0.1.16" auto_size_text: dependency: "direct main" description: @@ -157,10 +173,10 @@ packages: dependency: transitive description: name: build - sha256: "3fbda25365741f8251b39f3917fb3c8e286a96fd068a5a242e11c2012d495777" + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.1" build_cli_annotations: dependency: transitive description: @@ -181,34 +197,34 @@ packages: dependency: transitive description: name: build_daemon - sha256: "6bc5544ea6ce4428266e7ea680e945c68806c4aae2da0eb5e9ccf38df8d6acbf" + sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "4.0.0" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "7c35a3a7868626257d8aee47b51c26b9dba11eaddf3431117ed2744951416aab" + sha256: d912852cce27c9e80a93603db721c267716894462e7033165178b91138587972 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.3.2" build_runner: dependency: "direct dev" description: name: build_runner - sha256: b0a8a7b8a76c493e85f1b84bffa0588859a06197863dba8c9036b15581fd9727 + sha256: "10c6bcdbf9d049a0b666702cf1cee4ddfdc38f02a19d35ae392863b47519848b" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.4.6" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: "14febe0f5bac5ae474117a36099b4de6f1dbc52df6c5e55534b3da9591bf4292" + sha256: "6d6ee4276b1c5f34f21fdf39425202712d2be82019983d52f351c94aafbc2c41" url: "https://pub.dev" source: hosted - version: "7.2.7" + version: "7.2.10" built_collection: dependency: transitive description: @@ -221,51 +237,50 @@ packages: dependency: transitive description: name: built_value - sha256: "169565c8ad06adb760c3645bf71f00bff161b00002cace266cad42c5d22a7725" + sha256: ff627b645b28fb8bdb69e645f910c2458fd6b65f6585c3a53e0626024897dedf url: "https://pub.dev" source: hosted - version: "8.4.3" + version: "8.6.2" buttons_tabbar: dependency: "direct main" description: name: buttons_tabbar - sha256: d8a53cd3be0ce5b662d01378b1cd842eb44ee68da5abeeff3c081ee3bf614160 + sha256: "781128180f3e76cf93c093183f10395c664983dbee20bc4da2025be70085c2da" url: "https://pub.dev" source: hosted - version: "1.3.6" + version: "1.3.7+1" cached_network_image: dependency: "direct main" description: name: cached_network_image - sha256: fd3d0dc1d451f9a252b32d95d3f0c3c487bc41a75eba2e6097cb0b9c71491b15 + sha256: f98972704692ba679db144261172a8e20feb145636c617af0eb4022132a6797f url: "https://pub.dev" source: hosted - version: "3.2.3" + version: "3.3.0" cached_network_image_platform_interface: dependency: transitive description: name: cached_network_image_platform_interface - sha256: bb2b8403b4ccdc60ef5f25c70dead1f3d32d24b9d6117cfc087f496b178594a7 + sha256: "56aa42a7a01e3c9db8456d9f3f999931f1e05535b5a424271e9a38cabf066613" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" cached_network_image_web: dependency: transitive description: name: cached_network_image_web - sha256: b8eb814ebfcb4dea049680f8c1ffb2df399e4d03bf7a352c775e26fa06e02fa0 + sha256: "759b9a9f8f6ccbb66c185df805fac107f05730b1dab9c64626d1008cca532257" url: "https://pub.dev" source: hosted - version: "1.0.2" - catcher: + version: "1.1.0" + catcher_2: dependency: "direct main" description: - path: "." - ref: HEAD - resolved-ref: "5c91db2578abd0c1609dc409ee3daee168d8b20e" - url: "https://github.com/ThexXTURBOXx/catcher" - source: git - version: "0.8.0" + name: catcher_2 + sha256: "73e251057b1b59442d58b5109d26401cfed850df21da44b028338d4f85f69105" + url: "https://pub.dev" + source: hosted + version: "1.0.0" change_case: dependency: transitive description: @@ -286,10 +301,10 @@ packages: dependency: transitive description: name: checked_yaml - sha256: "3d1505d91afa809d177efd4eed5bb0eb65805097a1463abdd2add076effae311" + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.0.3" cli_util: dependency: transitive description: @@ -310,18 +325,18 @@ packages: dependency: transitive description: name: code_builder - sha256: "0d43dd1288fd145de1ecc9a3948ad4a6d5a82f0a14c4fdd0892260787d975cbe" + sha256: "315a598c7fbe77f22de1c9da7cfd6fd21816312f16ffa124453b4fc679e540f1" url: "https://pub.dev" source: hosted - version: "4.4.0" + version: "4.6.0" collection: dependency: "direct main" description: name: collection - sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a url: "https://pub.dev" source: hosted - version: "1.17.1" + version: "1.18.0" color: dependency: transitive description: @@ -330,22 +345,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" - connectivity_plus: - dependency: transitive - description: - name: connectivity_plus - sha256: "8599ae9edca5ff96163fca3e36f8e481ea917d1e71cdad912c084b5579913f34" - url: "https://pub.dev" - source: hosted - version: "4.0.1" - connectivity_plus_platform_interface: - dependency: transitive - description: - name: connectivity_plus_platform_interface - sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a - url: "https://pub.dev" - source: hosted - version: "1.2.4" convert: dependency: transitive description: @@ -354,30 +353,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: fd832b5384d0d6da4f6df60b854d33accaaeb63aa9e10e736a87381f08dee2cb + url: "https://pub.dev" + source: hosted + version: "0.3.3+5" crypto: dependency: transitive description: name: crypto - sha256: aa274aa7774f8964e4f4f38cc994db7b6158dd36e9187aaceaddc994b35c6c67 + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" csslib: dependency: transitive description: name: csslib - sha256: b36c7f7e24c0bdf1bf9a3da461c837d1de64b9f8beb190c9011d8c72a3dfd745 + sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" url: "https://pub.dev" source: hosted - version: "0.17.2" + version: "1.0.0" cupertino_icons: dependency: "direct main" description: name: cupertino_icons - sha256: e35129dc44c9118cee2a5603506d823bab99c68393879edb440e0090d07586be + sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.0.6" curved_navigation_bar: dependency: "direct main" description: @@ -386,22 +393,39 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.3" + dart_des: + dependency: transitive + description: + name: dart_des + sha256: "0a66afb8883368c824497fd2a1fd67bdb1a785965a3956728382c03d40747c33" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + dart_discord_rpc: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "4d05017838ebeadcdb832e1893fabad1506fddba" + url: "https://github.com/Tommypop2/dart_discord_rpc.git" + source: git + version: "0.0.3" dart_style: dependency: transitive description: name: dart_style - sha256: "7a03456c3490394c8e7665890333e91ae8a49be43542b616e414449ac358acd4" + sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" url: "https://pub.dev" source: hosted - version: "2.2.4" + version: "2.3.2" dartx: dependency: transitive description: name: dartx - sha256: "45d7176701f16c5a5e00a4798791c1964bc231491b879369c818dd9a9c764871" + sha256: "8b25435617027257d43e6508b5fe061012880ddfdaa75a71d607c3de2a13d244" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.0" dbus: dependency: "direct main" description: @@ -446,10 +470,10 @@ packages: dependency: "direct main" description: name: dio - sha256: ce75a1b40947fea0a0e16ce73337122a86762e38b982e1ccb909daa3b9bc4197 + sha256: "417e2a6f9d83ab396ec38ff4ea5da6c254da71e4db765ad737a42af6930140b7" url: "https://pub.dev" source: hosted - version: "5.3.2" + version: "5.3.3" disable_battery_optimization: dependency: "direct main" description: @@ -462,18 +486,27 @@ packages: dependency: transitive description: name: dots_indicator - sha256: e59dfc90030ee5a4fd4c53144a8ce97cc7a823c2067b8fb9814960cd1ae63f89 + sha256: f1599baa429936ba87f06ae5f2adc920a367b16d08f74db58c3d0f6e93bcdb5c url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.2" + draggable_scrollbar: + dependency: "direct main" + description: + path: "." + ref: cfd570035bf393de541d32e9b28808b5d7e602df + resolved-ref: cfd570035bf393de541d32e9b28808b5d7e602df + url: "https://github.com/thielepaul/flutter-draggable-scrollbar.git" + source: git + version: "0.1.0" duration: dependency: "direct main" description: name: duration - sha256: d0b29d0a345429e3986ac56d60e4aef65b37d11e653022b2b9a4b361332b777f + sha256: "0548a12d235dab185c677ef660995f23fdc06a02a2b984aa23805f6a03d82815" url: "https://pub.dev" source: hosted - version: "3.0.12" + version: "3.0.13" envied: dependency: "direct main" description: @@ -502,26 +535,90 @@ packages: dependency: transitive description: name: ffi - sha256: ed5337a5660c506388a9f012be0288fb38b49020ce2b45fe1f8b8323fe429f99 + sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.0" file: dependency: transitive description: name: file - sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" url: "https://pub.dev" source: hosted - version: "6.1.4" + version: "7.0.0" file_picker: dependency: "direct main" description: name: file_picker - sha256: c7a8e25ca60e7f331b153b0cb3d405828f18d3e72a6fa1d9440c86556fffc877 + sha256: "4e42aacde3b993c5947467ab640882c56947d9d27342a5b6f2895b23956954a6" url: "https://pub.dev" source: hosted - version: "5.3.0" + version: "6.1.1" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: "84eaf3e034d647859167d1f01cfe7b6352488f34c1b4932635012b202014c25b" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: d41e165d6f798ca941d536e5dc93494d50e78c571c28ad60cfe0b0fefeb9f1e7 + url: "https://pub.dev" + source: hosted + version: "0.5.0+3" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: b3fbdda64aa2e335df6e111f6b0f1bb968402ed81d2dd1fa4274267999aa32c2 + url: "https://pub.dev" + source: hosted + version: "0.5.1+6" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "045d372bf19b02aeb69cacf8b4009555fb5f6f0b7ad8016e5f46dd1387ddd492" + url: "https://pub.dev" + source: hosted + version: "0.9.2+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "182c3f8350cee659f7b115e956047ee3dc672a96665883a545e81581b9a82c72" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "0aa47a725c346825a2bd396343ce63ac00bda6eff2fbc43eabe99737dede8262" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: dc6622c4d66cb1bee623ddcc029036603c6cc45c85e4a775bb06008d61c809c1 + url: "https://pub.dev" + source: hosted + version: "0.9.2+1" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: d3547240c20cabf205c7c7f01a50ecdbc413755814d6677f3cb366f04abcead0 + url: "https://pub.dev" + source: hosted + version: "0.9.3+1" fixnum: dependency: transitive description: @@ -534,34 +631,34 @@ packages: dependency: "direct main" description: name: fl_query - sha256: "64f482fc09eb1166adca232f68772b2b11c616d88bce3208b2753c940ebc9f71" + sha256: daee5ab0ed8899baa201b89b5813107df5258144a9e2bcf192dbcf922c57d985 url: "https://pub.dev" source: hosted - version: "1.0.0-alpha.3" - fl_query_connectivity_plus_adapter: + version: "1.0.0" + fl_query_devtools: dependency: "direct main" description: - name: fl_query_connectivity_plus_adapter - sha256: a0e69615e25f6dfe74d1e5a0909aeeb865e93c65dd4e0b236f0846f9e54f758b + name: fl_query_devtools + sha256: "2ae8905fd4a95f1d245a1b54057c31c8d27fc961223bcb7ce13088bcf6595059" url: "https://pub.dev" source: hosted - version: "0.1.0-alpha.2" + version: "0.1.0" fl_query_hooks: dependency: "direct main" description: name: fl_query_hooks - sha256: b0ffc81fb047cbcedd9766776f9c72b95382730ce173226f0695c3f45774b0bc + sha256: "6c88b3bfbdc3e1330931b927903929d7351f86fc63266ac93b3acb9f133a09a9" url: "https://pub.dev" source: hosted - version: "1.0.0-alpha.3" + version: "1.0.0" fluentui_system_icons: dependency: "direct main" description: name: fluentui_system_icons - sha256: b7fd3f18a6431fe0c63610c7c6f46e48b5471104a9460678fb0e661230911bf7 + sha256: "7637cab80bd8d1ba762144cd85df79a7318c12ed5a66d166a9e4acbf24a4c412" url: "https://pub.dev" source: hosted - version: "1.1.190" + version: "1.1.214" flutter: dependency: "direct main" description: flutter @@ -583,22 +680,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.9" - flutter_blurhash: - dependency: transitive - description: - name: flutter_blurhash - sha256: "05001537bd3fac7644fa6558b09ec8c0a3f2eba78c0765f88912882b1331a5c6" - url: "https://pub.dev" - source: hosted - version: "0.7.0" flutter_cache_manager: dependency: "direct main" description: name: flutter_cache_manager - sha256: "32cd900555219333326a2d0653aaaf8671264c29befa65bbd9856d204a4c9fb3" + sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba" url: "https://pub.dev" source: hosted - version: "3.3.0" + version: "3.3.1" flutter_desktop_tools: dependency: "direct main" description: @@ -641,26 +730,26 @@ packages: dependency: transitive description: name: flutter_gen_core - sha256: e74db9fc706ce43ef0dfd4b296fcfa10f84c4d862b9b68a087e7c703f97c7a0a + sha256: e8637dd6a59860f89e5e71be0a27101ec32dad1a0ed7fd879fd23b6e91d5004d url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.3.1" flutter_gen_runner: dependency: "direct dev" description: name: flutter_gen_runner - sha256: "434511d7c3f7bb5c67d89a16451056093953bebf7afa8336baeceddfc6fe2a21" + sha256: "7de1bf4fc0439be0fef3178b6423d5c7f1f9f3a38a7c6fafe75d7f70ff4856d7" url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.3.1" flutter_hooks: dependency: "direct main" description: name: flutter_hooks - sha256: "9eab8fd7aa752c3c1c0a364f9825851d410eb935243411682f4b1b0a4c569d71" + sha256: "6ae13b1145c589112cbd5c4fda6c65908993a9cb18d4f82042e9c28dd9fbf611" url: "https://pub.dev" source: hosted - version: "0.20.0" + version: "0.20.1" flutter_inappwebview: dependency: "direct main" description: @@ -669,6 +758,54 @@ packages: url: "https://pub.dev" source: hosted version: "5.7.2+3" + flutter_keyboard_visibility: + dependency: transitive + description: + name: flutter_keyboard_visibility + sha256: "4983655c26ab5b959252ee204c2fffa4afeb4413cd030455194ec0caa3b8e7cb" + url: "https://pub.dev" + source: hosted + version: "5.4.1" + flutter_keyboard_visibility_linux: + dependency: transitive + description: + name: flutter_keyboard_visibility_linux + sha256: "6fba7cd9bb033b6ddd8c2beb4c99ad02d728f1e6e6d9b9446667398b2ac39f08" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_macos: + dependency: transitive + description: + name: flutter_keyboard_visibility_macos + sha256: c5c49b16fff453dfdafdc16f26bdd8fb8d55812a1d50b0ce25fc8d9f2e53d086 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_platform_interface: + dependency: transitive + description: + name: flutter_keyboard_visibility_platform_interface + sha256: e43a89845873f7be10cb3884345ceb9aebf00a659f479d1c8f4293fcb37022a4 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_web: + dependency: transitive + description: + name: flutter_keyboard_visibility_web + sha256: d3771a2e752880c79203f8d80658401d0c998e4183edca05a149f5098ce6e3d1 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_windows: + dependency: transitive + description: + name: flutter_keyboard_visibility_windows + sha256: fc4b0f0b6be9b93ae527f3d527fb56ee2d918cd88bbca438c478af7bcfd0ef73 + url: "https://pub.dev" + source: hosted + version: "1.0.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -681,10 +818,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: aeb0b80a8b3709709c9cc496cdc027c5b3216796bc0af0ce1007eaf24464fd4c + sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.3" flutter_localizations: dependency: "direct main" description: flutter @@ -694,90 +831,98 @@ packages: dependency: transitive description: name: flutter_mailer - sha256: "5ec538be34233a62129c3aedc8cfcfaca0c4de390ca43f331f52e972d410b84d" + sha256: a935e9caa842877e8ed56109afb75b86e6488edbcd4696a5ac02b327a48fcd8a url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.1" flutter_native_splash: dependency: "direct main" description: name: flutter_native_splash - sha256: af665ef80a213a9ed502845a3d7a61b9acca4100ee7e9f067a7440bc3acd6730 + sha256: "91004565166dbbc7a85e7e99b84124a287839830ca957cfe45004793fe6fe69f" url: "https://pub.dev" source: hosted - version: "2.2.19" + version: "2.3.3" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "96af49aa6b57c10a312106ad6f71deed5a754029c24789bbf620ba784f0bd0b0" + sha256: f185ac890306b5779ecbd611f52502d8d4d63d27703ef73161ca0407e815f02c url: "https://pub.dev" source: hosted - version: "2.0.14" + version: "2.0.16" flutter_riverpod: dependency: "direct main" description: name: flutter_riverpod - sha256: "0c997763ce06359ee4686553b74def84062e9d6929ac63f61fa02465c1f8e32c" + sha256: e667e406a74d67715f1fa0bd941d9ded49aff72f3a9f4440a36aece4e8d457a7 url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.4.3" flutter_rust_bridge: dependency: transitive description: name: flutter_rust_bridge - sha256: ff90d5ddd0cda6d94ed048cc9c4a4d993d1a4bb11605d60a1282fc1bbf173c77 + sha256: e12415c3bce49bcbc3fed383f0ea41ad7d828f6cf0eccba0588ffa5a812fe522 url: "https://pub.dev" source: hosted - version: "1.80.1" + version: "1.82.1" flutter_secure_storage: dependency: "direct main" description: name: flutter_secure_storage - sha256: "98352186ee7ad3639ccc77ad7924b773ff6883076ab952437d20f18a61f0a7c5" + sha256: ffdbb60130e4665d2af814a0267c481bcf522c41ae2e43caf69fa0146876d685 url: "https://pub.dev" source: hosted - version: "8.0.0" + version: "9.0.0" flutter_secure_storage_linux: dependency: transitive description: name: flutter_secure_storage_linux - sha256: "0912ae29a572230ad52d8a4697e5518d7f0f429052fd51df7e5a7952c7efe2a3" + sha256: "3d5032e314774ee0e1a7d0a9f5e2793486f0dff2dd9ef5a23f4e3fb2a0ae6a9e" url: "https://pub.dev" source: hosted - version: "1.1.3" + version: "1.2.0" flutter_secure_storage_macos: dependency: transitive description: name: flutter_secure_storage_macos - sha256: "083add01847fc1c80a07a08e1ed6927e9acd9618a35e330239d4422cd2a58c50" + sha256: bd33935b4b628abd0b86c8ca20655c5b36275c3a3f5194769a7b3f37c905369c url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.0.1" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: b3773190e385a3c8a382007893d678ae95462b3c2279e987b55d140d3b0cb81b + sha256: "0d4d3a5dd4db28c96ae414d7ba3b8422fd735a8255642774803b2532c9a61d7e" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.2" flutter_secure_storage_web: dependency: transitive description: name: flutter_secure_storage_web - sha256: "42938e70d4b872e856e678c423cc0e9065d7d294f45bc41fc1981a4eb4beaffe" + sha256: "30f84f102df9dcdaa2241866a958c2ec976902ebdaa8883fbfe525f1f2f3cf20" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" flutter_secure_storage_windows: dependency: transitive description: name: flutter_secure_storage_windows - sha256: fc2910ec9b28d60598216c29ea763b3a96c401f0ce1d13cdf69ccb0e5c93c3ee + sha256: "5809c66f9dd3b4b93b0a6e2e8561539405322ee767ac2f64d084e2ab5429d108" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" + flutter_sharing_intent: + dependency: "direct main" + description: + name: flutter_sharing_intent + sha256: "6eb896e6523b735e8230eeb206fd3b9f220f11ce879c2400a90b443147036ff9" + url: "https://pub.dev" + source: hosted + version: "1.1.0" flutter_svg: dependency: "direct main" description: @@ -800,18 +945,34 @@ packages: dependency: transitive description: name: fluttertoast - sha256: "774fa28b07f3a82c93596bc137be33189fec578ed3447a93a5a11c93435de394" + sha256: "474f7d506230897a3cd28c965ec21c5328ae5605fc9c400cd330e9e9d6ac175c" url: "https://pub.dev" source: hosted - version: "8.1.3" + version: "8.2.2" + form_validator: + dependency: "direct main" + description: + name: form_validator + sha256: "8cbe91b7d5260870d6fb9e23acd55d5d1d1fdf2397f0279a4931ac3c0c7bf8fb" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: "6c5031daae12c7072b3a87eff98983076434b4889ef2a44384d0cae3f82372ba" + url: "https://pub.dev" + source: hosted + version: "2.4.6" freezed_annotation: - dependency: transitive + dependency: "direct main" description: name: freezed_annotation - sha256: aeac15850ef1b38ee368d4c53ba9a847e900bb2c53a4db3f6881cbb3cb684338 + sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.1" frontend_server_client: dependency: transitive description: @@ -825,70 +986,70 @@ packages: description: flutter source: sdk version: "0.0.0" - functions_client: - dependency: transitive - description: - name: functions_client - sha256: "3b157b4d3ae9e38614fd80fab76d1ef1e0e39ff3412a45de2651f27cecb9d2d2" - url: "https://pub.dev" - source: hosted - version: "1.3.2" fuzzywuzzy: dependency: "direct main" description: name: fuzzywuzzy - sha256: f685751951297e560361b6416d9ba62d40231599d7b3e8c078318990d3bab84a + sha256: a84b99ebb21c448e02267070c91b218b4fbbef9c668b344aaeada49865985cae url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "1.1.6" + gap: + dependency: "direct main" + description: + name: gap + sha256: f19387d4e32f849394758b91377f9153a1b41d79513ef7668c088c77dbc6955d + url: "https://pub.dev" + source: hosted + version: "3.0.1" glob: dependency: transitive description: name: glob - sha256: "4515b5b6ddb505ebdd242a5f2cc5d22d3d6a80013789debfbda7777f47ea308c" + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" go_router: dependency: "direct main" description: name: go_router - sha256: b3cadd2cd59a4103fd5f6bc572ca75111264698784e927aa471921c3477d5475 + sha256: "3b40e751eaaa855179b416974d59d29669e750d2e50fcdb2b37f1cb0ca8c803a" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "13.0.1" google_fonts: dependency: "direct main" description: name: google_fonts - sha256: e20ff62b158b96f392bfc8afe29dee1503c94fbea2cbe8186fd59b756b8ae982 + sha256: f0b8d115a13ecf827013ec9fc883390ccc0e87a96ed5347a3114cac177ef18e8 url: "https://pub.dev" source: hosted - version: "5.1.0" - gotrue: - dependency: transitive - description: - name: gotrue - sha256: "6ba95e38c06af30d4a365112b433567df70f83d5853923274cb894ea9702c5fa" - url: "https://pub.dev" - source: hosted - version: "1.11.2" + version: "6.1.0" graphs: dependency: transitive description: name: graphs - sha256: f9e130f3259f52d26f0cfc0e964513796dafed572fa52e45d2f8d6ca14db39b2 + sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.1" gsettings: dependency: transitive description: name: gsettings - sha256: fe90d719e09a6f36607021047e642068a0c98839d9633db00b91633420ae8b0d + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" url: "https://pub.dev" source: hosted - version: "0.2.7" + version: "0.2.8" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" hive: dependency: "direct main" description: @@ -909,26 +1070,34 @@ packages: dependency: "direct dev" description: name: hive_generator - sha256: "65998cc4d2cd9680a3d9709d893d2f6bb15e6c1f92626c3f1fa650b4b3281521" + sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.1" hooks_riverpod: dependency: "direct main" description: name: hooks_riverpod - sha256: "71695b2e1dfc22a39f1f9c67b798f8f8f1521f2d0349817d13ccdd5c4cd7acba" + sha256: "69dcb88acbc68c81fc27ec15a89a4e24b7812c83c13a6307a1a9366ada758541" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.4.3" html: dependency: "direct main" description: name: html - sha256: "58e3491f7bf0b6a4ea5110c0c688877460d1a6366731155c4a4580e7ded773e8" + sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" url: "https://pub.dev" source: hosted - version: "0.15.3" + version: "0.15.4" + html_unescape: + dependency: "direct main" + description: + name: html_unescape + sha256: "15362d7a18f19d7b742ef8dcb811f5fd2a2df98db9f80ea393c075189e0b61e3" + url: "https://pub.dev" + source: hosted + version: "2.0.0" http: dependency: "direct main" description: @@ -957,10 +1126,74 @@ packages: dependency: transitive description: name: image - sha256: a72242c9a0ffb65d03de1b7113bc4e189686fc07c7147b8b41811d0dd0e0d9bf + sha256: "028f61960d56f26414eb616b48b04eb37d700cbe477b7fb09bf1d7ce57fd9271" url: "https://pub.dev" source: hosted - version: "4.0.17" + version: "4.1.3" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "7d7f2768df2a8b0a3cefa5ef4f84636121987d403130e70b17ef7e2cf650ba84" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "47da2161c2e9f8f8a9cbbd89d466d174333fbdd769aeed848912e0b16d9cb369" + url: "https://pub.dev" + source: hosted + version: "0.8.8" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "50bc9ae6a77eea3a8b11af5eb6c661eeb858fdd2f734c2a4fd17086922347ef7" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: c5538cacefacac733c724be7484377923b476216ad1ead35a0d2eadcdc0fc497 + url: "https://pub.dev" + source: hosted + version: "0.8.8+2" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: ed9b00e63977c93b0d2d2b343685bed9c324534ba5abafbb3dfbd6a780b1b514 + url: "https://pub.dev" + source: hosted + version: "2.9.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" integration_test: dependency: "direct dev" description: flutter @@ -970,18 +1203,18 @@ packages: dependency: "direct main" description: name: intl - sha256: a3715e3bc90294e971cb7dc063fbf3cd9ee0ebf8604ffeafabd9e6f16abbdbe6 + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" url: "https://pub.dev" source: hosted - version: "0.18.0" + version: "0.18.1" introduction_screen: dependency: "direct main" description: name: introduction_screen - sha256: "73965475d6b271846f81c5fce5b459546a4ea36c285408691522437fd6bbeb69" + sha256: ef5a5479a8e06a84b9a7eff16c698b9b82f70cd1b6203b264bc3686f9bfb77e2 url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.11" io: dependency: transitive description: @@ -990,6 +1223,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + jiosaavn: + dependency: "direct main" + description: + name: jiosaavn + sha256: d32b4f43f26488f942f5d7d19d748a1f2664ae3d41ff9c7d50eeb81705174bd2 + url: "https://pub.dev" + source: hosted + version: "0.1.0" js: dependency: transitive description: @@ -1010,26 +1251,50 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: "43793352f90efa5d8b251893a63d767b2f7c833120e3cc02adad55eefec04dc7" + sha256: aa1f5a8912615733e0fdc7a02af03308933c93235bdc8d50d0b0c8a8ccb0b969 url: "https://pub.dev" source: hosted - version: "6.6.2" - jwt_decode: + version: "6.7.1" + json_view: dependency: transitive description: - name: jwt_decode - sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb + name: json_view + sha256: "905c69f9e69d1eab5406b87ab6c10c3706c04c70c6a4959621bd2b43c2d27374" url: "https://pub.dev" source: hosted - version: "0.3.1" + version: "0.4.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + url: "https://pub.dev" + source: hosted + version: "10.0.0" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + url: "https://pub.dev" + source: hosted + version: "2.0.1" lints: dependency: transitive description: name: lints - sha256: "5e4a9cd06d447758280a8ac2405101e0e2094d2a1dbdd3756aec3fe7775ba593" + sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.1.1" local_notifier: dependency: transitive description: @@ -1042,114 +1307,122 @@ packages: dependency: "direct main" description: name: logger - sha256: "5076f09225f91dc49289a4ccb92df2eeea9ea01cf7c26d49b3a1f04c6a49eec1" + sha256: ba3bc83117b2b49bdd723c0ea7848e8285a0fbc597ba09203b20d329d020c24a url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "2.0.2" logging: dependency: transitive description: name: logging - sha256: "04094f2eb032cbb06c6f6e8d3607edcfcb0455e2bb6cbc010cb01171dcb64e6d" + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.0" mailer: dependency: transitive description: name: mailer - sha256: dae17f4c6a360de380fe6c3981f806fa8df69c94533bd6f347075e4f14417bf2 + sha256: "57f6dd1496699999a7bfd0aa6be0645384f477f4823e16d4321c40a434346382" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.0.1" matcher: dependency: transitive description: name: matcher - sha256: "6501fbd55da300384b768785b83e5ce66991266cec21af89ab9ae7f5ce1c4cbb" + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb url: "https://pub.dev" source: hosted - version: "0.12.15" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724 + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.8.0" media_kit: dependency: "direct main" description: name: media_kit - sha256: f19151ff1a1724ed8675f066b40e74af6d155fc859cb74487daeae2cbeff53e0 + sha256: "92c7f59e075d74471b31e703f81ccc1d7102739ebcce945b30a6417fa2f751d5" url: "https://pub.dev" source: hosted - version: "1.1.3+1" + version: "1.1.7" media_kit_libs_android_audio: - dependency: "direct main" + dependency: transitive description: name: media_kit_libs_android_audio - sha256: "767a93c44da73b7103a1fcbe2346f7211b7c44fa727f359410e690a156f630c5" + sha256: "1d9ba8814e58a12ae69014884abf96893d38e444abfb806ff38896dfe089dd15" url: "https://pub.dev" source: hosted - version: "1.3.1" - media_kit_libs_ios_audio: + version: "1.3.5" + media_kit_libs_audio: dependency: "direct main" + description: + name: media_kit_libs_audio + sha256: "9ccf9019edc220b8d0bfa1f53a91aa2cf2506ac860b4d5774c9d6bbdd49796ca" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + media_kit_libs_ios_audio: + dependency: transitive description: name: media_kit_libs_ios_audio - sha256: d643550be8ae26e2400dbae451fbaf4b19aede16646526807272702b83053322 + sha256: "78ccf04e27d6b4ba00a355578ccb39b772f00d48269a6ac3db076edf2d51934f" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.4" media_kit_libs_linux: - dependency: "direct main" + dependency: transitive description: name: media_kit_libs_linux - sha256: "838b9e8041d376873cc938872c75812989d0feb247ad93afd8dbc92bf052680a" + sha256: e186891c31daa6bedab4d74dcdb4e8adfccc7d786bfed6ad81fe24a3b3010310 url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.3" media_kit_libs_macos_audio: - dependency: "direct main" + dependency: transitive description: name: media_kit_libs_macos_audio - sha256: "408cceb6e2c8a8f332e5e97f0534ac82f260328bb3fc3464a27f1862f55a0244" + sha256: "3be21844df98f286de32808592835073cdef2c1a10078bac135da790badca950" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.4" media_kit_libs_windows_audio: - dependency: "direct main" + dependency: transitive description: name: media_kit_libs_windows_audio - sha256: "1ab55cc89ece7ebc0859ebf17b09e39a03d44c24667bdd96bd7ea3d6a2e9f69a" + sha256: c2fd558cc87b9d89a801141fcdffe02e338a3b21a41a18fbd63d5b221a1b8e53 url: "https://pub.dev" source: hosted - version: "1.0.6" + version: "1.0.9" media_kit_native_event_loop: - dependency: "direct main" + dependency: transitive description: name: media_kit_native_event_loop - sha256: e37ce6fb5fa71b8cf513c6a6cd591367743a342a385e7da621a047dd8ef6f4a4 + sha256: a605cf185499d14d58935b8784955a92a4bf0ff4e19a23de3d17a9106303930e url: "https://pub.dev" source: hosted - version: "1.0.7" + version: "1.0.8" meta: dependency: transitive description: name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.11.0" metadata_god: dependency: "direct main" description: name: metadata_god - sha256: "562c223d83a7bbf0a289ed0d5ed6a8cf8d94d673263203e9ff4930b44bd2f066" + sha256: cf13931c39eba0b9443d16e8940afdabee125bf08945f18d4c0d02bcae2a3317 url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.5.2+1" mime: dependency: "direct main" description: @@ -1174,30 +1447,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - nm: - dependency: transitive - description: - name: nm - sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" - url: "https://pub.dev" - source: hosted - version: "0.5.0" oauth2: dependency: transitive description: name: oauth2 - sha256: "1e8376c222651904caf7785fd2fa01b1e2be608c94bec842a94e116deca88f13" + sha256: c4013ef62be37744efdc0861878fd9e9285f34db1f9e331cc34100d7674feb42 url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.2" octo_image: dependency: transitive description: name: octo_image - sha256: "107f3ed1330006a3bea63615e81cf637433f5135a52466c7caa0e7152bca9143" + sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "2.0.0" package_config: dependency: transitive description: @@ -1226,18 +1491,18 @@ packages: dependency: "direct main" description: name: palette_generator - sha256: "0e3cd6974e10b1434dcf4cf779efddb80e2696585e273a2dbede6af52f94568d" + sha256: eb7082b4b97487ebc65b3ad3f6f0b7489b96e76840381ed0e06a46fe7ffd4068 url: "https://pub.dev" source: hosted - version: "0.3.3+2" + version: "0.3.3+3" path: dependency: "direct main" description: name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" url: "https://pub.dev" source: hosted - version: "1.8.3" + version: "1.9.0" path_drawing: dependency: transitive description: @@ -1258,106 +1523,98 @@ packages: dependency: "direct main" description: name: path_provider - sha256: dcea5feb97d8abf90cab9e9030b497fb7c3cbf26b7a1fe9e3ef7dcb0a1ddec95 + sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa url: "https://pub.dev" source: hosted - version: "2.0.12" + version: "2.1.1" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: a776c088d671b27f6e3aa8881d64b87b3e80201c64e8869b811325de7a76c15e + sha256: "6b8b19bd80da4f11ce91b2d1fb931f3006911477cec227cce23d3253d80df3f1" url: "https://pub.dev" source: hosted - version: "2.0.22" + version: "2.2.0" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "62a68e7e1c6c459f9289859e2fae58290c981ce21d1697faf54910fe1faa4c74" + sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.3.1" path_provider_linux: dependency: transitive description: name: path_provider_linux - sha256: ab0987bf95bc591da42dffb38c77398fc43309f0b9b894dcc5d6f40c4b26c379 + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 url: "https://pub.dev" source: hosted - version: "2.1.7" + version: "2.2.1" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: f0abc8ebd7253741f05488b4813d936b4d07c6bae3e86148a09e342ee4b08e76 + sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" url: "https://pub.dev" source: hosted - version: "2.0.5" + version: "2.1.1" path_provider_windows: dependency: transitive description: name: path_provider_windows - sha256: d3f80b32e83ec208ac95253e0cd4d298e104fbc63cb29c5c69edaed43b0c69d6 + sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" url: "https://pub.dev" source: hosted - version: "2.1.6" - pedantic: - dependency: transitive - description: - name: pedantic - sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602" - url: "https://pub.dev" - source: hosted - version: "1.11.1" + version: "2.2.1" permission_handler: dependency: "direct main" description: name: permission_handler - sha256: "33c6a1253d1f95fd06fa74b65b7ba907ae9811f9d5c1d3150e51417d04b8d6a8" + sha256: "284a66179cabdf942f838543e10413246f06424d960c92ba95c84439154fcac8" url: "https://pub.dev" source: hosted - version: "10.2.0" + version: "11.0.1" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: "8028362b40c4a45298f1cbfccd227c8dd6caf0e27088a69f2ba2ab15464159e2" + sha256: ace7d15a3d1a4a0b91c041d01e5405df221edb9de9116525efc773c74e6fc790 url: "https://pub.dev" source: hosted - version: "10.2.0" + version: "11.0.5" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: "9c370ef6a18b1c4b2f7f35944d644a56aa23576f23abee654cf73968de93f163" + sha256: "99e220bce3f8877c78e4ace901082fb29fa1b4ebde529ad0932d8d664b34f3f5" url: "https://pub.dev" source: hosted - version: "9.0.7" + version: "9.1.4" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: "68abbc472002b5e6dfce47fe9898c6b7d8328d58b5d2524f75e277c07a97eb84" + sha256: f2343e9fa9c22ae4fd92d4732755bfe452214e7189afcc097380950cf567b4b2 url: "https://pub.dev" source: hosted - version: "3.9.0" + version: "3.11.5" permission_handler_windows: dependency: transitive description: name: permission_handler_windows - sha256: f67cab14b4328574938ecea2db3475dad7af7ead6afab6338772c5f88963e38b + sha256: cc074aace208760f1eee6aa4fae766b45d947df85bc831cde77009cdb4720098 url: "https://pub.dev" source: hosted - version: "0.1.2" + version: "0.1.3" petitparser: dependency: transitive description: name: petitparser - sha256: "49392a45ced973e8d94a85fdb21293fbb40ba805fc49f2965101ae748a3683b4" + sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 url: "https://pub.dev" source: hosted - version: "5.1.0" + version: "5.4.0" piped_client: dependency: "direct main" description: @@ -1370,18 +1627,26 @@ packages: dependency: transitive description: name: platform - sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76" + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.4" plugin_platform_interface: dependency: transitive description: name: plugin_platform_interface - sha256: "6a2128648c854906c53fa8e33986fc0247a1116122f9534dd20e3ab9e16a32bc" + sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.6" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" + url: "https://pub.dev" + source: hosted + version: "3.7.3" pool: dependency: transitive description: @@ -1394,26 +1659,18 @@ packages: dependency: "direct main" description: name: popover - sha256: "4255a09e3bb64cada6aebdeeeb15453a2790802d4eecb9256ff5c895863582ea" + sha256: "59f4a55ebb484d012c8aaa273ad58eee571945231b71fb938c5a69f63b5a94d4" url: "https://pub.dev" source: hosted - version: "0.2.8+1" - postgrest: - dependency: transitive - description: - name: postgrest - sha256: "6a0f28f33af4582a9874ce15520531280d39831f356f9740839ec142fc8deeff" - url: "https://pub.dev" - source: hosted - version: "1.4.0" + version: "0.2.8+2" process: dependency: transitive description: name: process - sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" + sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" url: "https://pub.dev" source: hosted - version: "4.2.4" + version: "5.0.2" provider: dependency: transitive description: @@ -1426,18 +1683,18 @@ packages: dependency: "direct dev" description: name: pub_api_client - sha256: d4bc6c9ec778da1a79675eab41bde456b392973216acd783156afaee69230e22 + sha256: d456816ef5142906a22dc56e37be6bef6cb0276f0a26c11d1f7d277868202e71 url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.6.0" pub_semver: dependency: transitive description: name: pub_semver - sha256: "307de764d305289ff24ad257ad5c5793ce56d04947599ad68b3baa124105fc17" + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" pubspec: dependency: transitive description: @@ -1450,10 +1707,10 @@ packages: dependency: "direct dev" description: name: pubspec_parse - sha256: ec85d7d55339d85f44ec2b682a82fea340071e8978257e5a43e69f79e98ef50c + sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.2.3" puppeteer: dependency: transitive description: @@ -1470,30 +1727,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" - realtime_client: - dependency: transitive - description: - name: realtime_client - sha256: ff743de9bb0f46fcfffcfe64ae93062702dcd0f83a2ce8adc40d5fb7f542af90 - url: "https://pub.dev" - source: hosted - version: "1.1.3" - retry: - dependency: transitive - description: - name: retry - sha256: a8a1e475a100a0bdc73f529ca8ea1e9c9c76bec8ad86a1f47780139a34ce7aea - url: "https://pub.dev" - source: hosted - version: "3.1.1" riverpod: dependency: transitive description: name: riverpod - sha256: "0f43c64f1f79c2112c843305a879a746587fb7c1e388f1d4717737796756e2c4" + sha256: "494bf2cfb4df30000273d3052bdb1cc1de738574c6b678f0beb146ea56f5e208" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.4.3" rxdart: dependency: transitive description: @@ -1514,10 +1755,19 @@ packages: dependency: transitive description: name: screen_retriever - sha256: "4931f226ca158123ccd765325e9fbf360bfed0af9b460a10f960f9bb13d58323" + sha256: "6ee02c8a1158e6dae7ca430da79436e3b1c9563c8cf02f524af997c201ac2b90" url: "https://pub.dev" source: hosted - version: "0.1.6" + version: "0.1.9" + scrobblenaut: + dependency: "direct main" + description: + path: "." + ref: dart-3-support + resolved-ref: d90cb75d71737f3cfa2de4469d48080c0f3eedc2 + url: "https://github.com/KRTirtho/scrobblenaut.git" + source: git + version: "3.0.0" scroll_to_index: dependency: "direct main" description: @@ -1530,119 +1780,143 @@ packages: dependency: transitive description: name: sentry - sha256: "1c5498c8d1754dbf4fa51ca14d31c8c34ea0a0f897ff666ecd516dbd588dad6a" + sha256: "39c23342fc96105da449914f7774139a17a0ca8a4e70d9ad5200171f7e47d6ba" url: "https://pub.dev" source: hosted - version: "7.5.2" + version: "7.9.0" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: "858aaa72d8f61637d64e776aca82e1c67e6d9ee07979123c5d17115031c1b13b" + sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.2" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "8304d8a1f7d21a429f91dee552792249362b68a331ac5c3c1caf370f658873f6" + sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.1" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: cf2a42fb20148502022861f71698db12d937c7459345a1bdaa88fc91a91b3603 + sha256: "7bf53a9f2d007329ee6f3df7268fd498f8373602f943c975598bbb34649b62a7" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.4" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux - sha256: "9d387433ca65717bbf1be88f4d5bb18f10508917a8fa2fb02e0fd0d7479a9afa" + sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.2" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface - sha256: fb5cf25c0235df2d0640ac1b1174f6466bd311f621574997ac59018a6664548d + sha256: d4ec5fc9ebb2f2e056c617112aa75dcf92fc2e4faaf2ae999caa297473f75d8a url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.1" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - sha256: "74083203a8eae241e0de4a0d597dbedab3b8fef5563f33cf3c12d7e93c655ca5" + sha256: d762709c2bbe80626ecc819143013cc820fa49ca5e363620ee20a8b15a3e3daf url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.1" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows - sha256: "5e588e2efef56916a3b229c3bfe81e6a525665a454519ca51dbcc4236a274173" + sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.2" shelf: dependency: transitive description: name: shelf - sha256: c24a96135a2ccd62c64b69315a14adc5c3419df63b4d7c05832a346fdb73682c + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" shelf_static: dependency: transitive description: name: shelf_static - sha256: e792b76b96a36d4a41b819da593aff4bdd413576b3ba6150df5d8d9996d2e74c + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: a988c0e8d8ffbdb8a28aa7ec8e449c260f3deb808781fe1284d22c5bba7156e8 + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" sidebarx: dependency: "direct main" description: name: sidebarx - sha256: "26a8392ceddb659c8f2c688beba6c04bcbf520b4d5decb143c5fd7253653081f" + sha256: "7042d64844b8e64ca5c17e70d89b49df35b54a26c015b90000da9741eab70bc0" url: "https://pub.dev" source: hosted - version: "0.15.0" + version: "0.16.3" + simple_icons: + dependency: "direct main" + description: + name: simple_icons + sha256: "8aa6832dc7a263a3213e40ecbf1328a392308c809d534a3b860693625890483b" + url: "https://pub.dev" + source: hosted + version: "7.10.0" skeleton_text: dependency: "direct main" description: name: skeleton_text - sha256: "6e088723b97ddcccfcce45312ce5e385ed1e5139a57afdf574f753d51eaa77f1" + sha256: bacd536bf0664efe1cae53bcbd78c3d4040a120f300f69dc85d83f358471cc6c url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.0.1" + skeletonizer: + dependency: "direct main" + description: + name: skeletonizer + sha256: ff4c36e826efd5288d7a84e7619a6e9be8185d3064cecf101a9133762f3b401b + url: "https://pub.dev" + source: hosted + version: "0.8.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.99" + sliver_tools: + dependency: "direct main" + description: + name: sliver_tools + sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 + url: "https://pub.dev" + source: hosted + version: "0.2.12" smtc_windows: dependency: "direct main" description: name: smtc_windows - sha256: dd86d0f29b5a73ffed5650279e6abee01846017b9bc16c07c708e129648c08ac + sha256: aba2bad5ddfaf595496db04df3d9fdb54fb128fc1f39c8f024945a67455388fe url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.1.1" source_gen: dependency: transitive description: @@ -1655,50 +1929,50 @@ packages: dependency: transitive description: name: source_helper - sha256: "3b67aade1d52416149c633ba1bb36df44d97c6b51830c2198e934e3fca87ca1f" + sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" url: "https://pub.dev" source: hosted - version: "1.3.3" + version: "1.3.4" source_span: dependency: transitive description: name: source_span - sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.10.0" spotify: dependency: "direct main" description: name: spotify - sha256: c7c3f157f052143f713477bd5a764b080a0023ed084428bd0cf5a9e3bc260cc6 + sha256: e967c5e295792e9d38f4c5e9e60d7c2868ed9cb2a8fac2a67c75303f8395e374 url: "https://pub.dev" source: hosted - version: "0.11.0" + version: "0.12.0" sqflite: dependency: transitive description: name: sqflite - sha256: "78324387dc81df14f78df06019175a86a2ee0437624166c382e145d0a7fd9a4f" + sha256: "591f1602816e9c31377d5f008c2d9ef7b8aca8941c3f89cc5fd9d84da0c38a9a" url: "https://pub.dev" source: hosted - version: "2.2.4+1" + version: "2.3.0" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: bfd6973aaeeb93475bc0d875ac9aefddf7965ef22ce09790eb963992ffc5183f + sha256: "1b92f368f44b0dee2425bb861cfa17b6f6cf3961f762ff6f941d20b33355660a" url: "https://pub.dev" source: hosted - version: "2.4.2+2" + version: "2.5.0" stack_trace: dependency: transitive description: name: stack_trace - sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.11.1" state_notifier: dependency: transitive description: @@ -1707,22 +1981,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.2+1" - storage_client: - dependency: transitive - description: - name: storage_client - sha256: a3024569213b064587d616827747b766f9bc796e80cec99bd5ffb597b8aeb018 - url: "https://pub.dev" - source: hosted - version: "1.5.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" stream_transform: dependency: transitive description: @@ -1739,14 +2005,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" - supabase: + stroke_text: dependency: "direct main" description: - name: supabase - sha256: "8186f7ae39b1b27d860b9a8371801ac875a7e251a77235918a1adc3129690014" + name: stroke_text + sha256: "0ec0e526c0eae7d21ce628d78eb9ae9be634259f26b0f1735f9ed540890d8cf6" url: "https://pub.dev" source: hosted - version: "1.9.9" + version: "0.0.2" sync_http: dependency: transitive description: @@ -1767,10 +2033,10 @@ packages: dependency: "direct main" description: name: system_theme - sha256: "28bb63b997c252eee7fea6dc9e3528a9a6bf4b566ccbc8b49926389ca3e2c96b" + sha256: "1f208db140a3d1e1eac2034b54920d95699c1534df576ced44b3312c5de3975f" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.3.1" system_theme_web: dependency: transitive description: @@ -1780,7 +2046,7 @@ packages: source: hosted version: "0.0.2" system_tray: - dependency: transitive + dependency: "direct overridden" description: name: system_tray sha256: "087edb877f22f286d82d42f330fa640138c192e98aa9d20c2b83aa4e406bb432" @@ -1799,10 +2065,10 @@ packages: dependency: transitive description: name: test_api - sha256: eb6ac1540b26de412b3403a163d919ba86f6a973fe6cc50ae3541b80092fdcfb + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "0.6.1" time: dependency: transitive description: @@ -1831,26 +2097,26 @@ packages: dependency: transitive description: name: tuple - sha256: "0ea99cd2f9352b2586583ab2ce6489d1f95a5f6de6fb9492faaf97ae2060f0aa" + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.2" typed_data: dependency: transitive description: name: typed_data - sha256: "26f87ade979c47a150c9eaab93ccd2bebe70a27dc0b4b29517f2904f04eb11a5" + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.2" universal_io: dependency: transitive description: name: universal_io - sha256: "06866290206d196064fd61df4c7aea1ffe9a4e7c4ccaa8fcded42dd41948005d" + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.2" universal_platform: dependency: transitive description: @@ -1879,66 +2145,66 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: e8f2efc804810c0f2f5b485f49e7942179f56eabcfe81dce3387fec4bb55876b + sha256: "47e208a6711459d813ba18af120d9663c20bdf6985d6ad39fe165d2538378d27" url: "https://pub.dev" source: hosted - version: "6.1.9" + version: "6.1.14" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "3e2f6dfd2c7d9cd123296cab8ef66cfc2c1a13f5845f42c7a0f365690a8a7dd1" + sha256: b04af59516ab45762b2ca6da40fa830d72d0f6045cd97744450b73493fa76330 url: "https://pub.dev" source: hosted - version: "6.0.23" + version: "6.1.0" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "0a5af0aefdd8cf820dd739886efb1637f1f24489900204f50984634c07a54815" + sha256: "7c65021d5dee51813d652357bc65b8dd4a6177082a9966bc8ba6ee477baa795f" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.1.5" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "318c42cba924e18180c029be69caf0a1a710191b9ec49bb42b5998fdcccee3cc" + sha256: b651aad005e0cb06a01dbd84b428a301916dc75f0e7ea6165f80057fee2d8e8e url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.6" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "41988b55570df53b3dd2a7fc90c76756a963de6a8c5f8e113330cb35992e2094" + sha256: b55486791f666e62e0e8ff825e58a023fd6b1f71c49926483f1128d3bbd8fe88 url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.7" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface - sha256: "4eae912628763eb48fc214522e58e942fd16ce195407dbf45638239523c759a6" + sha256: "95465b39f83bfe95fcb9d174829d6476216f2d548b79c38ab2506e0458787618" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.5" url_launcher_web: dependency: transitive description: name: url_launcher_web - sha256: "44d79408ce9f07052095ef1f9a693c258d6373dc3944249374e30eff7219ccb0" + sha256: "2942294a500b4fa0b918685aff406773ba0a4cd34b7f42198742a94083020ce5" url: "https://pub.dev" source: hosted - version: "2.0.14" + version: "2.0.20" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: b6217370f8eb1fd85c8890c539f5a639a01ab209a36db82c921ebeacefc7a615 + sha256: "95fef3129dc7cfaba2bc3d5ba2e16063bb561fc6d78e63eee16162bc70029069" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.8" uuid: dependency: "direct main" description: @@ -1963,70 +2229,86 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + very_good_infinite_list: + dependency: "direct main" + description: + name: very_good_infinite_list + sha256: "6f5ad429edbce6084e1c600e56b26b1de8c6b138e8e8fc2de41b686166029aa5" + url: "https://pub.dev" + source: hosted + version: "0.7.1" visibility_detector: dependency: "direct main" description: name: visibility_detector - sha256: "15c54a459ec2c17b4705450483f3d5a2858e733aee893dcee9d75fd04814940d" + sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 url: "https://pub.dev" source: hosted - version: "0.3.3" + version: "0.4.0+2" vm_service: dependency: transitive description: name: vm_service - sha256: f6deed8ed625c52864792459709183da231ebf66ff0cf09e69b573227c377efe + sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 url: "https://pub.dev" source: hosted - version: "11.3.0" + version: "13.0.0" watcher: dependency: transitive description: name: watcher - sha256: "6a7f46926b01ce81bfc339da6a7f20afbe7733eff9846f6d6a5466aa4c6667c0" + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.1.0" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: ca49c0bc209c687b887f30527fb6a9d80040b072cc2990f34b9bec3e7663101b + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.4.0" webdriver: dependency: transitive description: name: webdriver - sha256: "3c923e918918feeb90c4c9fdf1fe39220fa4c0e8e2c0fffaded174498ef86c49" + sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" + wikipedia_api: + dependency: "direct main" + description: + name: wikipedia_api + sha256: "8bae02778c40e0c09ea237b7c1952c99a33a19ccbe31545e03c807fdc7c56ec6" + url: "https://pub.dev" + source: hosted + version: "0.1.0" win32: dependency: transitive description: name: win32 - sha256: "5a751eddf9db89b3e5f9d50c20ab8612296e4e8db69009788d6c8b060a84191c" + sha256: "9e82a402b7f3d518fb9c02d0e9ae45952df31b9bf34d77baf19da2de03fc2aaa" url: "https://pub.dev" source: hosted - version: "4.1.4" + version: "5.0.7" win32_registry: - dependency: transitive + dependency: "direct main" description: name: win32_registry - sha256: "1c52f994bdccb77103a6231ad4ea331a244dbcef5d1f37d8462f713143b0bfae" + sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.2" window_manager: dependency: "direct main" description: name: window_manager - sha256: "492806c69879f0d28e95472bbe5e8d5940ac8c6e99cc07052fe14946974555ba" + sha256: "6ee795be9124f90660ea9d05e581a466de19e1c89ee74fc4bf528f60c8600edd" url: "https://pub.dev" source: hosted - version: "0.3.1" + version: "0.3.6" window_size: dependency: "direct main" description: @@ -2040,42 +2322,34 @@ packages: dependency: transitive description: name: xdg_directories - sha256: bd512f03919aac5f1313eb8249f223bacf4927031bf60b02601f81f687689e86 + sha256: "589ada45ba9e39405c198fe34eb0f607cddb2108527e658136120892beac46d2" url: "https://pub.dev" source: hosted - version: "0.2.0+3" + version: "1.0.3" xml: dependency: transitive description: name: xml - sha256: "979ee37d622dec6365e2efa4d906c37470995871fe9ae080d967e192d88286b5" + sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84" url: "https://pub.dev" source: hosted - version: "6.2.2" + version: "6.3.0" yaml: dependency: transitive description: name: yaml - sha256: "23812a9b125b48d4007117254bca50abb6c712352927eece9e155207b1db2370" + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" url: "https://pub.dev" source: hosted - version: "3.1.1" - yet_another_json_isolate: - dependency: transitive - description: - name: yet_another_json_isolate - sha256: "86fad76026c4241a32831d6c7febd8f9bded5019e2cd36c5b148499808d8307d" - url: "https://pub.dev" - source: hosted - version: "1.1.1" + version: "3.1.2" youtube_explode_dart: dependency: "direct main" description: name: youtube_explode_dart - sha256: c5c5a7ddec7d42d341cb8e49d628c4b81618f927bbf81dbfa9c550bee39ef45d + sha256: "98fd11b51adbbca76cbdb17f560168f1d7a9835cecceea965f49eb1e5eed155c" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.2" sdks: - dart: ">=3.0.0 <4.0.0" - flutter: ">=3.10.0" + dart: ">=3.2.0 <4.0.0" + flutter: ">=3.13.0" diff --git a/pubspec.yaml b/pubspec.yaml index 92836342..d3fb5630 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.1.0+22 +version: 3.4.1+28 homepage: https://spotube.krtirtho.dev repository: https://github.com/KRTirtho/spotube @@ -19,10 +19,8 @@ dependencies: audio_session: ^0.1.13 auto_size_text: ^3.0.0 buttons_tabbar: ^1.3.6 - cached_network_image: ^3.2.2 - catcher: - git: - url: https://github.com/ThexXTURBOXx/catcher + cached_network_image: ^3.3.0 + catcher_2: 1.0.0 collection: ^1.15.0 cupertino_icons: ^1.0.5 curved_navigation_bar: ^1.0.3 @@ -33,10 +31,10 @@ dependencies: disable_battery_optimization: ^1.1.0+1 duration: ^3.0.12 envied: ^0.3.0 - file_picker: ^5.2.2 - fl_query: ^1.0.0-alpha.3 - fl_query_hooks: ^1.0.0-alpha.3 - fl_query_connectivity_plus_adapter: ^0.1.0-alpha.2 + 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 flutter: sdk: flutter @@ -51,51 +49,52 @@ dependencies: flutter_inappwebview: ^5.7.2+3 flutter_localizations: sdk: flutter - flutter_native_splash: ^2.2.19 - flutter_riverpod: ^2.1.1 - flutter_secure_storage: ^8.0.0 + flutter_native_splash: ^2.3.3 + flutter_riverpod: ^2.4.3 + flutter_secure_storage: ^9.0.0 flutter_svg: ^1.1.6 - fuzzywuzzy: ^0.2.0 - google_fonts: ^5.1.0 - go_router: ^10.0.0 + form_validator: ^2.1.1 + fuzzywuzzy: ^1.1.6 + go_router: ^13.0.1 + google_fonts: ^6.1.0 hive: ^2.2.3 hive_flutter: ^1.1.0 - hooks_riverpod: ^2.1.1 + hooks_riverpod: ^2.4.3 html: ^0.15.1 http: ^1.1.0 + image_picker: ^1.0.4 intl: ^0.18.0 introduction_screen: ^3.0.2 json_annotation: ^4.8.1 - logger: ^1.1.0 + logger: ^2.0.2 media_kit: ^1.1.3 - media_kit_native_event_loop: ^1.0.7 - media_kit_libs_android_audio: ^1.3.1 - media_kit_libs_ios_audio: ^1.1.2 - media_kit_libs_macos_audio: ^1.1.2 - media_kit_libs_windows_audio: ^1.0.6 - media_kit_libs_linux: ^1.1.0 - metadata_god: ^0.5.0 + 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: ^10.2.0 + permission_handler: ^11.0.1 piped_client: ^0.1.0 popover: ^0.2.6+3 + scrobblenaut: + git: + url: https://github.com/KRTirtho/scrobblenaut.git + ref: dart-3-support scroll_to_index: ^3.0.1 - shared_preferences: ^2.0.11 - sidebarx: ^0.15.0 - skeleton_text: ^3.0.0 - smtc_windows: ^0.1.0 - spotify: ^0.11.0 - supabase: ^1.9.9 + sidebarx: ^0.16.3 + shared_preferences: ^2.2.2 + skeleton_text: ^3.0.1 + smtc_windows: ^0.1.1 + spotify: ^0.12.0 + stroke_text: ^0.0.2 system_theme: ^2.1.0 titlebar_buttons: ^1.0.0 url_launcher: ^6.1.7 uuid: ^3.0.7 version: ^3.0.2 - visibility_detector: ^0.3.3 + visibility_detector: ^0.4.0+2 window_manager: ^0.3.1 window_size: git: @@ -103,6 +102,27 @@ dependencies: 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 + jiosaavn: ^0.1.0 + draggable_scrollbar: + git: + url: https://github.com/thielepaul/flutter-draggable-scrollbar.git + ref: cfd570035bf393de541d32e9b28808b5d7e602df + very_good_infinite_list: ^0.7.1 + gap: ^3.0.1 + sliver_tools: ^0.2.12 + dart_discord_rpc: + git: + url: https://github.com/Tommypop2/dart_discord_rpc.git + html_unescape: ^2.0.0 + wikipedia_api: ^0.1.0 + skeletonizer: ^0.8.0 + app_links: ^3.5.0 + win32_registry: ^1.1.2 + flutter_sharing_intent: ^1.1.0 + freezed_annotation: ^2.4.1 dev_dependencies: build_runner: ^2.3.2 @@ -119,10 +139,11 @@ dev_dependencies: json_serializable: ^6.6.2 pub_api_client: ^2.4.0 pubspec_parse: ^1.2.2 + freezed: ^2.4.6 dependency_overrides: http: ^1.1.0 - flutter_hooks: ^0.20.0 + system_tray: 2.0.2 flutter: generate: true @@ -132,7 +153,8 @@ flutter: - assets/tutorial/ - LICENSE -flutter_icons: +flutter_launcher_icons: + ios: true android: true image_path: "assets/spotube-logo.png" adaptive_icon_foreground: "assets/spotube-logo-foreground.jpg" diff --git a/untranslated_messages.json b/untranslated_messages.json index 0647a58b..4240c8c0 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1,42 +1,127 @@ { + "ar": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question", + "endless_playback" + ], + "bn": [ - "piped_api_down", - "piped_down_error_instructions" + "start_a_radio", + "how_to_start_radio", + "replace_queue_question", + "endless_playback" ], "ca": [ - "querying_info", - "piped_api_down", - "piped_down_error_instructions" + "start_a_radio", + "how_to_start_radio", + "replace_queue_question", + "endless_playback" ], "de": [ - "piped_api_down", - "piped_down_error_instructions" + "start_a_radio", + "how_to_start_radio", + "replace_queue_question", + "endless_playback" ], "es": [ - "piped_api_down", - "piped_down_error_instructions" + "start_a_radio", + "how_to_start_radio", + "replace_queue_question", + "endless_playback" + ], + + "fa": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question", + "endless_playback" ], "fr": [ - "piped_api_down", - "piped_down_error_instructions" + "start_a_radio", + "how_to_start_radio", + "replace_queue_question", + "endless_playback" ], "hi": [ - "piped_api_down", - "piped_down_error_instructions" + "start_a_radio", + "how_to_start_radio", + "replace_queue_question", + "endless_playback" + ], + + "it": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question", + "endless_playback" ], "ja": [ - "piped_api_down", - "piped_down_error_instructions" + "start_a_radio", + "how_to_start_radio", + "replace_queue_question", + "endless_playback" + ], + + "ne": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question", + "endless_playback" + ], + + "nl": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question", + "endless_playback" + ], + + "pl": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question", + "endless_playback" + ], + + "pt": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question", + "endless_playback" + ], + + "ru": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question", + "endless_playback" + ], + + "tr": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question", + "endless_playback" + ], + + "uk": [ + "start_a_radio", + "how_to_start_radio", + "replace_queue_question", + "endless_playback" ], "zh": [ - "piped_api_down", - "piped_down_error_instructions" + "start_a_radio", + "how_to_start_radio", + "replace_queue_question", + "endless_playback" ] } diff --git a/website/.eslintignore b/website/.eslintignore new file mode 100644 index 00000000..38972655 --- /dev/null +++ b/website/.eslintignore @@ -0,0 +1,13 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example + +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/website/.eslintrc.cjs b/website/.eslintrc.cjs new file mode 100644 index 00000000..0b757582 --- /dev/null +++ b/website/.eslintrc.cjs @@ -0,0 +1,31 @@ +/** @type { import("eslint").Linter.Config } */ +module.exports = { + root: true, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:svelte/recommended', + 'prettier' + ], + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + parserOptions: { + sourceType: 'module', + ecmaVersion: 2020, + extraFileExtensions: ['.svelte'] + }, + env: { + browser: true, + es2017: true, + node: true + }, + overrides: [ + { + files: ['*.svelte'], + parser: 'svelte-eslint-parser', + parserOptions: { + parser: '@typescript-eslint/parser' + } + } + ] +}; diff --git a/website/.eslintrc.json b/website/.eslintrc.json deleted file mode 100755 index 22c30ebe..00000000 --- a/website/.eslintrc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": [ - "next/core-web-vitals", - "prettier" - ] -} \ No newline at end of file diff --git a/website/.gitignore b/website/.gitignore old mode 100755 new mode 100644 index 737d8721..c1f6d69f --- a/website/.gitignore +++ b/website/.gitignore @@ -1,35 +1,11 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc .DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.netlify diff --git a/website/.node-version b/website/.node-version new file mode 100644 index 00000000..18c28417 --- /dev/null +++ b/website/.node-version @@ -0,0 +1 @@ +20.11.0 \ No newline at end of file diff --git a/website/.npmrc b/website/.npmrc new file mode 100644 index 00000000..b6f27f13 --- /dev/null +++ b/website/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/website/.prettierignore b/website/.prettierignore new file mode 100644 index 00000000..cc41cea9 --- /dev/null +++ b/website/.prettierignore @@ -0,0 +1,4 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/website/.prettierrc b/website/.prettierrc index 78760076..95730232 100644 --- a/website/.prettierrc +++ b/website/.prettierrc @@ -1,4 +1,8 @@ { - "singleQuote": false, - "useTabs": false -} \ No newline at end of file + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/website/.vscode/settings.json b/website/.vscode/settings.json new file mode 100644 index 00000000..e5c5673c --- /dev/null +++ b/website/.vscode/settings.json @@ -0,0 +1,120 @@ +{ + "prettier.documentSelectors": [ + "**/*.svelte" + ], + "tailwindCSS.classAttributes": [ + "class", + "accent", + "active", + "animIndeterminate", + "aspectRatio", + "background", + "badge", + "bgBackdrop", + "bgDark", + "bgDrawer", + "bgLight", + "blur", + "border", + "button", + "buttonAction", + "buttonBack", + "buttonClasses", + "buttonComplete", + "buttonDismiss", + "buttonNeutral", + "buttonNext", + "buttonPositive", + "buttonTextCancel", + "buttonTextConfirm", + "buttonTextFirst", + "buttonTextLast", + "buttonTextNext", + "buttonTextPrevious", + "buttonTextSubmit", + "caretClosed", + "caretOpen", + "chips", + "color", + "controlSeparator", + "controlVariant", + "cursor", + "display", + "element", + "fill", + "fillDark", + "fillLight", + "flex", + "flexDirection", + "gap", + "gridColumns", + "height", + "hover", + "inactive", + "indent", + "justify", + "meter", + "padding", + "position", + "regionAnchor", + "regionBackdrop", + "regionBody", + "regionCaption", + "regionCaret", + "regionCell", + "regionChildren", + "regionChipList", + "regionChipWrapper", + "regionCone", + "regionContent", + "regionControl", + "regionDefault", + "regionDrawer", + "regionFoot", + "regionFootCell", + "regionFooter", + "regionHead", + "regionHeadCell", + "regionHeader", + "regionIcon", + "regionInput", + "regionInterface", + "regionInterfaceText", + "regionLabel", + "regionLead", + "regionLegend", + "regionList", + "regionListItem", + "regionNavigation", + "regionPage", + "regionPanel", + "regionRowHeadline", + "regionRowMain", + "regionSummary", + "regionSymbol", + "regionTab", + "regionTrail", + "ring", + "rounded", + "select", + "shadow", + "slotDefault", + "slotFooter", + "slotHeader", + "slotLead", + "slotMessage", + "slotMeta", + "slotPageContent", + "slotPageFooter", + "slotPageHeader", + "slotSidebarLeft", + "slotSidebarRight", + "slotTrail", + "spacing", + "text", + "track", + "transition", + "width", + "zIndex" + ] +} \ No newline at end of file diff --git a/website/README.md b/website/README.md old mode 100755 new mode 100644 index c87e0421..5ce67661 --- a/website/README.md +++ b/website/README.md @@ -1,34 +1,38 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). +# create-svelte -## Getting Started +Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte). -First, run the development server: +## Creating a project + +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 + +# create a new project in my-app +npm 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: ```bash npm run dev -# or -yarn dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +## Building -You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. +To create a production version of your app: -[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. +```bash +npm run build +``` -The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. +You can preview the production build with `npm run preview`. -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +> 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/components/AdDetector.tsx b/website/components/AdDetector.tsx deleted file mode 100644 index 33de86ec..00000000 --- a/website/components/AdDetector.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { - Button, - CloseButton, - Heading, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, - Stack, - Text, - Tooltip, - useDisclosure, - VStack, -} from "@chakra-ui/react"; -import { FC, ReactNode, useEffect, useState } from "react"; - -const AdDetector: FC<{ children: ReactNode }> = ({ children }) => { - const [adBlockEnabled, setAdBlockEnabled] = useState(false); - const { isOpen, onOpen, onClose } = useDisclosure(); - const [joke, setJoke] = useState>({}); - - useEffect(() => { - (async () => { - const googleAdUrl = - "https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"; - try { - await fetch(new Request(googleAdUrl)); - } catch (e) { - setAdBlockEnabled(true); - setJoke( - await ( - await fetch( - "https://v2.jokeapi.dev/joke/Any?blacklistFlags=racist,sexist" - ) - ).json() - ); - onOpen(); - } - })(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( - <> - - - - Support the Creator💚 - -

- Open source developers work really hard to provide the best, - secure & efficient software experience for you & people all around - the world. Most of the time we work without any wages at all but - we need minimum support to live & these Ads Helps Us earn - the minimum wage that we need to live.{" "} - - So, please support Spotube by disabling the AdBlocker on this - page or by sponsoring or donating to our collectives directly - -

-
- - - -
-
- {!adBlockEnabled ? ( - children - ) : ( - - - setAdBlockEnabled(false)} - /> - - - Here's something interesting: - - {joke.joke ?? ( - <> -

{joke.setup}

-

{joke.delivery}

- - )} -
-
- - - Be grateful for all the favors you get. But don't let it - become a pile of debt. Try returning them as soon as you can. - You'll feel relieved - - - - Kingkor Roy Tirtho - - -
- )} - - ); -}; - -export default AdDetector; diff --git a/website/components/ArticleCard.tsx b/website/components/ArticleCard.tsx deleted file mode 100644 index 0bef6f51..00000000 --- a/website/components/ArticleCard.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { Link, Flex, Box, chakra, Image, Button, Text } from "@chakra-ui/react"; -import { BlogPost } from "pages/blog"; -import { FC } from "react"; -import NavLink from "next/link"; - -const ArticleCard: FC = ({ - metadata: { - author, - author_avatar_url, - cover_image, - tags, - title, - summary, - date, - }, - slug, -}) => { - return ( - - - - - - {tags.map((tag, i) => { - return ( - - {tag} - - ); - })} - - - {title} - - - - {summary} - - - - - - - - - {author} - - - {date} - - - - - - - - - - ); -}; - -export default ArticleCard; diff --git a/website/components/CodeBlock.tsx b/website/components/CodeBlock.tsx deleted file mode 100644 index 111a2011..00000000 --- a/website/components/CodeBlock.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { chakra } from "@chakra-ui/react"; -import { FC, ReactNode } from "react"; - -type Props = { - children: ReactNode; -}; - -export const CodeBlock: FC = ({ children }) => { - return ( - - {children} - - ); -}; diff --git a/website/components/DownloadButton.tsx b/website/components/DownloadButton.tsx deleted file mode 100644 index 1aa43e70..00000000 --- a/website/components/DownloadButton.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { - Menu, - ButtonGroup, - Button, - MenuButton, - IconButton, - MenuList, - MenuItem, - Link as Anchor, -} from "@chakra-ui/react"; -import { Platform, usePlatform } from "hooks/usePlatform"; -import React from "react"; -import { - FaApple, - FaCaretDown, - FaUbuntu, - FaLinux, - FaWindows, - FaAndroid, -} from "react-icons/fa"; -import { MdOutlineFileDownload } from "react-icons/md"; - -const baseURL = "https://github.com/KRTirtho/spotube/releases/latest/download/"; - -const DownloadLinks = Object.freeze({ - [Platform.linux]: [ - { - name: "deb", - url: baseURL + "Spotube-linux-x86_64.deb", - icon: , - }, - { - name: "tar", - url: baseURL + "Spotube-linux-x86_64.tar.xz", - icon: , - }, - { - name: "AppImage", - url: baseURL + "Spotube-linux-x86_64.AppImage", - icon: , - }, - ], - [Platform.android]: [ - { - name: "apk", - url: baseURL + "Spotube-android-all-arch.apk", - icon: , - }, - ], - [Platform.mac]: [ - { - name: "dmg", - url: baseURL + "Spotube-macos-universal.dmg", - icon: , - }, - ], - [Platform.windows]: [ - { - name: "exe", - url: baseURL + "Spotube-windows-x86_64-setup.exe", - icon: , - }, - { - name: "nupkg", - url: baseURL + "Spotube-windows-x86_64.nupkg ", - icon: , - }, - ], -}); - -const DownloadButton = () => { - const platform = usePlatform(); - - const allPlatforms = Object.entries(Platform) - .map(([, value]) => { - return DownloadLinks[value].map((s) => ({ - ...s, - name: `${value} (.${s.name})`, - })); - }) - .flat(1); - - const currentPlatform = DownloadLinks[platform][0]; - return ( - - - - } - /> - - - {allPlatforms.map(({ name, url, icon }) => { - return ( - - {name} - - ); - })} - - - ); -}; - -export default DownloadButton; diff --git a/website/components/Footer.tsx b/website/components/Footer.tsx deleted file mode 100644 index b940815d..00000000 --- a/website/components/Footer.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { Flex, chakra, Link, IconButton } from "@chakra-ui/react"; - -import { FaGithub, FaRedditAlien } from "react-icons/fa"; -import { FiTwitter } from "react-icons/fi"; - -const Footer = () => { - return ( - - - Spotube - - - - © 2022, Spotube. All rights reserved - - - - } - variant="link" - /> - } - variant="link" - /> - } - variant="link" - /> - - - ); -}; - -export default Footer; diff --git a/website/components/Navbar.tsx b/website/components/Navbar.tsx deleted file mode 100644 index 2da65d77..00000000 --- a/website/components/Navbar.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { - Box, - Button, - chakra, - CloseButton, - Flex, - Heading, - HStack, - IconButton, - Link, - useColorMode, - useColorModeValue, - useDisclosure, - VisuallyHidden, - VStack, -} from "@chakra-ui/react"; -import NavLink from "next/link"; -import { GoLightBulb } from "react-icons/go"; -import { FiGithub, FiSun } from "react-icons/fi"; -import Image from "next/legacy/image"; -import React from "react"; -import { AiOutlineMenu } from "react-icons/ai"; -import { BsHeartFill } from "react-icons/bs"; - -const Navbar = () => { - const bg = useColorModeValue("white", "gray.800"); - const mobileNav = useDisclosure(); - const { colorMode, toggleColorMode } = useColorMode(); - return ( - - - - - - - - Spotube - - - Spotube - - - - - - - - - - - - - - - - - - : } - aria-label="Dark Mode Toggle" - onClick={() => { - toggleColorMode(); - }} - /> - - } - onClick={mobileNav.onOpen} - /> - - - - - - - - - - - - - - - - - - - - ); -}; - -export default Navbar; diff --git a/website/components/UserDetailedCard.tsx b/website/components/UserDetailedCard.tsx deleted file mode 100644 index fee3bdbd..00000000 --- a/website/components/UserDetailedCard.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { - Flex, - Box, - Icon, - chakra, - Image, - HStack, - IconButton, - Link, - CircularProgress, -} from "@chakra-ui/react"; -import { MdEmail, MdLocationOn } from "react-icons/md"; -import { BsFillBriefcaseFill } from "react-icons/bs"; -import { FC } from "react"; -import { FaGithub } from "react-icons/fa"; -import { FiTwitter } from "react-icons/fi"; -import { octokit } from "configurations/ocotokit"; -import useSWR from "swr"; - -interface UserDetailedCardProps { - username: string; - emoji: string; -} - -const UserDetailedCard: FC = ({ username, emoji }) => { - const { data } = useSWR(`user-${username}}`, () => - octokit.users.getByUsername({ username }) - ); - - if (!data) { - return ; - } - - return ( - - - - - {emoji} - - {data.data.name ?? data.data.login} - - - - - - {data.data.bio} - - - {data.data.company && ( - - - - - {data.data.company} - - - )} - - {data.data.location && ( - - - - - {data.data.location} - - - )} - {data.data.email && ( - - - - {data.data.email} - - - )} - - } - variant="link" - /> - {data.data.twitter_username && ( - } - variant="link" - /> - )} - - - - ); -}; - -export default UserDetailedCard; diff --git a/website/components/special.tsx b/website/components/special.tsx deleted file mode 100644 index 93b2d565..00000000 --- a/website/components/special.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import Script from "next/script"; -import { FC, useId } from "react"; - -type AdComponent = FC<{ - slot: string; -}>; - -export const DisplayAd: AdComponent = ({ slot }) => { - const id = useId(); - return ( - <> - - - - ); -}; - -export const GridMultiplexAd: AdComponent = ({ slot }) => { - const id = useId(); - return ( - <> - - - - ); -}; - -export const InFeedAd = () => { - const id = useId(); - return ( - <> - - - - ); -}; diff --git a/website/configurations/gtag.ts b/website/configurations/gtag.ts deleted file mode 100644 index 01dcba50..00000000 --- a/website/configurations/gtag.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const GA_TRACKING_ID = process.env.NEXT_PUBLIC_GA_ID; - -// https://developers.google.com/analytics/devguides/collection/gtagjs/pages -export const pageview = (url: any) => { - (window as any).gtag("config", GA_TRACKING_ID, { - page_path: url, - }); -}; - -// https://developers.google.com/analytics/devguides/collection/gtagjs/events -export const event = ({ action, category, label, value }: any) => { - (window as any).gtag("event", action, { - event_category: category, - event_label: label, - value: value, - }); -}; diff --git a/website/configurations/ocotokit.ts b/website/configurations/ocotokit.ts deleted file mode 100644 index b50fb197..00000000 --- a/website/configurations/ocotokit.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Octokit } from "@octokit/rest"; - -export const octokit: Octokit = new Octokit(); \ No newline at end of file diff --git a/website/hooks/usePlatform.ts b/website/hooks/usePlatform.ts deleted file mode 100644 index a502e4cd..00000000 --- a/website/hooks/usePlatform.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect, useState } from "react"; -import { detectOS } from "detect-browser"; - -export enum Platform { - linux = "Linux", - windows = "Windows", - mac = "Mac", - android = "Android", -} - -export function usePlatform(): Platform { - const [platform, setPlatform] = useState(Platform.linux); - - useEffect(() => { - const detectedPlatform = detectOS(navigator.userAgent)?.toLowerCase(); - - if (!detectedPlatform) return; - - if (detectedPlatform.includes("windows")) setPlatform(Platform.windows); - else if (detectedPlatform.includes("mac")) setPlatform(Platform.mac); - else if (detectedPlatform.includes("android")) - setPlatform(Platform.android); - }, []); - - return platform; -} diff --git a/website/misc/MarkdownComponentDefs.tsx b/website/misc/MarkdownComponentDefs.tsx deleted file mode 100644 index f3c901ca..00000000 --- a/website/misc/MarkdownComponentDefs.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { - Link as Anchor, - Heading, - Text, - chakra, - Code, - HStack, - Divider, - Box, -} from "@chakra-ui/react"; -import { Options } from "react-markdown"; - -export const MarkdownComponentDefs: Options["components"] = { - a: (props: any) => , - h1: (props: any) => , - h2: (props: any) => , - h3: (props: any) => , - h4: (props: any) => , - h5: (props: any) => , - h6: (props: any) => , - p: (props: any) => , - li: (props: any) => , - code: (props) => ( - - ), - blockquote: (props) => { - return ( - - - - {props.children} - - - - ); - }, -}; diff --git a/website/next-env.d.ts b/website/next-env.d.ts deleted file mode 100755 index 4f11a03d..00000000 --- a/website/next-env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/website/next.config.js b/website/next.config.js deleted file mode 100755 index ae887958..00000000 --- a/website/next.config.js +++ /dev/null @@ -1,7 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - reactStrictMode: true, - swcMinify: true, -} - -module.exports = nextConfig diff --git a/website/package-lock.json b/website/package-lock.json new file mode 100644 index 00000000..89323983 --- /dev/null +++ b/website/package-lock.json @@ -0,0 +1,6391 @@ +{ + "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 d975154a..0f8e138a 100644 --- a/website/package.json +++ b/website/package.json @@ -1,43 +1,63 @@ { "name": "website", - "version": "0.1.0", + "version": "1.0.0", "private": true, + "type": "module", "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint" - }, - "dependencies": { - "@babel/core": "^7.22.10", - "@chakra-ui/react": "^2.8.0", - "@chakra-ui/system": "^2.6.0", - "@chakra-ui/theme-tools": "^2.1.0", - "@emotion/react": "^11.11.1", - "@emotion/styled": "^11.11.0", - "@octokit/rest": "^20.0.1", - "@types/progress": "^2.0.5", - "detect-browser": "^5.3.0", - "framer-motion": "^10", - "gray-matter": "^4.0.3", - "next": "13.4.19", - "nextjs-progressbar": "^0.0.16", - "react": "18.2.0", - "react-dom": "18.2.0", - "react-icons": "^4.10.1", - "react-markdown": "^8.0.7", - "remark-gemoji": "^7.0.1", - "remark-gfm": "^3.0.1", - "swr": "^2.2.1" + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "test": "playwright test", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --check . && eslint .", + "format": "prettier --write ." }, "devDependencies": { - "@types/node": "20.5.3", - "@types/react": "18.2.21", - "@types/react-dom": "18.2.7", - "@types/react-syntax-highlighter": "^15.5.7", - "eslint": "8.47.0", - "eslint-config-next": "13.4.19", - "eslint-config-prettier": "^9.0.0", - "typescript": "5.1.6" + "@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" + }, + "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" } -} +} \ No newline at end of file diff --git a/website/pages/_app.tsx b/website/pages/_app.tsx deleted file mode 100755 index 99163093..00000000 --- a/website/pages/_app.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import "../styles/globals.css"; -import type { AppProps } from "next/app"; -import { - ChakraProvider, - extendTheme, - withDefaultColorScheme, -} from "@chakra-ui/react"; -import Navbar from "components/Navbar"; -import { chakra } from "@chakra-ui/react"; -import { mode } from "@chakra-ui/theme-tools"; -import Head from "next/head"; -import { useRouter } from "next/router"; -// import Script from "next/script"; -// import * as gtag from "configurations/gtag"; -// import AdDetector from "components/AdDetector"; -import Footer from "components/Footer"; -import NextNProgress from "nextjs-progressbar"; - -const customTheme = extendTheme( - { - initialColorMode: 'system', - useSystemColorMode: true, - styles: { - global: (props: any) => ({ - body: { - bg: mode("white", "#171717")(props), - }, - }), - }, - colors: { - blue: { - 50: "#e6f2ff", - 100: "#e6f2ff", - 200: "#e6f2ff", - 300: "#1681bd", - 400: "#1681bd", - 500: "#3a4da5", - 600: "#2d3c7d", - 700: "#1f2b55", - 800: "#121c2e", - 900: "#080e18", - }, - components: { - Link: { - baseStyle: { - color: "blue", - }, - }, - }, - }, - }, - withDefaultColorScheme({ colorScheme: "blue" }) -); - -function MyApp({ Component, pageProps }: AppProps) { - const router = useRouter(); - // useEffect(() => { - // const handleRouteChange = (url: string) => { - // gtag.pageview(url); - // }; - // router.events.on("routeChangeComplete", handleRouteChange); - // router.events.on("hashChangeComplete", handleRouteChange); - // return () => { - // router.events.off("routeChangeComplete", handleRouteChange); - // router.events.off("hashChangeComplete", handleRouteChange); - // }; - // }, [router.events]); - - return ( - <> - {/* + +
+ {#each Object.entries(links) as link} + +
+ {#each link[1][1] as icon} + + {/each} +

+ {link[1][2]} +

+
+

{link[0]}

+
+ {/each} +
diff --git a/website/src/lib/components/markdown/layout.svelte b/website/src/lib/components/markdown/layout.svelte new file mode 100644 index 00000000..54426420 --- /dev/null +++ b/website/src/lib/components/markdown/layout.svelte @@ -0,0 +1,3 @@ +
+ +
diff --git a/website/src/lib/components/navbar/darkmode-toggle.svelte b/website/src/lib/components/navbar/darkmode-toggle.svelte new file mode 100644 index 00000000..ec4f757c --- /dev/null +++ b/website/src/lib/components/navbar/darkmode-toggle.svelte @@ -0,0 +1,32 @@ + + +
+ {#if label} + + {/if} + { + isDark.update((prev) => !prev); + }} + /> +
diff --git a/website/src/lib/components/navbar/navbar.svelte b/website/src/lib/components/navbar/navbar.svelte new file mode 100644 index 00000000..093fc09c --- /dev/null +++ b/website/src/lib/components/navbar/navbar.svelte @@ -0,0 +1,57 @@ + + +
+
+
+ +

+ + Spotube Logo + Spotube + +

+
+ + + +
+ +
diff --git a/website/src/lib/components/navdrawer/navdrawer.svelte b/website/src/lib/components/navdrawer/navdrawer.svelte new file mode 100644 index 00000000..68f53e06 --- /dev/null +++ b/website/src/lib/components/navdrawer/navdrawer.svelte @@ -0,0 +1,37 @@ + + + diff --git a/website/src/lib/index.ts b/website/src/lib/index.ts new file mode 100644 index 00000000..f0fb2ab8 --- /dev/null +++ b/website/src/lib/index.ts @@ -0,0 +1,61 @@ +import { + faAndroid, + faApple, + faDebian, + faFedora, + faOpensuse, + faUbuntu, + faWindows, + faRedhat +} from '@fortawesome/free-brands-svg-icons'; +import { type IconDefinition } from '@fortawesome/free-brands-svg-icons/index'; +import { Home, Newspaper, Download } from 'lucide-svelte'; + +export const routes: Record = { + '/': ['Home', Home], + '/blog': ['Blog', Newspaper], + '/downloads': ['Downloads', Download], + '/about': ['About', null] +}; + +const releasesUrl = 'https://github.com/KRTirtho/Spotube/releases/latest/download'; + +export const downloadLinks: Record = { + 'Android Apk': [`${releasesUrl}/Spotube-android-all-arch.apk`, [faAndroid]], + 'Windows Executable': [`${releasesUrl}/Spotube-windows-x86_64-setup.exe`, [faWindows]], + 'macOS Dmg': [`${releasesUrl}/Spotube-macos-universal.dmg`, [faApple]], + 'Ubuntu, Debian': [`${releasesUrl}/Spotube-linux-x86_64.deb`, [faUbuntu, faDebian]], + 'Fedora, Redhat, Opensuse': [ + `${releasesUrl}/Spotube-linux-x86_64.rpm`, + [faFedora, faRedhat, faOpensuse] + ], + 'iPhone Ipa': [`${releasesUrl}/Spotube-iOS.ipa`, [faApple]] +}; + +export const extendedDownloadLinks: Record = { + Android: [`${releasesUrl}/Spotube-android-all-arch.apk`, [faAndroid], 'apk'], + Windows: [`${releasesUrl}/Spotube-windows-x86_64-setup.exe`, [faWindows], 'exe'], + macOS: [`${releasesUrl}/Spotube-macos-universal.dmg`, [faApple], 'dmg'], + 'Ubuntu, Debian': [`${releasesUrl}/Spotube-linux-x86_64.deb`, [faUbuntu, faDebian], 'deb'], + 'Fedora, Redhat, Opensuse': [ + `${releasesUrl}/Spotube-linux-x86_64.rpm`, + [faFedora, faRedhat, faOpensuse], + 'rpm' + ], + iPhone: [`${releasesUrl}/Spotube-iOS.ipa`, [faApple], 'ipa'] +}; + +const nightlyReleaseUrl = 'https://github.com/KRTirtho/Spotube/releases/download/nightly'; + +export const extendedNightlyDownloadLinks: Record = { + Android: [`${nightlyReleaseUrl}/Spotube-android-all-arch.apk`, [faAndroid], 'apk'], + Windows: [`${nightlyReleaseUrl}/Spotube-windows-x86_64-setup.exe`, [faWindows], 'exe'], + macOS: [`${nightlyReleaseUrl}/Spotube-macos-universal.dmg`, [faApple], 'dmg'], + 'Ubuntu, Debian': [`${nightlyReleaseUrl}/Spotube-linux-x86_64.deb`, [faUbuntu, faDebian], 'deb'], + 'Fedora, Redhat, Opensuse': [ + `${nightlyReleaseUrl}/Spotube-linux-x86_64.rpm`, + [faFedora, faRedhat, faOpensuse], + 'rpm' + ], + iPhone: [`${nightlyReleaseUrl}/Spotube-iOS.ipa`, [faApple], 'ipa'] +}; diff --git a/website/src/lib/persisted-store.ts b/website/src/lib/persisted-store.ts new file mode 100644 index 00000000..0581fc1d --- /dev/null +++ b/website/src/lib/persisted-store.ts @@ -0,0 +1,106 @@ +import { writable as internal, type Writable } from 'svelte/store'; + +declare type Updater = (value: T) => T; +declare type StoreDict = { [key: string]: Writable }; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +interface Stores { + local: StoreDict; + session: StoreDict; +} + +const stores: Stores = { + local: {}, + session: {} +}; + +export interface Serializer { + parse(text: string): T; + stringify(object: T): string; +} + +export type StorageType = 'local' | 'session'; + +export interface Options { + serializer?: Serializer; + storage?: StorageType; + syncTabs?: boolean; + onError?: (e: unknown) => void; +} + +function getStorage(type: StorageType) { + return type === 'local' ? localStorage : sessionStorage; +} + +/** @deprecated `writable()` has been renamed to `persisted()` */ +export function writable(key: string, initialValue: T, options?: Options): Writable { + console.warn( + "writable() has been deprecated. Please use persisted() instead.\n\nchange:\n\nimport { writable } from 'svelte-persisted-store'\n\nto:\n\nimport { persisted } from 'svelte-persisted-store'" + ); + return persisted(key, initialValue, options); +} +export function persisted(key: string, initialValue: T, options?: Options): Writable { + const serializer = options?.serializer ?? JSON; + const storageType = options?.storage ?? 'local'; + const syncTabs = options?.syncTabs ?? true; + const onError = + options?.onError ?? + ((e) => + console.error(`Error when writing value from persisted store "${key}" to ${storageType}`, e)); + const browser = typeof window !== 'undefined' && typeof document !== 'undefined'; + const storage = browser ? getStorage(storageType) : null; + + function updateStorage(key: string, value: T) { + try { + storage?.setItem(key, serializer.stringify(value)); + } catch (e) { + onError(e); + } + } + + function maybeLoadInitial(): T { + const json = storage?.getItem(key); + + if (json) { + return serializer.parse(json); + } + + return initialValue; + } + + if (!stores[storageType][key]) { + const initial = maybeLoadInitial(); + const store = internal(initial, (set) => { + if (browser && storageType == 'local' && syncTabs) { + const handleStorage = (event: StorageEvent) => { + if (event.key === key) set(event.newValue ? serializer.parse(event.newValue) : null); + }; + + window.addEventListener('storage', handleStorage); + + return () => window.removeEventListener('storage', handleStorage); + } + }); + + const { subscribe, set } = store; + + stores[storageType][key] = { + set(value: T) { + set(value); + updateStorage(key, value); + }, + update(callback: Updater) { + return store.update((last) => { + const value = callback(last); + + updateStorage(key, value); + + return value; + }); + }, + subscribe + }; + } + + return stores[storageType][key]; +} diff --git a/website/src/lib/posts.ts b/website/src/lib/posts.ts new file mode 100644 index 00000000..02fa3d07 --- /dev/null +++ b/website/src/lib/posts.ts @@ -0,0 +1,41 @@ +export interface Post { + date: string; + title: string; + tags: string[]; + published: boolean; + author: string; + readingTime: { + text: string; + minutes: number; + time: number; + words: number; + }; + reading_time_text: string; + preview_html: string; + preview: string; + previewHtml: string; + slug: string | null; + path: string; +} + +export const getPosts = async () => { + // Fetch posts from local Markdown files + const posts: Post[] = await Promise.all( + Object.entries(import.meta.glob('../../posts/**/*.md')).map(async ([path, resolver]) => { + const resolved = (await resolver()) as { metadata: Post }; + const { metadata } = resolved; + const slug = path.split('/').pop()?.slice(0, -3) ?? ''; + return { ...metadata, slug }; + }) + ); + + let sortedPosts = posts.sort((a, b) => +new Date(b.date) - +new Date(a.date)); + + sortedPosts = sortedPosts.map((post) => ({ + ...post + })); + + return { + posts: sortedPosts + }; +}; diff --git a/website/src/routes/+layout.svelte b/website/src/routes/+layout.svelte new file mode 100644 index 00000000..6908b77a --- /dev/null +++ b/website/src/routes/+layout.svelte @@ -0,0 +1,71 @@ + + +
+ + {#if $drawerStore.id === 'navdrawer'} + + {/if} + + + +

+
+ diff --git a/website/src/routes/+page.svelte b/website/src/routes/+page.svelte new file mode 100644 index 00000000..8b78dbc5 --- /dev/null +++ b/website/src/routes/+page.svelte @@ -0,0 +1,110 @@ + + + + Spotube + + + + + + + + +
+
+

Spotube

+
+

+ An Open Source Spotify Client for every platform +
+ + + + +
+

+

+ And it's not + built with Electron (web technologies) +

+
+ +
+
+ + +

+ +

+ Supporters + +

+

+ We are grateful for the support of individuals and organizations who have made Spotube possible. +

+ +
+ + Open Collective + +
+ + +
diff --git a/website/src/routes/+page.ts b/website/src/routes/+page.ts new file mode 100644 index 00000000..5d50a467 --- /dev/null +++ b/website/src/routes/+page.ts @@ -0,0 +1,34 @@ +interface Member { + MemberId: number; + createdAt: string; + type: string; + role: string; + isActive: boolean; + totalAmountDonated: number; + currency?: string; + lastTransactionAt: string; + lastTransactionAmount: number; + profile: string; + name: string; + company?: string; + description?: string; + image?: string; + email?: string; + twitter?: string; + github?: string; + website?: string; + tier?: string; +} + +export const load = async () => { + const res = await fetch('https://opencollective.com/spotube/members/all.json'); + const members = (await res.json()) as Member[]; + + return { + props: { + members: members + .filter((m) => m.totalAmountDonated > 0) + .sort((a, b) => b.totalAmountDonated - a.totalAmountDonated) + } + }; +}; diff --git a/website/src/routes/about/+page.svelte b/website/src/routes/about/+page.svelte new file mode 100644 index 00000000..7f88ea46 --- /dev/null +++ b/website/src/routes/about/+page.svelte @@ -0,0 +1,22 @@ +
+

About

+ +

+ +

Author & Developer

+
+ + Author of Spotube +
+
Kingkor Roy Tirtho
+

Flutter developer

+
+
diff --git a/website/src/routes/api/posts/+server.ts b/website/src/routes/api/posts/+server.ts new file mode 100644 index 00000000..af78499b --- /dev/null +++ b/website/src/routes/api/posts/+server.ts @@ -0,0 +1,9 @@ +import { getPosts } from '$lib/posts'; +import type { RequestHandler } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; + +export const GET: RequestHandler = async () => { + const { posts } = await getPosts(); + + return json(posts); +}; diff --git a/website/src/routes/blog/+page.svelte b/website/src/routes/blog/+page.svelte new file mode 100644 index 00000000..5ee98938 --- /dev/null +++ b/website/src/routes/blog/+page.svelte @@ -0,0 +1,32 @@ + + +
+

Blog Posts

+
+ +
diff --git a/website/src/routes/blog/+page.ts b/website/src/routes/blog/+page.ts new file mode 100644 index 00000000..c48ac87f --- /dev/null +++ b/website/src/routes/blog/+page.ts @@ -0,0 +1,11 @@ +import type { Post } from '$lib/posts.js'; + +export const load = async ({ fetch }) => { + const res = await fetch(`api/posts`); + if (res.ok) { + const posts: Post[] = await res.json(); + return { posts }; + } else { + return { posts: [] }; + } +}; diff --git a/website/src/routes/blog/[slug]/+page.svelte b/website/src/routes/blog/[slug]/+page.svelte new file mode 100644 index 00000000..1931a991 --- /dev/null +++ b/website/src/routes/blog/[slug]/+page.svelte @@ -0,0 +1,26 @@ + + + + Blog | {title} + + +
+

{title}

+
+
+

{new Date(date).toDateString()}

+

{readingTime?.text ?? ''}

+ + + +
+
diff --git a/website/src/routes/blog/[slug]/+page.ts b/website/src/routes/blog/[slug]/+page.ts new file mode 100644 index 00000000..6621dbab --- /dev/null +++ b/website/src/routes/blog/[slug]/+page.ts @@ -0,0 +1,23 @@ +import type { Post } from '$lib/posts.js'; + +export const load = async ({ params }) => { + const { slug } = params; + + try { + const post = await import(`../../../../posts/${slug}.md`); + return { + Content: post.default as ConstructorOfATypedSvelteComponent, + meta: { + ...post.metadata, + slug, + path: `/blog/${slug}` + } as Post + }; + } catch (err) { + console.error('Error loading the post:', err); + return { + status: 500, + error: `Could not load the post: ${(err as Error).message || err}` + }; + } +}; diff --git a/website/src/routes/downloads/+page.svelte b/website/src/routes/downloads/+page.svelte new file mode 100644 index 00000000..50d101ee --- /dev/null +++ b/website/src/routes/downloads/+page.svelte @@ -0,0 +1,39 @@ + + +
+

+ Download + +

+

+
Spotube is available for every platform
+
+ + + +


+ +

Other Downloads

+

+
+ {#each otherDownloads as download} + +
+ +
{download[1]}
+
+
+ {/each} +
+
diff --git a/website/src/routes/downloads/nightly/+page.svelte b/website/src/routes/downloads/nightly/+page.svelte new file mode 100644 index 00000000..279e6736 --- /dev/null +++ b/website/src/routes/downloads/nightly/+page.svelte @@ -0,0 +1,33 @@ + + +
+

+ Nightly Downloads + +

+

+ +
+ + +
diff --git a/website/src/routes/downloads/older/+page.svelte b/website/src/routes/downloads/older/+page.svelte new file mode 100644 index 00000000..9f535230 --- /dev/null +++ b/website/src/routes/downloads/older/+page.svelte @@ -0,0 +1,138 @@ + + +
+
+

Older versions

+ +
+

+ +
+ {#each data.releases as release} +

+ {release.tag_name} + + ({formatDistanceToNow(release.published_at ?? new Date(), { addSuffix: true })}) + +

+
+ {#each Object.entries(groupByOS(release.assets)) as [osName, assets]} +
+
+ + {osName} +
+ +
+ {/each} +
+ + + + + Release Notes & Changelogs + + + + + + +
+ {/each} +
+
+
diff --git a/website/src/routes/downloads/older/+page.ts b/website/src/routes/downloads/older/+page.ts new file mode 100644 index 00000000..e26a329c --- /dev/null +++ b/website/src/routes/downloads/older/+page.ts @@ -0,0 +1,14 @@ +import type { PageLoad } from './$types'; +import { Octokit } from '@octokit/rest'; + +const github = new Octokit(); +export const load: PageLoad = async () => { + const { data: releases } = await github.repos.listReleases({ + owner: 'KRTirtho', + repo: 'spotube' + }); + + return { + releases + }; +}; diff --git a/website/src/routes/downloads/packages/+page.svx b/website/src/routes/downloads/packages/+page.svx new file mode 100644 index 00000000..e7da4e74 --- /dev/null +++ b/website/src/routes/downloads/packages/+page.svx @@ -0,0 +1,70 @@ +--- +title: CLI Packages Managers +author: Kingkor Roy Tirtho +--- + + + +
+

Package Managers

+ + Spotube is available in various Package Managers supported by Platform + + ## Linux + +### Flatpak + +Make sure [Flatpak](https://flatpak.org) is installed in your Linux device & Run the following command in the terminal: + +```bash +$ flatpak install com.github.KRTirtho.Spotube +``` + +### Arch User Repository (AUR) + +If you're an Arch Linux user, you can also install Spotube from AUR. +Make sure you have `yay`/`pamac`/`paru` installed in your system. And Run the Following command in the Terminal: + +```bash +$ yay -Sy spotube-bin +``` + +```bash +$ pamac install spotube-bin +``` + +```bash +$ paru -Sy spotube-bin +``` + +## Windows + +### Chocolatey + +Spotube is available in [community.chocolatey.org](https://community.chocolatey.org) repo. If you have chocolatey install in your system just run following command in an Elevated Command Prompt or PowerShell: + +```powershell +$ choco install spotube +``` + +### WinGet + +Spotube is also available in the Official Windows PackageManager WinGet. Make sure you have WinGet installed in your Windows machine and run following in a Terminal: + +```powershell +$ winget install --id KRTirtho.Spotube +``` + +### Scoop + +Spotube is also available in [Scoop](https://scoop.sh) bucket. Make sure you have Scoop installed in your Windows machine and run following in a Terminal: + +```powershell +$ scoop bucket add extras +$ scoop install spotube +``` + +
diff --git a/website/static/android-chrome-192x192.png b/website/static/android-chrome-192x192.png new file mode 100644 index 00000000..2ef2f3e7 Binary files /dev/null and b/website/static/android-chrome-192x192.png differ diff --git a/website/static/android-chrome-512x512.png b/website/static/android-chrome-512x512.png new file mode 100644 index 00000000..41bda9c4 Binary files /dev/null and b/website/static/android-chrome-512x512.png differ diff --git a/website/static/apple-touch-icon.png b/website/static/apple-touch-icon.png new file mode 100644 index 00000000..c171ce48 Binary files /dev/null and b/website/static/apple-touch-icon.png differ diff --git a/website/static/favicon-16x16.png b/website/static/favicon-16x16.png new file mode 100644 index 00000000..6c14fa32 Binary files /dev/null and b/website/static/favicon-16x16.png differ diff --git a/website/static/favicon-32x32.png b/website/static/favicon-32x32.png new file mode 100644 index 00000000..8106368f Binary files /dev/null and b/website/static/favicon-32x32.png differ diff --git a/website/static/favicon.ico b/website/static/favicon.ico new file mode 100644 index 00000000..e8e1c26b Binary files /dev/null and b/website/static/favicon.ico differ diff --git a/website/static/fonts/AbrilFatface.ttf b/website/static/fonts/AbrilFatface.ttf new file mode 100644 index 00000000..a2917114 Binary files /dev/null and b/website/static/fonts/AbrilFatface.ttf differ diff --git a/website/static/images/spotube-logo.png b/website/static/images/spotube-logo.png new file mode 100644 index 00000000..b24a8c23 Binary files /dev/null and b/website/static/images/spotube-logo.png differ diff --git a/website/public/spotube-logo.svg b/website/static/images/spotube-logo.svg similarity index 100% rename from website/public/spotube-logo.svg rename to website/static/images/spotube-logo.svg diff --git a/website/static/manifest.json b/website/static/manifest.json new file mode 100644 index 00000000..a6f8ee29 --- /dev/null +++ b/website/static/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "Spotube", + "short_name": "spotube", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} \ No newline at end of file diff --git a/website/static/robots.txt b/website/static/robots.txt new file mode 100644 index 00000000..7d329b1d --- /dev/null +++ b/website/static/robots.txt @@ -0,0 +1 @@ +User-agent: * diff --git a/website/styles/globals.css b/website/styles/globals.css deleted file mode 100755 index e5e2dcc2..00000000 --- a/website/styles/globals.css +++ /dev/null @@ -1,16 +0,0 @@ -html, -body { - padding: 0; - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, - Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; -} - -a { - color: inherit; - text-decoration: none; -} - -* { - box-sizing: border-box; -} diff --git a/website/svelte.config.js b/website/svelte.config.js new file mode 100644 index 00000000..b5717e39 --- /dev/null +++ b/website/svelte.config.js @@ -0,0 +1,55 @@ +import adapter from '@sveltejs/adapter-cloudflare'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; +import { mdsvex } from 'mdsvex'; +import readingTime from 'remark-reading-time'; +import remarkExternalLinks from 'remark-external-links'; +import slugPlugin from 'rehype-slug'; +import autolinkHeadings from 'rehype-autolink-headings'; +import relativeImages from 'mdsvex-relative-images'; +import remarkGfm from 'remark-gfm'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + extensions: ['.svelte', '.svx', '.md'], + // Consult https://kit.svelte.dev/docs/integrations#preprocessors + // for more information about preprocessors + preprocess: [ + vitePreprocess(), + mdsvex({ + extensions: ['.svx', '.md'], + highlight: {}, + layout: './src/lib/components/markdown/layout.svelte', + smartypants: { + dashes: 'oldschool' + }, + + remarkPlugins: [ + remarkGfm, + // adds a `readingTime` frontmatter attribute + readingTime(), + relativeImages, + // external links open in a new tab + [remarkExternalLinks, { target: '_blank', rel: 'noopener' }] + ], + rehypePlugins: [ + slugPlugin, + [ + autolinkHeadings, + { + behavior: 'wrap' + } + ] + ] + }) + ], + vitePlugin: { + inspector: true + }, + kit: { + // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. + // If your environment is not supported or you settled on a specific environment, switch out the adapter. + // See https://kit.svelte.dev/docs/adapters for more information about adapters. + adapter: adapter() + } +}; +export default config; diff --git a/website/tailwind.config.ts b/website/tailwind.config.ts new file mode 100644 index 00000000..77611a2e --- /dev/null +++ b/website/tailwind.config.ts @@ -0,0 +1,28 @@ +import { join } from 'path'; +import type { Config } from 'tailwindcss'; +import typography from '@tailwindcss/typography'; +import { skeleton } from '@skeletonlabs/tw-plugin'; + +export default { + darkMode: 'class', + content: [ + './src/**/*.{html,js,svelte,ts}', + join(require.resolve('@skeletonlabs/skeleton'), '../**/*.{html,js,svelte,ts}') + ], + theme: { + extend: {} + }, + plugins: [ + typography, + skeleton({ + themes: { + preset: [ + { + name: 'wintry', + enhancements: true + } + ] + } + }) + ] +} satisfies Config; diff --git a/website/tests/test.ts b/website/tests/test.ts new file mode 100644 index 00000000..5816be41 --- /dev/null +++ b/website/tests/test.ts @@ -0,0 +1,6 @@ +import { expect, test } from '@playwright/test'; + +test('index page has expected h1', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Welcome to SvelteKit' })).toBeVisible(); +}); diff --git a/website/tsconfig.json b/website/tsconfig.json old mode 100755 new mode 100644 index ace58477..8534dc99 --- a/website/tsconfig.json +++ b/website/tsconfig.json @@ -1,23 +1,18 @@ { + "extends": "./.svelte-kit/tsconfig.json", "compilerOptions": { - "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, - "skipLibCheck": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "noEmit": true, - "declaration": true, - "sourceMap": true, + "checkJs": true, "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "node", + "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "baseUrl": "." - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] -} + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} \ No newline at end of file diff --git a/website/vite.config.ts b/website/vite.config.ts new file mode 100644 index 00000000..839d4507 --- /dev/null +++ b/website/vite.config.ts @@ -0,0 +1,22 @@ +import { purgeCss } from 'vite-plugin-tailwind-purgecss'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + sveltekit(), + purgeCss({ + safelist: { + // any selectors that begin with "hljs-" will not be purged + greedy: [/^hljs-/] + } + }) + ], + server: { + fs: { + // Allow serving files from one level up to the project root + // posts, copy + allow: ['..'] + } + } +}); diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt index b2e4bd8d..4f2af69b 100644 --- a/windows/flutter/CMakeLists.txt +++ b/windows/flutter/CMakeLists.txt @@ -9,6 +9,11 @@ include(${EPHEMERAL_DIR}/generated_config.cmake) # https://github.com/flutter/flutter/issues/57146. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") @@ -91,7 +96,7 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - windows-x64 $ + ${FLUTTER_TARGET_PLATFORM} $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index b377c3c9..fcf9927e 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,8 +6,9 @@ #include "generated_plugin_registrant.h" -#include -#include +#include +#include +#include #include #include #include @@ -20,10 +21,12 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { - CatcherPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("CatcherPlugin")); - ConnectivityPlusWindowsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + DartDiscordRpcPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("DartDiscordRpcPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); LocalNotifierPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index ca8c97c9..0fe6e076 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,8 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST - catcher - connectivity_plus + app_links + dart_discord_rpc + file_selector_windows flutter_secure_storage_windows local_notifier media_kit_libs_windows_audio diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp index d5c04f23..9823151c 100644 --- a/windows/runner/win32_window.cpp +++ b/windows/runner/win32_window.cpp @@ -3,6 +3,7 @@ #include #include "resource.h" +#include "app_links/app_links_plugin_c_api.h" namespace { @@ -105,6 +106,9 @@ Win32Window::~Win32Window() { bool Win32Window::CreateAndShow(const std::wstring& title, const Point& origin, const Size& size) { + if (SendAppLinkToInstance(title)) { + return false; + } Destroy(); const wchar_t* window_class = @@ -244,3 +248,39 @@ bool Win32Window::OnCreate() { void Win32Window::OnDestroy() { // No-op; provided for subclasses. } + +// app_links +bool Win32Window::SendAppLinkToInstance(const std::wstring& title) { + // Find our exact window + HWND hwnd = ::FindWindow(kWindowClassName, title.c_str()); + + if (hwnd) { + // Dispatch new link to current window + SendAppLink(hwnd); + + // (Optional) Restore our window to front in same state + WINDOWPLACEMENT place = { sizeof(WINDOWPLACEMENT) }; + GetWindowPlacement(hwnd, &place); + + switch(place.showCmd) { + case SW_SHOWMAXIMIZED: + ShowWindow(hwnd, SW_SHOWMAXIMIZED); + break; + case SW_SHOWMINIMIZED: + ShowWindow(hwnd, SW_RESTORE); + break; + default: + ShowWindow(hwnd, SW_NORMAL); + break; + } + + SetWindowPos(0, HWND_TOP, 0, 0, 0, 0, SWP_SHOWWINDOW | SWP_NOSIZE | SWP_NOMOVE); + SetForegroundWindow(hwnd); + // END Restore + + // Window has been found, don't create another one. + return true; + } + + return false; +} \ No newline at end of file diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h index 17ba4311..1d817bd2 100644 --- a/windows/runner/win32_window.h +++ b/windows/runner/win32_window.h @@ -93,6 +93,10 @@ class Win32Window { // window handle for hosted content. HWND child_content_ = nullptr; + // Dispatches link if any. + // This method enables our app to be with a single instance too. + // This is mandatory if you want to catch further links in same app. + bool SendAppLinkToInstance(const std::wstring& title); }; #endif // RUNNER_WIN32_WINDOW_H_