diff --git a/.env.example b/.env.example index 888cbe6b..35c5d563 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,4 @@ LASTFM_API_SECRET=$LASTFM_API_SECRET RELEASE_CHANNEL=$RELEASE_CHANNEL HIDE_DONATIONS=$HIDE_DONATIONS +DISABLE_SPOTIFY_IMAGES=$DISABLE_SPOTIFY_IMAGES diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index 7f89fed4..b27ea36b 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,3 +1,3 @@ { - "flutterSdkVersion": "3.24.5" + "flutterSdkVersion": "3.29.0" } \ No newline at end of file diff --git a/.fvmrc b/.fvmrc index 679f8e11..0c75e237 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,4 +1,4 @@ { - "flutter": "3.24.5", + "flutter": "3.29.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 a9c57836..d4872798 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,7 +9,8 @@ body: attributes: label: Is there an existing issue for this? (Please read the description) description: | - PLEASE! Make sure to check if this issue is a duplicate. + 🚨 PLEASE! Make sure to check if this issue is a duplicate. 🚨 + Don't waste our time, we are working hard to make Spotube better for you. Try with multiple similar keywords, and check the closed issues too. @@ -50,7 +51,7 @@ body: value: |
Logs - + ``` ``` @@ -60,7 +61,7 @@ body: - type: input attributes: label: Operating System - description: The OS in which you used Spotube to face the issue. + description: The OS in which you used Spotube to face the issue. Use comma to separate multiple OS. placeholder: Android, Linux, macOS or Windows? Make sure to include the version too. validations: required: true @@ -96,7 +97,10 @@ body: - type: checkboxes attributes: label: Self grab - description: If you are a developer and want to work on this issue yourself, you can check this box and wait for maintainer response. Any contributions are welcome! + description: | + If you are a developer and want to work on this issue yourself, you can check this box and wait for maintainer response. Any contributions are welcome! + + This project is maintained by one person. So PRs are always welcome. This is the best way to get your issue fixed faster. options: - label: I'm ready to work on this issue! required: false diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index 23e5cc74..cf275007 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -4,7 +4,7 @@ on: pull_request: env: - FLUTTER_VERSION: 3.24.5 + FLUTTER_VERSION: 3.29.0 jobs: lint: @@ -28,7 +28,6 @@ jobs: RELEASE_CHANNEL: nightly HIDE_DONATIONS: 0 - - name: Configure repo run: | flutter pub get @@ -36,4 +35,4 @@ jobs: - name: Lint Dart files run: | - dart analyze --no-fatal-warnings \ No newline at end of file + dart analyze --no-fatal-warnings diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 3a456bda..f88e618c 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -4,7 +4,7 @@ on: inputs: version: description: Version to publish (x.x.x) - default: 3.8.3 + default: 4.0.0 required: true dry_run: description: Dry run @@ -55,7 +55,7 @@ jobs: - uses: dsaltares/fetch-gh-release-asset@master with: version: tags/v${{ inputs.version }} # mind the "v" prefix - file: spotube-linux-${{inputs.version}}-x86_64.tar.xz + file: spotube-linux-${{inputs.version}}-x86_64.tar.xz token: ${{ secrets.GITHUB_TOKEN }} - name: Update PKGBUILD versions @@ -111,7 +111,7 @@ jobs: steps: - name: Tagname (workflow dispatch) run: echo 'TAG_NAME=${{inputs.version}}' >> $GITHUB_ENV - + - uses: robinraju/release-downloader@main with: repository: KRTirtho/spotube @@ -120,11 +120,11 @@ jobs: zipBall: false out-file-path: dist fileName: "Spotube-playstore-all-arch.aab" - + - name: Create service-account.json run: | echo "${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_BASE64 }}" | base64 -d > service-account.json - + - name: Upload Android Release to Play Store if: ${{!inputs.dry_run}} uses: r0adkll/upload-google-play@v1 diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 89c2fedd..f3380d39 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -20,7 +20,8 @@ on: description: Dry run without uploading to release env: - FLUTTER_VERSION: 3.24.5 + FLUTTER_VERSION: 3.29.0 + FLUTTER_CHANNEL: master permissions: contents: write @@ -30,64 +31,72 @@ jobs: strategy: matrix: include: - - os: ubuntu-latest + - os: ubuntu-22.04 platform: linux + arch: x86 files: | dist/Spotube-linux-x86_64.deb dist/Spotube-linux-x86_64.rpm dist/spotube-linux-*-x86_64.tar.xz - - os: ubuntu-latest - platform: linux_arm + - os: ubuntu-22.04-arm + platform: linux + arch: arm64 files: | dist/Spotube-linux-aarch64.deb dist/spotube-linux-*-aarch64.tar.xz - - os: ubuntu-latest + - os: ubuntu-22.04 platform: android + arch: all files: | build/Spotube-android-all-arch.apk build/Spotube-playstore-all-arch.aab - os: windows-latest platform: windows + arch: x86 files: | dist/Spotube-windows-x86_64.nupkg dist/Spotube-windows-x86_64-setup.exe - os: macos-latest platform: ios + arch: all files: | Spotube-iOS.ipa - os: macos-14 platform: macos + arch: all files: | build/Spotube-macos-universal.dmg build/Spotube-macos-universal.pkg runs-on: ${{matrix.os}} steps: - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2.12.0 + - uses: subosito/flutter-action@v2.18.0 with: - cache: true - cache-key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.yaml') }} flutter-version: ${{ env.FLUTTER_VERSION }} + channel: ${{ env.FLUTTER_CHANNEL }} + cache: true + git-source: https://github.com/flutter/flutter.git + - name: Setup Java if: ${{matrix.platform == 'android'}} uses: actions/setup-java@v4 with: - distribution: 'zulu' - java-version: '17' - cache: 'gradle' + distribution: "zulu" + java-version: "17" + cache: "gradle" check-latest: true - - name: Set up QEMU - if: ${{matrix.platform == 'linux_arm'}} - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - if: ${{matrix.platform == 'linux_arm'}} - uses: docker/setup-buildx-action@v3 + - name: Setup Rust toolchain - if: ${{matrix.platform != 'linux_arm'}} uses: dtolnay/rust-toolchain@stable with: toolchain: stable + - name: Install Xcode + if: ${{matrix.platform == 'ios'}} + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "16.1" + - name: Install ${{matrix.platform}} dependencies run: | flutter pub get @@ -98,29 +107,17 @@ jobs: run: | echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks echo '${{ secrets.KEY_PROPERTIES }}' > android/key.properties - - - name: Unessary hosted tools - if: ${{matrix.platform == 'linux_arm'}} - uses: jlumbroso/free-disk-space@main - with: - tool-cache: false - swap-storage: false - android: true - dotnet: true - haskell: true - large-packages: true - docker-images: true - + - name: Build ${{matrix.platform}} binaries - run: dart cli/cli.dart build ${{matrix.platform}} + run: dart cli/cli.dart build --arch=${{matrix.arch}} ${{matrix.platform}} env: CHANNEL: ${{inputs.channel}} DOTENV: ${{secrets.DOTENV_RELEASE}} - - - uses: actions/upload-artifact@v3 + + - uses: actions/upload-artifact@v4 with: if-no-files-found: error - name: Spotube-Release-Binaries + name: ${{matrix.platform}}-${{matrix.arch}} path: ${{matrix.files}} - name: Debug With SSH When fails @@ -130,14 +127,13 @@ jobs: limit-access-to-actor: true upload: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 needs: - build_platform steps: - uses: actions/checkout@v4 - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: - name: Spotube-Release-Binaries path: ./Spotube-Release-Binaries - name: Install dependencies @@ -146,18 +142,19 @@ jobs: - name: Generate Checksums run: | tree . - md5sum Spotube-Release-Binaries/* >> RELEASE.md5sum - sha256sum Spotube-Release-Binaries/* >> RELEASE.sha256sum + find Spotube-Release-Binaries -type f -exec md5sum {} \; >> RELEASE.md5sum + find Spotube-Release-Binaries -type f -exec sha256sum {} \; >> RELEASE.sha256sum + sed -i 's|Spotube-Release-Binaries/.*/\([^/]*\)$|\1|' RELEASE.sha256sum RELEASE.md5sum sed -i 's|Spotube-Release-Binaries/||' RELEASE.sha256sum RELEASE.md5sum - + - name: Extract pubspec version run: | echo "PUBSPEC_VERSION=$(grep -oP 'version:\s*\K[^+]+(?=\+)' pubspec.yaml)" >> $GITHUB_ENV - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: if-no-files-found: error - name: Spotube-Release-Binaries + name: sums path: | RELEASE.md5sum RELEASE.sha256sum @@ -172,7 +169,7 @@ jobs: omitNameDuringUpdate: true omitPrereleaseDuringUpdate: true allowUpdates: true - artifacts: Spotube-Release-Binaries/*,RELEASE.sha256sum,RELEASE.md5sum + artifacts: Spotube-Release-Binaries/**/*,RELEASE.sha256sum,RELEASE.md5sum - name: Upload Release Binaries (nightly) if: ${{ !inputs.dry_run && inputs.channel == 'nightly' }} @@ -184,9 +181,15 @@ jobs: omitNameDuringUpdate: true omitPrereleaseDuringUpdate: true allowUpdates: true - artifacts: Spotube-Release-Binaries/*,RELEASE.sha256sum,RELEASE.md5sum + artifacts: Spotube-Release-Binaries/**/*,RELEASE.sha256sum,RELEASE.md5sum body: | Build Number: ${{github.run_number}} - + Nightly release includes newest features but may contain bugs It is preferred to use the stable version unless you know what you're doing + + - name: Debug With SSH When fails + if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} + uses: mxschmitt/action-tmate@v3 + with: + limit-access-to-actor: true diff --git a/.gitignore b/.gitignore index f9bd15f8..119e42e5 100644 --- a/.gitignore +++ b/.gitignore @@ -79,4 +79,7 @@ android/key.properties tm.json # FVM Version Cache -.fvm/ \ No newline at end of file +.fvm/ + +android/build +android/app/.cxx diff --git a/.vscode/launch.json b/.vscode/launch.json index 7a1e8b9b..deabf1d3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -30,6 +30,17 @@ "request": "launch", "program": "lib/main.dart", "flutterMode": "release" + }, + { + "name": "spotube (mobile) (release)", + "type": "dart", + "request": "launch", + "program": "lib/main.dart", + "flutterMode": "release", + "args": [ + "--flavor", + "dev" + ] } ], "compounds": [] diff --git a/.vscode/settings.json b/.vscode/settings.json index 11fae610..88de51a4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,6 +13,7 @@ "RGBO", "riverpod", "Scrobblenaut", + "shadcn", "skeletonizer", "songlink", "speechiness", @@ -27,5 +28,5 @@ "README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md", "*.dart": "${capture}.g.dart,${capture}.freezed.dart" }, - "dart.flutterSdkPath": ".fvm/flutter_sdk" + "dart.flutterSdkPath": ".fvm/versions/3.29.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 471d5a95..008c1870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [4.0.0](https://github.com/krtirtho/spotube/compare/v3.9.0...v4.0.0) (2025-03-07) + +## Changes + +### Bug Fixes + +- SafeArea for global bottom items not working +- SafeArea not working for bottom floating widgets +- youtube video unplayable issue and use more ytClients +- **ios**: downloads not working due to permission errors (#2180) +- remove automaticallyImplyLeading from root tabs +- **android**: back button not working and safe area issues +- duplicates in recent section +- youtube source taking too long to buffer +- youtube tracks keeps skipping despite being matched correctly +- follow artist not working #2057 +- youtube_explode_dart failing for many videos due to youtube ios client visitor data change +- piped api not working + +### Features + +- **android**: home widget support (#2148) +- rewrite entire app in shadcn-ui replacing material eww +- grid/list customizable playbutton view +- flag to hide spotify generated images with patterns +- show placeholder images where there is no item or on empty page +- pause playback when no internet connection +- implement yt-dlp for desktop and NewPipeExtractor for Android (#2316) +- custom piped & invidious instance support + ## [3.9.0](https://github.com/krtirtho/spotube/compare/v3.8.3...v3.9.0) (2024-12-08) ## Changes @@ -17,7 +47,6 @@ All notable changes to this project will be documented in this file. See [standa - add invidious audio source and fix auto skipping tracks (#2005) - track caching and cached track export support (#2117) - ## [3.8.3](https://github.com/krtirtho/spotube/compare/v3.8.2...v3.8.3) (2024-10-09) ## Changes @@ -38,7 +67,6 @@ All notable changes to this project will be documented in this file. See [standa - endless song loading issue and no playback #1925 - ## [3.8.1](https://github.com/krtirtho/spotube/compare/v3.8.0...v3.8.1) (2024-09-15) ## Changes @@ -65,7 +93,6 @@ All notable changes to this project will be documented in this file. See [standa - **desktop**: show error dialog if webview is not found on login #1871 - manually detect and define touch behavior #1763 - ## [3.8.0](https://github.com/krtirtho/spotube/compare/v3.7.1...v3.8.0) (2024-06-06) ### Features @@ -100,708 +127,672 @@ All notable changes to this project will be documented in this file. See [standa - popup menu item opacity - linux: change app id in flatpak environment - ## [3.7.1](https://github.com/krtirtho/spotube/compare/v3.7.0...v3.7.1) (2024-06-06) - ### Bug Fixes -* alternative sources not showing up for SongLink matched results ([37d002d](https://github.com/krtirtho/spotube/commit/37d002d133cacb3a34884713ac8f6637694af57c)) -* **android:** Media Controls not working above Android 14 [#1561](https://github.com/krtirtho/spotube/issues/1561) ([3394c1b](https://github.com/krtirtho/spotube/commit/3394c1b0574e44dc624b2b2f0bf32f343ae9f049)) -* browse anonymously button takes to wrong route ([73c5b30](https://github.com/krtirtho/spotube/commit/73c5b30b63a4c82bb7ad5b52bc10c5594566a800)) -* **desktop:** titlebar drag to move not working ([5f280a1](https://github.com/krtirtho/spotube/commit/5f280a19f4d5f8882ae2ff60c6cc595ded7a5a1d)) -* **desktop:** window is not centered ([47f98b9](https://github.com/krtirtho/spotube/commit/47f98b98aafab9b426733ed44cab2be8a646a98e)) -* **ios:** download not working [#1575](https://github.com/krtirtho/spotube/issues/1575) ([6591ec0](https://github.com/krtirtho/spotube/commit/6591ec0e1b441dd8fd535eace19a58c7749389ca)) -* **linux:** application window not visible after launch ([8fc44ed](https://github.com/krtirtho/spotube/commit/8fc44ed6550e8b2b804991ff82df08afb1c94ca8)) -* local track not showing up in queue ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) -* use weak match for Jiosaavn fallback to improve matching ([6cb2986](https://github.com/krtirtho/spotube/commit/6cb29868d2030b5e9312863c17e8f24889942e24)) -* **windows:** media controls not showing up [#1542](https://github.com/krtirtho/spotube/issues/1542) ([d7d864f](https://github.com/krtirtho/spotube/commit/d7d864ff2bc937675a544a7edf645c5148ec836a)) -* **windows:** revert Flutter version to 3.19.6 to avoid distortion [#1553](https://github.com/krtirtho/spotube/issues/1553) ([982cf0b](https://github.com/krtirtho/spotube/commit/982cf0bd435638fa20f17ef527fe21d031b5ffaf)) +- alternative sources not showing up for SongLink matched results ([37d002d](https://github.com/krtirtho/spotube/commit/37d002d133cacb3a34884713ac8f6637694af57c)) +- **android:** Media Controls not working above Android 14 [#1561](https://github.com/krtirtho/spotube/issues/1561) ([3394c1b](https://github.com/krtirtho/spotube/commit/3394c1b0574e44dc624b2b2f0bf32f343ae9f049)) +- browse anonymously button takes to wrong route ([73c5b30](https://github.com/krtirtho/spotube/commit/73c5b30b63a4c82bb7ad5b52bc10c5594566a800)) +- **desktop:** titlebar drag to move not working ([5f280a1](https://github.com/krtirtho/spotube/commit/5f280a19f4d5f8882ae2ff60c6cc595ded7a5a1d)) +- **desktop:** window is not centered ([47f98b9](https://github.com/krtirtho/spotube/commit/47f98b98aafab9b426733ed44cab2be8a646a98e)) +- **ios:** download not working [#1575](https://github.com/krtirtho/spotube/issues/1575) ([6591ec0](https://github.com/krtirtho/spotube/commit/6591ec0e1b441dd8fd535eace19a58c7749389ca)) +- **linux:** application window not visible after launch ([8fc44ed](https://github.com/krtirtho/spotube/commit/8fc44ed6550e8b2b804991ff82df08afb1c94ca8)) +- local track not showing up in queue ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) +- use weak match for Jiosaavn fallback to improve matching ([6cb2986](https://github.com/krtirtho/spotube/commit/6cb29868d2030b5e9312863c17e8f24889942e24)) +- **windows:** media controls not showing up [#1542](https://github.com/krtirtho/spotube/issues/1542) ([d7d864f](https://github.com/krtirtho/spotube/commit/d7d864ff2bc937675a544a7edf645c5148ec836a)) +- **windows:** revert Flutter version to 3.19.6 to avoid distortion [#1553](https://github.com/krtirtho/spotube/issues/1553) ([982cf0b](https://github.com/krtirtho/spotube/commit/982cf0bd435638fa20f17ef527fe21d031b5ffaf)) ## [3.7.0](https://github.com/krtirtho/spotube/compare/v3.6.0...v3.7.0) (2024-06-03) - ### Features -* local library folder cards ([fc5bfa0](https://github.com/krtirtho/spotube/commit/fc5bfa089ce2f46ab786565d6750564d704ee7e0)) -* Local music library ([#1479](https://github.com/krtirtho/spotube/issues/1479)) ([22caa81](https://github.com/krtirtho/spotube/commit/22caa818f4ac31626aaff6952e43512b42237d00)) -* personalized stats based on local music history ([#1522](https://github.com/krtirtho/spotube/issues/1522)) ([82307bc](https://github.com/krtirtho/spotube/commit/82307bc030035b03ab1b8d8ec7b24da19a866b12)) -* play initially available tracks of playlist/album immediately and fetch rest in background [#670](https://github.com/krtirtho/spotube/issues/670) ([02acbd9](https://github.com/krtirtho/spotube/commit/02acbd93271145dde365f6c547e0d9d902be65f1)) -* **player:** add volume slider floating label showing percentage ([#1445](https://github.com/krtirtho/spotube/issues/1445)) ([8fad225](https://github.com/krtirtho/spotube/commit/8fad2251b3536e9468e0fb193939ead98bad3bc6)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) -* **translations:** add Basque translation ([#1493](https://github.com/krtirtho/spotube/issues/1493)) ([dbc1c45](https://github.com/krtirtho/spotube/commit/dbc1c452dd53153c61589f956ea9836cea7bf2bb)) -* **translations:** add Finnish translations ([#1449](https://github.com/krtirtho/spotube/issues/1449)) ([edc997e](https://github.com/krtirtho/spotube/commit/edc997e7470ce17f60c96b8198dc8851cbf21f18)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) -* **translations:** add georgian language ([#1450](https://github.com/krtirtho/spotube/issues/1450)) ([1e7f0e1](https://github.com/krtirtho/spotube/commit/1e7f0e1fe71e0a8d86614fc884861f8791469112)) -* **translations:** add Indonesian translation ([#1426](https://github.com/krtirtho/spotube/issues/1426)) ([0280654](https://github.com/krtirtho/spotube/commit/0280654bb6bad373aee521f5a866228d2d38f038)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) -* **translations:** Improve tr locales ([#1419](https://github.com/krtirtho/spotube/issues/1419)) ([bf45681](https://github.com/krtirtho/spotube/commit/bf45681deb951c772bf6ca05e213c949c04bded1)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) -* upgrade to Flutter 3.22.0 ([71341ec](https://github.com/krtirtho/spotube/commit/71341ec0bda6ed985b43836712075b97a2cf8bac)) - +- local library folder cards ([fc5bfa0](https://github.com/krtirtho/spotube/commit/fc5bfa089ce2f46ab786565d6750564d704ee7e0)) +- Local music library ([#1479](https://github.com/krtirtho/spotube/issues/1479)) ([22caa81](https://github.com/krtirtho/spotube/commit/22caa818f4ac31626aaff6952e43512b42237d00)) +- personalized stats based on local music history ([#1522](https://github.com/krtirtho/spotube/issues/1522)) ([82307bc](https://github.com/krtirtho/spotube/commit/82307bc030035b03ab1b8d8ec7b24da19a866b12)) +- play initially available tracks of playlist/album immediately and fetch rest in background [#670](https://github.com/krtirtho/spotube/issues/670) ([02acbd9](https://github.com/krtirtho/spotube/commit/02acbd93271145dde365f6c547e0d9d902be65f1)) +- **player:** add volume slider floating label showing percentage ([#1445](https://github.com/krtirtho/spotube/issues/1445)) ([8fad225](https://github.com/krtirtho/spotube/commit/8fad2251b3536e9468e0fb193939ead98bad3bc6)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) +- **translations:** add Basque translation ([#1493](https://github.com/krtirtho/spotube/issues/1493)) ([dbc1c45](https://github.com/krtirtho/spotube/commit/dbc1c452dd53153c61589f956ea9836cea7bf2bb)) +- **translations:** add Finnish translations ([#1449](https://github.com/krtirtho/spotube/issues/1449)) ([edc997e](https://github.com/krtirtho/spotube/commit/edc997e7470ce17f60c96b8198dc8851cbf21f18)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) +- **translations:** add georgian language ([#1450](https://github.com/krtirtho/spotube/issues/1450)) ([1e7f0e1](https://github.com/krtirtho/spotube/commit/1e7f0e1fe71e0a8d86614fc884861f8791469112)) +- **translations:** add Indonesian translation ([#1426](https://github.com/krtirtho/spotube/issues/1426)) ([0280654](https://github.com/krtirtho/spotube/commit/0280654bb6bad373aee521f5a866228d2d38f038)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) +- **translations:** Improve tr locales ([#1419](https://github.com/krtirtho/spotube/issues/1419)) ([bf45681](https://github.com/krtirtho/spotube/commit/bf45681deb951c772bf6ca05e213c949c04bded1)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) +- upgrade to Flutter 3.22.0 ([71341ec](https://github.com/krtirtho/spotube/commit/71341ec0bda6ed985b43836712075b97a2cf8bac)) ### Bug Fixes -* fallback to LRCLIB when lyrics line less than 6 lines [#1461](https://github.com/krtirtho/spotube/issues/1461) ([9aea354](https://github.com/krtirtho/spotube/commit/9aea35468fa7cd176ddc8810b37b90c2d8246931)) -* **linux:** tray icon not showing [#541](https://github.com/krtirtho/spotube/issues/541) ([7ac7917](https://github.com/krtirtho/spotube/commit/7ac791757abb30f40374c169c4211916287bb3f3)) -* local track not showing up in queue ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) -* **macos:** Logs directory not created by default [#1353](https://github.com/krtirtho/spotube/issues/1353) ([4ca8939](https://github.com/krtirtho/spotube/commit/4ca893950b07f678acf7db690112c47d21e54782)) -* **playback:** skipping tracks with unplayable sources instead of falling back [#1492](https://github.com/krtirtho/spotube/issues/1492) ([c607a33](https://github.com/krtirtho/spotube/commit/c607a330ed279dfbebe8d4bd325745ac6301a58f)) -* **search:** load more button not working [#1417](https://github.com/krtirtho/spotube/issues/1417) ([7e07c2e](https://github.com/krtirtho/spotube/commit/7e07c2e1985da7ccb96b1fac2ecd703720068d26)) -* some text are garbled in different parts of the app [#1463](https://github.com/krtirtho/spotube/issues/1463) [#1505](https://github.com/krtirtho/spotube/issues/1505) ([d2683c5](https://github.com/krtirtho/spotube/commit/d2683c52d81d807be6ff72f15b8e9eb18181e211)) -* spotify friends and user profile icon (mobile) showing when not authenticated [#1410](https://github.com/krtirtho/spotube/issues/1410) ([9bccbc9](https://github.com/krtirtho/spotube/commit/9bccbc93c63dd34f6e15ff68c276976ecd1d9a33)) -* **updater:** dead link ([#1408](https://github.com/krtirtho/spotube/issues/1408)) ([6907f9c](https://github.com/krtirtho/spotube/commit/6907f9c756d8f49aadb1b23a2a1dc8bf7d658dc0)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) -* windows SSL Certificate error breaking login [#905](https://github.com/krtirtho/spotube/issues/905) ([#1474](https://github.com/krtirtho/spotube/issues/1474)) ([937a706](https://github.com/krtirtho/spotube/commit/937a706ac9c0e59943b2609e5cc398dcdbed2344)), closes [#1468](https://github.com/krtirtho/spotube/issues/1468) -* **windows:** installer tries to install in current directory ([c3c9fc5](https://github.com/krtirtho/spotube/commit/c3c9fc544c68b3d897dd7241a61cab7a199b4539)) +- fallback to LRCLIB when lyrics line less than 6 lines [#1461](https://github.com/krtirtho/spotube/issues/1461) ([9aea354](https://github.com/krtirtho/spotube/commit/9aea35468fa7cd176ddc8810b37b90c2d8246931)) +- **linux:** tray icon not showing [#541](https://github.com/krtirtho/spotube/issues/541) ([7ac7917](https://github.com/krtirtho/spotube/commit/7ac791757abb30f40374c169c4211916287bb3f3)) +- local track not showing up in queue ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) +- **macos:** Logs directory not created by default [#1353](https://github.com/krtirtho/spotube/issues/1353) ([4ca8939](https://github.com/krtirtho/spotube/commit/4ca893950b07f678acf7db690112c47d21e54782)) +- **playback:** skipping tracks with unplayable sources instead of falling back [#1492](https://github.com/krtirtho/spotube/issues/1492) ([c607a33](https://github.com/krtirtho/spotube/commit/c607a330ed279dfbebe8d4bd325745ac6301a58f)) +- **search:** load more button not working [#1417](https://github.com/krtirtho/spotube/issues/1417) ([7e07c2e](https://github.com/krtirtho/spotube/commit/7e07c2e1985da7ccb96b1fac2ecd703720068d26)) +- some text are garbled in different parts of the app [#1463](https://github.com/krtirtho/spotube/issues/1463) [#1505](https://github.com/krtirtho/spotube/issues/1505) ([d2683c5](https://github.com/krtirtho/spotube/commit/d2683c52d81d807be6ff72f15b8e9eb18181e211)) +- spotify friends and user profile icon (mobile) showing when not authenticated [#1410](https://github.com/krtirtho/spotube/issues/1410) ([9bccbc9](https://github.com/krtirtho/spotube/commit/9bccbc93c63dd34f6e15ff68c276976ecd1d9a33)) +- **updater:** dead link ([#1408](https://github.com/krtirtho/spotube/issues/1408)) ([6907f9c](https://github.com/krtirtho/spotube/commit/6907f9c756d8f49aadb1b23a2a1dc8bf7d658dc0)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) +- windows SSL Certificate error breaking login [#905](https://github.com/krtirtho/spotube/issues/905) ([#1474](https://github.com/krtirtho/spotube/issues/1474)) ([937a706](https://github.com/krtirtho/spotube/commit/937a706ac9c0e59943b2609e5cc398dcdbed2344)), closes [#1468](https://github.com/krtirtho/spotube/issues/1468) +- **windows:** installer tries to install in current directory ([c3c9fc5](https://github.com/krtirtho/spotube/commit/c3c9fc544c68b3d897dd7241a61cab7a199b4539)) ## [3.6.0](https://github.com/krtirtho/spotube/compare/v3.5.0...v3.6.0) (2024-04-15) - ### Features -* add Spotify homepage personalized recommendations ([#1402](https://github.com/krtirtho/spotube/issues/1402)) ([9e25c74](https://github.com/krtirtho/spotube/commit/9e25c742d4e43e4e10d2b48afb8e6d90288ffa11)) -* add user profile page ([39e97ee](https://github.com/krtirtho/spotube/commit/39e97eef34d87348a264843e145f31f82832d12e)) -* **android:** Filter Device To Force High Frame Rate ([#880](https://github.com/krtirtho/spotube/issues/880)) ([6e41b10](https://github.com/krtirtho/spotube/commit/6e41b106fa989adee393d3ce2535e75446ad3eea)) -* improved caching based on riverpod ([#1343](https://github.com/krtirtho/spotube/issues/1343)) ([6673e5a](https://github.com/krtirtho/spotube/commit/6673e5a8a86b9667cf9dbff9bb7c40ea6b7de771)) -* LAN connect a.k.a control remote Spotube playback and local output device selection ([#1355](https://github.com/krtirtho/spotube/issues/1355)) ([68374ef](https://github.com/krtirtho/spotube/commit/68374efd3ec556f31b937e5b96920787b54eec78)) -* **lyrics:** add LRCLIB lyrics provider as fallback ([5afe823](https://github.com/krtirtho/spotube/commit/5afe823abdb198340b55d138d8173d886a811632)) -* search history support [#1236](https://github.com/krtirtho/spotube/issues/1236) ([82b1cfa](https://github.com/krtirtho/spotube/commit/82b1cfa0d775e3958c666280943a893c9113d468)) -* **translations:** Add Czech translation ([#1401](https://github.com/krtirtho/spotube/issues/1401)) ([5a6b800](https://github.com/krtirtho/spotube/commit/5a6b80091259359bc38c4b91cd8cb496c4270fa4)) -* **translations:** add Thai Language ([#1319](https://github.com/krtirtho/spotube/issues/1319)) ([b70f250](https://github.com/krtirtho/spotube/commit/b70f250e8d5137fd990787ec9e3d058126cf14f3)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) - +- add Spotify homepage personalized recommendations ([#1402](https://github.com/krtirtho/spotube/issues/1402)) ([9e25c74](https://github.com/krtirtho/spotube/commit/9e25c742d4e43e4e10d2b48afb8e6d90288ffa11)) +- add user profile page ([39e97ee](https://github.com/krtirtho/spotube/commit/39e97eef34d87348a264843e145f31f82832d12e)) +- **android:** Filter Device To Force High Frame Rate ([#880](https://github.com/krtirtho/spotube/issues/880)) ([6e41b10](https://github.com/krtirtho/spotube/commit/6e41b106fa989adee393d3ce2535e75446ad3eea)) +- improved caching based on riverpod ([#1343](https://github.com/krtirtho/spotube/issues/1343)) ([6673e5a](https://github.com/krtirtho/spotube/commit/6673e5a8a86b9667cf9dbff9bb7c40ea6b7de771)) +- LAN connect a.k.a control remote Spotube playback and local output device selection ([#1355](https://github.com/krtirtho/spotube/issues/1355)) ([68374ef](https://github.com/krtirtho/spotube/commit/68374efd3ec556f31b937e5b96920787b54eec78)) +- **lyrics:** add LRCLIB lyrics provider as fallback ([5afe823](https://github.com/krtirtho/spotube/commit/5afe823abdb198340b55d138d8173d886a811632)) +- search history support [#1236](https://github.com/krtirtho/spotube/issues/1236) ([82b1cfa](https://github.com/krtirtho/spotube/commit/82b1cfa0d775e3958c666280943a893c9113d468)) +- **translations:** Add Czech translation ([#1401](https://github.com/krtirtho/spotube/issues/1401)) ([5a6b800](https://github.com/krtirtho/spotube/commit/5a6b80091259359bc38c4b91cd8cb496c4270fa4)) +- **translations:** add Thai Language ([#1319](https://github.com/krtirtho/spotube/issues/1319)) ([b70f250](https://github.com/krtirtho/spotube/commit/b70f250e8d5137fd990787ec9e3d058126cf14f3)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) ### Bug Fixes -* instance of Artist bug [#1362](https://github.com/krtirtho/spotube/issues/1362) ([c8dd802](https://github.com/krtirtho/spotube/commit/c8dd8025ec96bd78ed77cae35f1429aa48c16fde)) -* **playback:** sponsor block skips and stutters in same position ([0d080b7](https://github.com/krtirtho/spotube/commit/0d080b77b72529c0be5ebc27ace1c52307511f73)) +- instance of Artist bug [#1362](https://github.com/krtirtho/spotube/issues/1362) ([c8dd802](https://github.com/krtirtho/spotube/commit/c8dd8025ec96bd78ed77cae35f1429aa48c16fde)) +- **playback:** sponsor block skips and stutters in same position ([0d080b7](https://github.com/krtirtho/spotube/commit/0d080b77b72529c0be5ebc27ace1c52307511f73)) ## [3.5.0](https://github.com/krtirtho/spotube/compare/v3.4.1...v3.5.0) (2024-03-08) - ### Features -* add endless playback support [#285](https://github.com/krtirtho/spotube/issues/285) ([9dfd49c](https://github.com/krtirtho/spotube/commit/9dfd49ca04f0e915e333e205b17ac70456873f6e)) -* add getting started page ([96a2a1f](https://github.com/krtirtho/spotube/commit/96a2a1f5a622cb3c580041417d5023e37fa69716)) -* Add iOS background play support ([#1166](https://github.com/krtirtho/spotube/issues/1166)) ([095587e](https://github.com/krtirtho/spotube/commit/095587ee84f7d867c69fcf4b09ed608d63478e1e)) -* add songlink based track matching for youtube and open song link button ([9095a8c](https://github.com/krtirtho/spotube/commit/9095a8c8f849e42daabb7efcc20085cfb863c974)) -* **playlist:** show confirmation before deleting user playlist [#1222](https://github.com/krtirtho/spotube/issues/1222) ([9f92440](https://github.com/krtirtho/spotube/commit/9f9244062a39759aa0ce28d2d5f7c8fa53d73003)) -* Sort by Duration ([#1238](https://github.com/krtirtho/spotube/issues/1238)) ([6f8271f](https://github.com/krtirtho/spotube/commit/6f8271f5e9394cb4053e41dd222aa2844c34d609)) -* start radio support ([4defeef](https://github.com/krtirtho/spotube/commit/4defeefe7e5947aa00a2afb2a06577ec141cdc52)) -* **translations:** add Korean translation ([#1275](https://github.com/krtirtho/spotube/issues/1275)) ([fdea930](https://github.com/krtirtho/spotube/commit/fdea9307bbfb8f3f62cfb795bfb3ca58c38c33d9)) -* **translations:** Added Vietnamese ([#1135](https://github.com/krtirtho/spotube/issues/1135)) ([019ba86](https://github.com/krtirtho/spotube/commit/019ba865e20a8b54ea3490c01e47158eaf3a4c8d)) -* **windows:** Install Visual C++ 2015-2022 Redistributable if missing when installing ([ba69496](https://github.com/krtirtho/spotube/commit/ba69496dcc9a1b7f6ea4e104e71764a854d27f1f)) - +- add endless playback support [#285](https://github.com/krtirtho/spotube/issues/285) ([9dfd49c](https://github.com/krtirtho/spotube/commit/9dfd49ca04f0e915e333e205b17ac70456873f6e)) +- add getting started page ([96a2a1f](https://github.com/krtirtho/spotube/commit/96a2a1f5a622cb3c580041417d5023e37fa69716)) +- Add iOS background play support ([#1166](https://github.com/krtirtho/spotube/issues/1166)) ([095587e](https://github.com/krtirtho/spotube/commit/095587ee84f7d867c69fcf4b09ed608d63478e1e)) +- add songlink based track matching for youtube and open song link button ([9095a8c](https://github.com/krtirtho/spotube/commit/9095a8c8f849e42daabb7efcc20085cfb863c974)) +- **playlist:** show confirmation before deleting user playlist [#1222](https://github.com/krtirtho/spotube/issues/1222) ([9f92440](https://github.com/krtirtho/spotube/commit/9f9244062a39759aa0ce28d2d5f7c8fa53d73003)) +- Sort by Duration ([#1238](https://github.com/krtirtho/spotube/issues/1238)) ([6f8271f](https://github.com/krtirtho/spotube/commit/6f8271f5e9394cb4053e41dd222aa2844c34d609)) +- start radio support ([4defeef](https://github.com/krtirtho/spotube/commit/4defeefe7e5947aa00a2afb2a06577ec141cdc52)) +- **translations:** add Korean translation ([#1275](https://github.com/krtirtho/spotube/issues/1275)) ([fdea930](https://github.com/krtirtho/spotube/commit/fdea9307bbfb8f3f62cfb795bfb3ca58c38c33d9)) +- **translations:** Added Vietnamese ([#1135](https://github.com/krtirtho/spotube/issues/1135)) ([019ba86](https://github.com/krtirtho/spotube/commit/019ba865e20a8b54ea3490c01e47158eaf3a4c8d)) +- **windows:** Install Visual C++ 2015-2022 Redistributable if missing when installing ([ba69496](https://github.com/krtirtho/spotube/commit/ba69496dcc9a1b7f6ea4e104e71764a854d27f1f)) ### Bug Fixes -* album images are small in certain places ([ca76a39](https://github.com/krtirtho/spotube/commit/ca76a39910b1a5af91aa7882a0d33c9d71db58a2)) -* album, artist page not loading [#1282](https://github.com/krtirtho/spotube/issues/1282) ([a9a1d4c](https://github.com/krtirtho/spotube/commit/a9a1d4c9dc24aaf3181dc4090d1822ebfe755991)) -* **android:** audio issue when screen is off and broadcast audio session id ([#1221](https://github.com/krtirtho/spotube/issues/1221) & [#1247](https://github.com/krtirtho/spotube/issues/1247)) ([17105a6](https://github.com/krtirtho/spotube/commit/17105a640bf5107bd5d333b9b4d097c14a3949a2)), closes [KRTirtho/spotube#571](https://github.com/KRTirtho/spotube/issues/571) -* **android:** only ask battery optimization once [#1252](https://github.com/krtirtho/spotube/issues/1252) ([e516afb](https://github.com/krtirtho/spotube/commit/e516afb185f616471822ea745495a3d1d1281bd3)) -* **android:** pressing back button in any other tab other than home exits the app ([c3289a0](https://github.com/krtirtho/spotube/commit/c3289a0ba4e7de094a15246677ffcb940504ebde)) -* **android:** system back button in player page exits the app ([3294f65](https://github.com/krtirtho/spotube/commit/3294f657fe8a03b18d9be8974968b6508465963d)) -* cleanTitle removing feat and ft from words instead of whole words ([8612345](https://github.com/krtirtho/spotube/commit/86123456f2ff577921cf62cffca180427dfe1dd5)) -* friends list not scrollable with mouse drag ([ab08c82](https://github.com/krtirtho/spotube/commit/ab08c82c8dd501263049f3adcbd48907ba13e3a9)) -* no draggable scrollbar in playlist/album page [#1158](https://github.com/krtirtho/spotube/issues/1158) ([6f71e52](https://github.com/krtirtho/spotube/commit/6f71e52ea8a5712d2c3527f2a524af9fbb718bef)) -* non-banger songs breaking the queue if sources not found ([90f7c53](https://github.com/krtirtho/spotube/commit/90f7c531cdc8640afdbabf5a0592159715ea1e6f)) -* track loading when not found in Youtube ([e964f61](https://github.com/krtirtho/spotube/commit/e964f61d38cb303e3d3fd60c866414f57207181c)) -* **translations:** Update app_nl.arb ([#1168](https://github.com/krtirtho/spotube/issues/1168)) ([8167963](https://github.com/krtirtho/spotube/commit/8167963212eeb5dfb0b4fb2eadf81d466659a9f1)) +- album images are small in certain places ([ca76a39](https://github.com/krtirtho/spotube/commit/ca76a39910b1a5af91aa7882a0d33c9d71db58a2)) +- album, artist page not loading [#1282](https://github.com/krtirtho/spotube/issues/1282) ([a9a1d4c](https://github.com/krtirtho/spotube/commit/a9a1d4c9dc24aaf3181dc4090d1822ebfe755991)) +- **android:** audio issue when screen is off and broadcast audio session id ([#1221](https://github.com/krtirtho/spotube/issues/1221) & [#1247](https://github.com/krtirtho/spotube/issues/1247)) ([17105a6](https://github.com/krtirtho/spotube/commit/17105a640bf5107bd5d333b9b4d097c14a3949a2)), closes [KRTirtho/spotube#571](https://github.com/KRTirtho/spotube/issues/571) +- **android:** only ask battery optimization once [#1252](https://github.com/krtirtho/spotube/issues/1252) ([e516afb](https://github.com/krtirtho/spotube/commit/e516afb185f616471822ea745495a3d1d1281bd3)) +- **android:** pressing back button in any other tab other than home exits the app ([c3289a0](https://github.com/krtirtho/spotube/commit/c3289a0ba4e7de094a15246677ffcb940504ebde)) +- **android:** system back button in player page exits the app ([3294f65](https://github.com/krtirtho/spotube/commit/3294f657fe8a03b18d9be8974968b6508465963d)) +- cleanTitle removing feat and ft from words instead of whole words ([8612345](https://github.com/krtirtho/spotube/commit/86123456f2ff577921cf62cffca180427dfe1dd5)) +- friends list not scrollable with mouse drag ([ab08c82](https://github.com/krtirtho/spotube/commit/ab08c82c8dd501263049f3adcbd48907ba13e3a9)) +- no draggable scrollbar in playlist/album page [#1158](https://github.com/krtirtho/spotube/issues/1158) ([6f71e52](https://github.com/krtirtho/spotube/commit/6f71e52ea8a5712d2c3527f2a524af9fbb718bef)) +- non-banger songs breaking the queue if sources not found ([90f7c53](https://github.com/krtirtho/spotube/commit/90f7c531cdc8640afdbabf5a0592159715ea1e6f)) +- track loading when not found in Youtube ([e964f61](https://github.com/krtirtho/spotube/commit/e964f61d38cb303e3d3fd60c866414f57207181c)) +- **translations:** Update app_nl.arb ([#1168](https://github.com/krtirtho/spotube/issues/1168)) ([8167963](https://github.com/krtirtho/spotube/commit/8167963212eeb5dfb0b4fb2eadf81d466659a9f1)) ## [3.4.1](https://personal.github.com/krtirtho/spotube/compare/v3.4.0...v3.4.1) (2024-01-27) - ### Features -* add create playlist button in add playlist dialog ([2168a64](https://personal.github.com/krtirtho/spotube/commit/2168a640af3104a43139c303d78e2c2326a1bda7)) -* add spotify friends activity ([#1130](https://personal.github.com/krtirtho/spotube/issues/1130)) ([7983932](https://personal.github.com/krtirtho/spotube/commit/79839329b0970acccb0c566a31eee508adbc8557)) -* **deep-link:** add track opening page ([988a975](https://personal.github.com/krtirtho/spotube/commit/988a975bf1a675df0cfc7b17776bcec74c67f1f2)) -* haptic feedback on long press and reordering actions ([6242200](https://personal.github.com/krtirtho/spotube/commit/624220090572eb643dce37ca8ffd85d2b3f5c9df)) -* improve youtube/piped matching by suffixing "- Topic" ([8184555](https://personal.github.com/krtirtho/spotube/commit/8184555ee89fd30aaf886af9fc1d52c142fdebb0)) -* **translations:** add Nepali (नेपाली) translations ([#1111](https://personal.github.com/krtirtho/spotube/issues/1111)) ([c3ebf56](https://personal.github.com/krtirtho/spotube/commit/c3ebf56ac149b0af8815a5533fe6c386df743440)), closes [#1074](https://personal.github.com/krtirtho/spotube/issues/1074) [#1100](https://personal.github.com/krtirtho/spotube/issues/1100) - +- add create playlist button in add playlist dialog ([2168a64](https://personal.github.com/krtirtho/spotube/commit/2168a640af3104a43139c303d78e2c2326a1bda7)) +- add spotify friends activity ([#1130](https://personal.github.com/krtirtho/spotube/issues/1130)) ([7983932](https://personal.github.com/krtirtho/spotube/commit/79839329b0970acccb0c566a31eee508adbc8557)) +- **deep-link:** add track opening page ([988a975](https://personal.github.com/krtirtho/spotube/commit/988a975bf1a675df0cfc7b17776bcec74c67f1f2)) +- haptic feedback on long press and reordering actions ([6242200](https://personal.github.com/krtirtho/spotube/commit/624220090572eb643dce37ca8ffd85d2b3f5c9df)) +- improve youtube/piped matching by suffixing "- Topic" ([8184555](https://personal.github.com/krtirtho/spotube/commit/8184555ee89fd30aaf886af9fc1d52c142fdebb0)) +- **translations:** add Nepali (नेपाली) translations ([#1111](https://personal.github.com/krtirtho/spotube/issues/1111)) ([c3ebf56](https://personal.github.com/krtirtho/spotube/commit/c3ebf56ac149b0af8815a5533fe6c386df743440)), closes [#1074](https://personal.github.com/krtirtho/spotube/issues/1074) [#1100](https://personal.github.com/krtirtho/spotube/issues/1100) ### Bug Fixes -* alternative searched sources doesn't play [#1059](https://personal.github.com/krtirtho/spotube/issues/1059) ([a8e9b82](https://personal.github.com/krtirtho/spotube/commit/a8e9b824f33add8f6a83f0d147e889eb6beeb442)) -* alternative source doesn't persist on next restart [#840](https://personal.github.com/krtirtho/spotube/issues/840) ([62fde50](https://personal.github.com/krtirtho/spotube/commit/62fde50442f04f93255b5b1b1dcca23d116a13ec)) -* **android:** download failing for permission issues [#1015](https://personal.github.com/krtirtho/spotube/issues/1015) ([5509cae](https://personal.github.com/krtirtho/spotube/commit/5509cae91c8b1f5cb9fac179060f477397a4a27f)) -* artist page error [#1018](https://personal.github.com/krtirtho/spotube/issues/1018) ([8cd650b](https://personal.github.com/krtirtho/spotube/commit/8cd650b07e5f4c4c2f296bf4374e5ee67fb3eb50)) -* audio resumes after a phone call even if it was paused before [#926](https://personal.github.com/krtirtho/spotube/issues/926) ([fd1899f](https://personal.github.com/krtirtho/spotube/commit/fd1899f162395752142d7aa7320d1c39b0995070)) -* better error message for failing to find lyrics [#1085](https://personal.github.com/krtirtho/spotube/issues/1085) ([e58e18d](https://personal.github.com/krtirtho/spotube/commit/e58e18de33d7bc6fb0e4ddd7ccf6ea14472642b1)) -* Black window flash when starting the app ([#1003](https://personal.github.com/krtirtho/spotube/issues/1003)) ([02e44fc](https://personal.github.com/krtirtho/spotube/commit/02e44fc6b849a873adad382f5d46ed8caf32359f)) -* **linux:** crash after login ([0dfd401](https://personal.github.com/krtirtho/spotube/commit/0dfd40153714b7a4b83ac30f0c56830bc0c05ffd)) -* **macos:** backbutton and window button overlap and unused empty space on home ([b9417ca](https://personal.github.com/krtirtho/spotube/commit/b9417ca3575992673357230dab49e0124dd576b1)) -* **macos:** download folder unchangeable ([9d74cf5](https://personal.github.com/krtirtho/spotube/commit/9d74cf5fc250a6a143321d49b8e045519b4c2872)) -* **macos:** Respect Minimize to tray option ([#1001](https://personal.github.com/krtirtho/spotube/issues/1001)) ([69559ba](https://personal.github.com/krtirtho/spotube/commit/69559ba24285636e42b2f2231f956c31388c5cf3)) -* **macos:** system tray shows name and sidebar weird gap [#1083](https://personal.github.com/krtirtho/spotube/issues/1083) ([27057ea](https://personal.github.com/krtirtho/spotube/commit/27057ea0c8d83c9701057c18b473f1af4e4e82be)) -* releases section is empty when user doesn't follow any artists [#1104](https://personal.github.com/krtirtho/spotube/issues/1104) ([682e88e](https://personal.github.com/krtirtho/spotube/commit/682e88e0c55bc0f4708bc0b4681b129e5c61c999)) -* search page vertical scrollbar moves on horizontal scroll [#1017](https://personal.github.com/krtirtho/spotube/issues/1017) ([c203ac6](https://personal.github.com/krtirtho/spotube/commit/c203ac69ee74ba8722dae3da4b47761cd8d59c34)) -* songs doesn't play when sources with preferred audio codec is empty ([#976](https://personal.github.com/krtirtho/spotube/issues/976)) ([ba4e11a](https://personal.github.com/krtirtho/spotube/commit/ba4e11a40ab18308437a05333a46eace6f8eeb5a)) -* track index not showing after 200 ([a752cf4](https://personal.github.com/krtirtho/spotube/commit/a752cf4c978d1b05851aabb6c84c7862de551320)) -* track pad horizontal scrolling not working ([59e0e6b](https://personal.github.com/krtirtho/spotube/commit/59e0e6bb659b70831f6e0ae064100381c57f149c)) +- alternative searched sources doesn't play [#1059](https://personal.github.com/krtirtho/spotube/issues/1059) ([a8e9b82](https://personal.github.com/krtirtho/spotube/commit/a8e9b824f33add8f6a83f0d147e889eb6beeb442)) +- alternative source doesn't persist on next restart [#840](https://personal.github.com/krtirtho/spotube/issues/840) ([62fde50](https://personal.github.com/krtirtho/spotube/commit/62fde50442f04f93255b5b1b1dcca23d116a13ec)) +- **android:** download failing for permission issues [#1015](https://personal.github.com/krtirtho/spotube/issues/1015) ([5509cae](https://personal.github.com/krtirtho/spotube/commit/5509cae91c8b1f5cb9fac179060f477397a4a27f)) +- artist page error [#1018](https://personal.github.com/krtirtho/spotube/issues/1018) ([8cd650b](https://personal.github.com/krtirtho/spotube/commit/8cd650b07e5f4c4c2f296bf4374e5ee67fb3eb50)) +- audio resumes after a phone call even if it was paused before [#926](https://personal.github.com/krtirtho/spotube/issues/926) ([fd1899f](https://personal.github.com/krtirtho/spotube/commit/fd1899f162395752142d7aa7320d1c39b0995070)) +- better error message for failing to find lyrics [#1085](https://personal.github.com/krtirtho/spotube/issues/1085) ([e58e18d](https://personal.github.com/krtirtho/spotube/commit/e58e18de33d7bc6fb0e4ddd7ccf6ea14472642b1)) +- Black window flash when starting the app ([#1003](https://personal.github.com/krtirtho/spotube/issues/1003)) ([02e44fc](https://personal.github.com/krtirtho/spotube/commit/02e44fc6b849a873adad382f5d46ed8caf32359f)) +- **linux:** crash after login ([0dfd401](https://personal.github.com/krtirtho/spotube/commit/0dfd40153714b7a4b83ac30f0c56830bc0c05ffd)) +- **macos:** backbutton and window button overlap and unused empty space on home ([b9417ca](https://personal.github.com/krtirtho/spotube/commit/b9417ca3575992673357230dab49e0124dd576b1)) +- **macos:** download folder unchangeable ([9d74cf5](https://personal.github.com/krtirtho/spotube/commit/9d74cf5fc250a6a143321d49b8e045519b4c2872)) +- **macos:** Respect Minimize to tray option ([#1001](https://personal.github.com/krtirtho/spotube/issues/1001)) ([69559ba](https://personal.github.com/krtirtho/spotube/commit/69559ba24285636e42b2f2231f956c31388c5cf3)) +- **macos:** system tray shows name and sidebar weird gap [#1083](https://personal.github.com/krtirtho/spotube/issues/1083) ([27057ea](https://personal.github.com/krtirtho/spotube/commit/27057ea0c8d83c9701057c18b473f1af4e4e82be)) +- releases section is empty when user doesn't follow any artists [#1104](https://personal.github.com/krtirtho/spotube/issues/1104) ([682e88e](https://personal.github.com/krtirtho/spotube/commit/682e88e0c55bc0f4708bc0b4681b129e5c61c999)) +- search page vertical scrollbar moves on horizontal scroll [#1017](https://personal.github.com/krtirtho/spotube/issues/1017) ([c203ac6](https://personal.github.com/krtirtho/spotube/commit/c203ac69ee74ba8722dae3da4b47761cd8d59c34)) +- songs doesn't play when sources with preferred audio codec is empty ([#976](https://personal.github.com/krtirtho/spotube/issues/976)) ([ba4e11a](https://personal.github.com/krtirtho/spotube/commit/ba4e11a40ab18308437a05333a46eace6f8eeb5a)) +- track index not showing after 200 ([a752cf4](https://personal.github.com/krtirtho/spotube/commit/a752cf4c978d1b05851aabb6c84c7862de551320)) +- track pad horizontal scrolling not working ([59e0e6b](https://personal.github.com/krtirtho/spotube/commit/59e0e6bb659b70831f6e0ae064100381c57f149c)) ## [3.4.0](https://github.com/KRTirtho/spotube/compare/v3.3.0...v3.4.0) (2023-12-30) - ### Features -* Add Go to Album option in track option [#917](https://github.com/KRTirtho/spotube/issues/917) ([b0beeca](https://github.com/KRTirtho/spotube/commit/b0beeca0cbaf810fae27832cff98cfda95715050)) -* **translations:** add Italian language translations ([#818](https://github.com/KRTirtho/spotube/issues/818)) ([e4eb0e2](https://github.com/KRTirtho/spotube/commit/e4eb0e2596ade2bb5195e183f03af42742fc8486)), closes [#676](https://github.com/KRTirtho/spotube/issues/676) [#676](https://github.com/KRTirtho/spotube/issues/676) -* compact genre view in home page ([82ed5e9](https://github.com/KRTirtho/spotube/commit/82ed5e90576b57ef32e61a65015e04862ab15461)) -* Deep link support ([#950](https://github.com/KRTirtho/spotube/issues/950)) ([4050f55](https://github.com/KRTirtho/spotube/commit/4050f556400aaec5515231578512cf1a6b990110)) -* improve loading animations ([b92583d](https://github.com/KRTirtho/spotube/commit/b92583d0df7b8dee0d121cd2bb666b14c77d8c86)) -* toggle for discord rpc ([24a2294](https://github.com/KRTirtho/spotube/commit/24a2294512bb0c4aff77bc8dcad9b4de3e8b45c6)) -* **translations:** add Dutch Language ([#969](https://github.com/KRTirtho/spotube/issues/969)) ([3ad7ba6](https://github.com/KRTirtho/spotube/commit/3ad7ba66b56e93e69d2181d47029b7549ed225fc)) - +- Add Go to Album option in track option [#917](https://github.com/KRTirtho/spotube/issues/917) ([b0beeca](https://github.com/KRTirtho/spotube/commit/b0beeca0cbaf810fae27832cff98cfda95715050)) +- **translations:** add Italian language translations ([#818](https://github.com/KRTirtho/spotube/issues/818)) ([e4eb0e2](https://github.com/KRTirtho/spotube/commit/e4eb0e2596ade2bb5195e183f03af42742fc8486)), closes [#676](https://github.com/KRTirtho/spotube/issues/676) [#676](https://github.com/KRTirtho/spotube/issues/676) +- compact genre view in home page ([82ed5e9](https://github.com/KRTirtho/spotube/commit/82ed5e90576b57ef32e61a65015e04862ab15461)) +- Deep link support ([#950](https://github.com/KRTirtho/spotube/issues/950)) ([4050f55](https://github.com/KRTirtho/spotube/commit/4050f556400aaec5515231578512cf1a6b990110)) +- improve loading animations ([b92583d](https://github.com/KRTirtho/spotube/commit/b92583d0df7b8dee0d121cd2bb666b14c77d8c86)) +- toggle for discord rpc ([24a2294](https://github.com/KRTirtho/spotube/commit/24a2294512bb0c4aff77bc8dcad9b4de3e8b45c6)) +- **translations:** add Dutch Language ([#969](https://github.com/KRTirtho/spotube/issues/969)) ([3ad7ba6](https://github.com/KRTirtho/spotube/commit/3ad7ba66b56e93e69d2181d47029b7549ed225fc)) ### Bug Fixes -* add safe area in home ([9ee6067](https://github.com/KRTirtho/spotube/commit/9ee60677f6d50df7468e12dc6653ecedefa2494f)) -* amoled mode and color scheme can't be changed ([840e014](https://github.com/KRTirtho/spotube/commit/840e014f2b18f193d040baef0e0cd595088a4a84)) -* doesn't minimize to tray when system title bar close button is used [#866](https://github.com/KRTirtho/spotube/issues/866) ([bb8f250](https://github.com/KRTirtho/spotube/commit/bb8f250f5f351c1a353791b77b25b9de7586191f)) -* genre border issues ([2fb16e6](https://github.com/KRTirtho/spotube/commit/2fb16e64e9cdfca54d633cdf287b0544ecdda3b6)) -* Incorrect "Artist" label/heading on Search Results Page [#920](https://github.com/KRTirtho/spotube/issues/920) ([f86d544](https://github.com/KRTirtho/spotube/commit/f86d5449168068e338f769d7f504d2146b86dc79)) -* metadata not getting added for YouTube tracks [#916](https://github.com/KRTirtho/spotube/issues/916) and Wrong duration of downloaded tracks [#912](https://github.com/KRTirtho/spotube/issues/912) ([a7b9398](https://github.com/KRTirtho/spotube/commit/a7b9398708ede865dc2c25fb791c8e98eeff7a38)) -* Playlist refresh not working [#915](https://github.com/KRTirtho/spotube/issues/915) ([5f1df5a](https://github.com/KRTirtho/spotube/commit/5f1df5a87d8fb7980b52cf57b7b6bedea57a1269)) -* track view header title overflow and player view drag glitch ([b04d884](https://github.com/KRTirtho/spotube/commit/b04d8849e7169824ec5b980236b5d61b2629f56e)) -* wrong artist name sent while scrobbling [#958](https://github.com/KRTirtho/spotube/issues/958) ([dcbe729](https://github.com/KRTirtho/spotube/commit/dcbe7294b742d43fbff4e89ab4c4825e94421dd9)) +- add safe area in home ([9ee6067](https://github.com/KRTirtho/spotube/commit/9ee60677f6d50df7468e12dc6653ecedefa2494f)) +- amoled mode and color scheme can't be changed ([840e014](https://github.com/KRTirtho/spotube/commit/840e014f2b18f193d040baef0e0cd595088a4a84)) +- doesn't minimize to tray when system title bar close button is used [#866](https://github.com/KRTirtho/spotube/issues/866) ([bb8f250](https://github.com/KRTirtho/spotube/commit/bb8f250f5f351c1a353791b77b25b9de7586191f)) +- genre border issues ([2fb16e6](https://github.com/KRTirtho/spotube/commit/2fb16e64e9cdfca54d633cdf287b0544ecdda3b6)) +- Incorrect "Artist" label/heading on Search Results Page [#920](https://github.com/KRTirtho/spotube/issues/920) ([f86d544](https://github.com/KRTirtho/spotube/commit/f86d5449168068e338f769d7f504d2146b86dc79)) +- metadata not getting added for YouTube tracks [#916](https://github.com/KRTirtho/spotube/issues/916) and Wrong duration of downloaded tracks [#912](https://github.com/KRTirtho/spotube/issues/912) ([a7b9398](https://github.com/KRTirtho/spotube/commit/a7b9398708ede865dc2c25fb791c8e98eeff7a38)) +- Playlist refresh not working [#915](https://github.com/KRTirtho/spotube/issues/915) ([5f1df5a](https://github.com/KRTirtho/spotube/commit/5f1df5a87d8fb7980b52cf57b7b6bedea57a1269)) +- track view header title overflow and player view drag glitch ([b04d884](https://github.com/KRTirtho/spotube/commit/b04d8849e7169824ec5b980236b5d61b2629f56e)) +- wrong artist name sent while scrobbling [#958](https://github.com/KRTirtho/spotube/issues/958) ([dcbe729](https://github.com/KRTirtho/spotube/commit/dcbe7294b742d43fbff4e89ab4c4825e94421dd9)) ## [3.3.0](https://github.com/KRTirtho/spotube/compare/v3.2.0...v3.3.0) (2023-11-27) - ### Features -* Add JioSaavn as audio source ([#881](https://github.com/KRTirtho/spotube/issues/881)) ([14069cd](https://github.com/KRTirtho/spotube/commit/14069cd4fe08597c8d9aa0810270fb4c386c1d55)) -* **android:** better quick scroll/drag to scroll implementation ([2e2c44f](https://github.com/KRTirtho/spotube/commit/2e2c44f0afef69bf9bc485db97d45127a0847c8e)) -* **artist:** modularize page and add wikipedia section ([2a69886](https://github.com/KRTirtho/spotube/commit/2a698865567883271471ace9a44123bbfd8fcd2f)) -* discord RPC integration [#98](https://github.com/KRTirtho/spotube/issues/98) ([88b8785](https://github.com/KRTirtho/spotube/commit/88b8785cb86a19900f3a867b044c1ccb2fe400bb)) -* **mini_player:** show/hide lyrics [#851](https://github.com/KRTirtho/spotube/issues/851) ([dcbb156](https://github.com/KRTirtho/spotube/commit/dcbb1568337969841acc0abe0e7185ee5e4c3590)) -* paginated playlist and album page ([28a5d6b](https://github.com/KRTirtho/spotube/commit/28a5d6bb3820ab0bd4007664f73d685f6e1d2c90)) -* **translations:** add Turkish translations ([0c22469](https://github.com/KRTirtho/spotube/commit/0c22469503f32dbbf1a5d31419c1b76c699fa966)) - +- Add JioSaavn as audio source ([#881](https://github.com/KRTirtho/spotube/issues/881)) ([14069cd](https://github.com/KRTirtho/spotube/commit/14069cd4fe08597c8d9aa0810270fb4c386c1d55)) +- **android:** better quick scroll/drag to scroll implementation ([2e2c44f](https://github.com/KRTirtho/spotube/commit/2e2c44f0afef69bf9bc485db97d45127a0847c8e)) +- **artist:** modularize page and add wikipedia section ([2a69886](https://github.com/KRTirtho/spotube/commit/2a698865567883271471ace9a44123bbfd8fcd2f)) +- discord RPC integration [#98](https://github.com/KRTirtho/spotube/issues/98) ([88b8785](https://github.com/KRTirtho/spotube/commit/88b8785cb86a19900f3a867b044c1ccb2fe400bb)) +- **mini_player:** show/hide lyrics [#851](https://github.com/KRTirtho/spotube/issues/851) ([dcbb156](https://github.com/KRTirtho/spotube/commit/dcbb1568337969841acc0abe0e7185ee5e4c3590)) +- paginated playlist and album page ([28a5d6b](https://github.com/KRTirtho/spotube/commit/28a5d6bb3820ab0bd4007664f73d685f6e1d2c90)) +- **translations:** add Turkish translations ([0c22469](https://github.com/KRTirtho/spotube/commit/0c22469503f32dbbf1a5d31419c1b76c699fa966)) ### Bug Fixes -* "Add () to Playlist" option not showing in favorited playlists [#904](https://github.com/KRTirtho/spotube/issues/904) ([96021e1](https://github.com/KRTirtho/spotube/commit/96021e1a49d22bd25fd052c122f49f439c2bea43)) -* 0:00 media duration in queue after application restart [#782](https://github.com/KRTirtho/spotube/issues/782) ([83c0b49](https://github.com/KRTirtho/spotube/commit/83c0b49da962d9f3d40de9525f90f0b320e8f7b8)) -* Add to Playlist Dialog memory leak [#817](https://github.com/KRTirtho/spotube/issues/817) ([fed36ec](https://github.com/KRTirtho/spotube/commit/fed36ecdd81e8a0f8358693eff0a6233dea32e5d)) -* **album_card:** show loading state during adding track to queue/play ([5633367](https://github.com/KRTirtho/spotube/commit/5633367397812148f6d712d06e97a4f84033f968)) -* alternative track source safearea overflow [#876](https://github.com/KRTirtho/spotube/issues/876) ([7b72a90](https://github.com/KRTirtho/spotube/commit/7b72a90bc65b541cbe2e24ef2234524b522ad71d)) -* android invalid download location Download not starting or not explaining error [#720](https://github.com/KRTirtho/spotube/issues/720) ([d056dbf](https://github.com/KRTirtho/spotube/commit/d056dbf9eeef7033dbc012d0c05800063e820042)) -* changed settings are not persisting after force stop [#821](https://github.com/KRTirtho/spotube/issues/821) ([e29a38d](https://github.com/KRTirtho/spotube/commit/e29a38dfa43ddf7a38046d1d40424f01dbe62261)) -* check for unsynced lyrics and error handling for timed lyrics query ([1d77556](https://github.com/KRTirtho/spotube/commit/1d77556157d158600f29cf2ea5f26c567607dec7)) -* **genres:** lag while scrolling ([dc980b0](https://github.com/KRTirtho/spotube/commit/dc980b024edad3132e72cbb2f0087297a4b76469)) -* infinite list disappearing for a moment everytime new page is fetched ([1334a62](https://github.com/KRTirtho/spotube/commit/1334a62aaea31f97031b3ebf455e94c583f37314)) -* last track of queue keeps repeating [#718](https://github.com/KRTirtho/spotube/issues/718) ([58e5698](https://github.com/KRTirtho/spotube/commit/58e569864dddd74c3064624998dfc184046e97eb)) -* Navigating to settings, redirects to home page [#812](https://github.com/KRTirtho/spotube/issues/812) ([da04f06](https://github.com/KRTirtho/spotube/commit/da04f068f9b7effff8d50cb5714d93ea80c22b7f)) -* new releases section flickering on scroll glitch ([ee94b7c](https://github.com/KRTirtho/spotube/commit/ee94b7cbb24e0f0bc22a6d49c830d4055aa02895)) -* **playbutton_card:** annoying animation ([574406d](https://github.com/KRTirtho/spotube/commit/574406dd5fc410914b27e7fce374323696845012)) -* scrobbling not working for first track or single track ([0a6b54d](https://github.com/KRTirtho/spotube/commit/0a6b54da367345b73fe6e954f1d9368d9f9ead71)) -* settings page scrollbar position ([ee82290](https://github.com/KRTirtho/spotube/commit/ee8229020b3b03fc074b316db4b322af13b807bd)) -* shuffle doesn't move active track to top ([4956bf3](https://github.com/KRTirtho/spotube/commit/4956bf367baae39c88b5de7c6c136513a14f8ad2)) -* spotube doesn't exit properly, hangs in infinite loop [#768](https://github.com/KRTirtho/spotube/issues/768) ([353ca79](https://github.com/KRTirtho/spotube/commit/353ca79be334077c3ac27b4f64e8b4b15eca7175)) -* trim login field padding ([286ef83](https://github.com/KRTirtho/spotube/commit/286ef83e8ec516db70019398d9e3e724437a4172)) -* use CustomScrollView for personalized page ([7d05c40](https://github.com/KRTirtho/spotube/commit/7d05c40dc0d04208b059f2483c1e4de199c8b51d)) -* user_playlists layout, track tile index, ([487c2ed](https://github.com/KRTirtho/spotube/commit/487c2ed6bdc4af33006ba52532eb4eaaa261dceb)) -* **windows:** media control not working [#641](https://github.com/KRTirtho/spotube/issues/641) ([7818574](https://github.com/KRTirtho/spotube/commit/7818574356d0fb8ff567e1f6a83fd0b6f2ee7c8a)) +- "Add () to Playlist" option not showing in favorited playlists [#904](https://github.com/KRTirtho/spotube/issues/904) ([96021e1](https://github.com/KRTirtho/spotube/commit/96021e1a49d22bd25fd052c122f49f439c2bea43)) +- 0:00 media duration in queue after application restart [#782](https://github.com/KRTirtho/spotube/issues/782) ([83c0b49](https://github.com/KRTirtho/spotube/commit/83c0b49da962d9f3d40de9525f90f0b320e8f7b8)) +- Add to Playlist Dialog memory leak [#817](https://github.com/KRTirtho/spotube/issues/817) ([fed36ec](https://github.com/KRTirtho/spotube/commit/fed36ecdd81e8a0f8358693eff0a6233dea32e5d)) +- **album_card:** show loading state during adding track to queue/play ([5633367](https://github.com/KRTirtho/spotube/commit/5633367397812148f6d712d06e97a4f84033f968)) +- alternative track source safearea overflow [#876](https://github.com/KRTirtho/spotube/issues/876) ([7b72a90](https://github.com/KRTirtho/spotube/commit/7b72a90bc65b541cbe2e24ef2234524b522ad71d)) +- android invalid download location Download not starting or not explaining error [#720](https://github.com/KRTirtho/spotube/issues/720) ([d056dbf](https://github.com/KRTirtho/spotube/commit/d056dbf9eeef7033dbc012d0c05800063e820042)) +- changed settings are not persisting after force stop [#821](https://github.com/KRTirtho/spotube/issues/821) ([e29a38d](https://github.com/KRTirtho/spotube/commit/e29a38dfa43ddf7a38046d1d40424f01dbe62261)) +- check for unsynced lyrics and error handling for timed lyrics query ([1d77556](https://github.com/KRTirtho/spotube/commit/1d77556157d158600f29cf2ea5f26c567607dec7)) +- **genres:** lag while scrolling ([dc980b0](https://github.com/KRTirtho/spotube/commit/dc980b024edad3132e72cbb2f0087297a4b76469)) +- infinite list disappearing for a moment everytime new page is fetched ([1334a62](https://github.com/KRTirtho/spotube/commit/1334a62aaea31f97031b3ebf455e94c583f37314)) +- last track of queue keeps repeating [#718](https://github.com/KRTirtho/spotube/issues/718) ([58e5698](https://github.com/KRTirtho/spotube/commit/58e569864dddd74c3064624998dfc184046e97eb)) +- Navigating to settings, redirects to home page [#812](https://github.com/KRTirtho/spotube/issues/812) ([da04f06](https://github.com/KRTirtho/spotube/commit/da04f068f9b7effff8d50cb5714d93ea80c22b7f)) +- new releases section flickering on scroll glitch ([ee94b7c](https://github.com/KRTirtho/spotube/commit/ee94b7cbb24e0f0bc22a6d49c830d4055aa02895)) +- **playbutton_card:** annoying animation ([574406d](https://github.com/KRTirtho/spotube/commit/574406dd5fc410914b27e7fce374323696845012)) +- scrobbling not working for first track or single track ([0a6b54d](https://github.com/KRTirtho/spotube/commit/0a6b54da367345b73fe6e954f1d9368d9f9ead71)) +- settings page scrollbar position ([ee82290](https://github.com/KRTirtho/spotube/commit/ee8229020b3b03fc074b316db4b322af13b807bd)) +- shuffle doesn't move active track to top ([4956bf3](https://github.com/KRTirtho/spotube/commit/4956bf367baae39c88b5de7c6c136513a14f8ad2)) +- spotube doesn't exit properly, hangs in infinite loop [#768](https://github.com/KRTirtho/spotube/issues/768) ([353ca79](https://github.com/KRTirtho/spotube/commit/353ca79be334077c3ac27b4f64e8b4b15eca7175)) +- trim login field padding ([286ef83](https://github.com/KRTirtho/spotube/commit/286ef83e8ec516db70019398d9e3e724437a4172)) +- use CustomScrollView for personalized page ([7d05c40](https://github.com/KRTirtho/spotube/commit/7d05c40dc0d04208b059f2483c1e4de199c8b51d)) +- user_playlists layout, track tile index, ([487c2ed](https://github.com/KRTirtho/spotube/commit/487c2ed6bdc4af33006ba52532eb4eaaa261dceb)) +- **windows:** media control not working [#641](https://github.com/KRTirtho/spotube/issues/641) ([7818574](https://github.com/KRTirtho/spotube/commit/7818574356d0fb8ff567e1f6a83fd0b6f2ee7c8a)) ## [3.2.0](https://github.com/KRTirtho/spotube/compare/v3.1.2...v3.2.0) (2023-10-16) - ### Features -* ability to select/copy lyrics [#802](https://github.com/KRTirtho/spotube/issues/802) ([0eb9ee8](https://github.com/KRTirtho/spotube/commit/0eb9ee8648bee43a8009e6752674b1be646c0916)) -* add Amoled theme [#724](https://github.com/KRTirtho/spotube/issues/724) ([5c5dbf6](https://github.com/KRTirtho/spotube/commit/5c5dbf69ecea95c92d3c3900ad690a500d75b4e2)) -* add audio normalization [#164](https://github.com/KRTirtho/spotube/issues/164) ([da10ab2](https://github.com/KRTirtho/spotube/commit/da10ab2e291d4ba4d3082b9a6ae535639fb8f1b7)) -* add restore default settings button ([94c3866](https://github.com/KRTirtho/spotube/commit/94c386638f2e5a42d21c8f157835443333ee6d5c)) -* configurable audio normalization switch ([c325911](https://github.com/KRTirtho/spotube/commit/c325911c0d87758a203a52df02179c1513bad3fd)) -* customizable stream/download file formats ([#757](https://github.com/KRTirtho/spotube/issues/757)) ([e54762b](https://github.com/KRTirtho/spotube/commit/e54762be6add6524ab614d103fc3557a101c75f4)) -* improve and unify the logging framework ([#738](https://github.com/KRTirtho/spotube/issues/738)) ([c7432bb](https://github.com/KRTirtho/spotube/commit/c7432bbd986d576a93957f0a22bdbca5c1e87f20)) -* LastFM scrobbling support ([#761](https://github.com/KRTirtho/spotube/issues/761)) ([f5bd907](https://github.com/KRTirtho/spotube/commit/f5bd90731d9abc19d684c8bcb231eff399e73023)) -* loading indicator for genre and personalized pages ([ffe8d9c](https://github.com/KRTirtho/spotube/commit/ffe8d9ca6da25cb3e6fd2c781d5ed3a7b919510e)) -* manual offline detection ([854ab89](https://github.com/KRTirtho/spotube/commit/854ab8910dffb2837c011d3439173a1f0ebe9c6c)) -* show error dialog on failed to login ([101c325](https://github.com/KRTirtho/spotube/commit/101c32523d3be8c05527261f6f63f939d388ad79)) -* sliding up player support ([083319f](https://github.com/KRTirtho/spotube/commit/083319fd2445ab179e3dcda0a6aeaca6f13dda29)) -* swipe to open player view ([#765](https://github.com/KRTirtho/spotube/issues/765)) ([9aee056](https://github.com/KRTirtho/spotube/commit/9aee0568bf42eed9fea8d517e960a010abf0ebf2)) -* thicken the scrollbars & make 'em interactive for mobile ([#764](https://github.com/KRTirtho/spotube/issues/764)) ([84a4bcd](https://github.com/KRTirtho/spotube/commit/84a4bcd948ab459489aaf6f39d6954776c3401d7)) -* **translations:** add Arabic Translations ([#740](https://github.com/KRTirtho/spotube/issues/740)) ([38493f9](https://github.com/KRTirtho/spotube/commit/38493f9dd75303890857a626c0b276ee1ab75bb2)) -* **translations:** add Farsi Translations ([#760](https://github.com/KRTirtho/spotube/issues/760)) ([fe42cfe](https://github.com/KRTirtho/spotube/commit/fe42cfe8430035d9b67dd158fb7b835ee4071497)) - +- ability to select/copy lyrics [#802](https://github.com/KRTirtho/spotube/issues/802) ([0eb9ee8](https://github.com/KRTirtho/spotube/commit/0eb9ee8648bee43a8009e6752674b1be646c0916)) +- add Amoled theme [#724](https://github.com/KRTirtho/spotube/issues/724) ([5c5dbf6](https://github.com/KRTirtho/spotube/commit/5c5dbf69ecea95c92d3c3900ad690a500d75b4e2)) +- add audio normalization [#164](https://github.com/KRTirtho/spotube/issues/164) ([da10ab2](https://github.com/KRTirtho/spotube/commit/da10ab2e291d4ba4d3082b9a6ae535639fb8f1b7)) +- add restore default settings button ([94c3866](https://github.com/KRTirtho/spotube/commit/94c386638f2e5a42d21c8f157835443333ee6d5c)) +- configurable audio normalization switch ([c325911](https://github.com/KRTirtho/spotube/commit/c325911c0d87758a203a52df02179c1513bad3fd)) +- customizable stream/download file formats ([#757](https://github.com/KRTirtho/spotube/issues/757)) ([e54762b](https://github.com/KRTirtho/spotube/commit/e54762be6add6524ab614d103fc3557a101c75f4)) +- improve and unify the logging framework ([#738](https://github.com/KRTirtho/spotube/issues/738)) ([c7432bb](https://github.com/KRTirtho/spotube/commit/c7432bbd986d576a93957f0a22bdbca5c1e87f20)) +- LastFM scrobbling support ([#761](https://github.com/KRTirtho/spotube/issues/761)) ([f5bd907](https://github.com/KRTirtho/spotube/commit/f5bd90731d9abc19d684c8bcb231eff399e73023)) +- loading indicator for genre and personalized pages ([ffe8d9c](https://github.com/KRTirtho/spotube/commit/ffe8d9ca6da25cb3e6fd2c781d5ed3a7b919510e)) +- manual offline detection ([854ab89](https://github.com/KRTirtho/spotube/commit/854ab8910dffb2837c011d3439173a1f0ebe9c6c)) +- show error dialog on failed to login ([101c325](https://github.com/KRTirtho/spotube/commit/101c32523d3be8c05527261f6f63f939d388ad79)) +- sliding up player support ([083319f](https://github.com/KRTirtho/spotube/commit/083319fd2445ab179e3dcda0a6aeaca6f13dda29)) +- swipe to open player view ([#765](https://github.com/KRTirtho/spotube/issues/765)) ([9aee056](https://github.com/KRTirtho/spotube/commit/9aee0568bf42eed9fea8d517e960a010abf0ebf2)) +- thicken the scrollbars & make 'em interactive for mobile ([#764](https://github.com/KRTirtho/spotube/issues/764)) ([84a4bcd](https://github.com/KRTirtho/spotube/commit/84a4bcd948ab459489aaf6f39d6954776c3401d7)) +- **translations:** add Arabic Translations ([#740](https://github.com/KRTirtho/spotube/issues/740)) ([38493f9](https://github.com/KRTirtho/spotube/commit/38493f9dd75303890857a626c0b276ee1ab75bb2)) +- **translations:** add Farsi Translations ([#760](https://github.com/KRTirtho/spotube/issues/760)) ([fe42cfe](https://github.com/KRTirtho/spotube/commit/fe42cfe8430035d9b67dd158fb7b835ee4071497)) ### Bug Fixes -* add libmpv1 for ubuntu-based systems ([#739](https://github.com/KRTirtho/spotube/issues/739)) ([5115e04](https://github.com/KRTirtho/spotube/commit/5115e041e78c20fce798a80f1d844bfc60746958)) -* add xdg-user-dirs as deps ([f3e331e](https://github.com/KRTirtho/spotube/commit/f3e331ecf733995da24c9b907efc5ed4bd02ffdd)) -* **android :** file_selector getDirectoryPath returns unusable content urls [#720](https://github.com/KRTirtho/spotube/issues/720) ([b3cf639](https://github.com/KRTirtho/spotube/commit/b3cf639ee2f970f4df9b394b260c3ad8a5732a9c)) -* **android:** audio doesn't resume on interruption end ([15d466a](https://github.com/KRTirtho/spotube/commit/15d466a04538ec70c3a0c132f2baaaf8690f8d4e)) -* **android:** system navigator back doesn't close player ([20d7092](https://github.com/KRTirtho/spotube/commit/20d70927c909347e84ffa8e456f8fab88d49d179)) -* get rid of overflow errors & status bar dark color ([5bb8231](https://github.com/KRTirtho/spotube/commit/5bb8231782287faf75c778fadb3a03ac774d14f0)) -* keyboard shortcuts changing route but not update sidebar ([2d93441](https://github.com/KRTirtho/spotube/commit/2d934411887bd104d8265236df5bf595c5ad2278)) -* last track repeats ([ed6ca00](https://github.com/KRTirtho/spotube/commit/ed6ca006ce237ed8d509cde9ed47cd6ea3396b63)) -* minor glitches ([e5d0aaf](https://github.com/KRTirtho/spotube/commit/e5d0aaf80d22b2291b6f7e7c5e18dd99ae1a7a82)) -* not fetching all followed artists ([#759](https://github.com/KRTirtho/spotube/issues/759)) ([c09a572](https://github.com/KRTirtho/spotube/commit/c09a5729251d8df820442d55477455f78c19c52e)) -* use audio_service_mpris plugin ([e29cc25](https://github.com/KRTirtho/spotube/commit/e29cc2578cab36729e235b117c1b5489c3452902)) -* valid non-ASCII characters get removed from downloaded file name [#745](https://github.com/KRTirtho/spotube/issues/745) ([a7e102f](https://github.com/KRTirtho/spotube/commit/a7e102ffc726d00df369560ec9a7f742f9d387bb)) +- add libmpv1 for ubuntu-based systems ([#739](https://github.com/KRTirtho/spotube/issues/739)) ([5115e04](https://github.com/KRTirtho/spotube/commit/5115e041e78c20fce798a80f1d844bfc60746958)) +- add xdg-user-dirs as deps ([f3e331e](https://github.com/KRTirtho/spotube/commit/f3e331ecf733995da24c9b907efc5ed4bd02ffdd)) +- **android :** file_selector getDirectoryPath returns unusable content urls [#720](https://github.com/KRTirtho/spotube/issues/720) ([b3cf639](https://github.com/KRTirtho/spotube/commit/b3cf639ee2f970f4df9b394b260c3ad8a5732a9c)) +- **android:** audio doesn't resume on interruption end ([15d466a](https://github.com/KRTirtho/spotube/commit/15d466a04538ec70c3a0c132f2baaaf8690f8d4e)) +- **android:** system navigator back doesn't close player ([20d7092](https://github.com/KRTirtho/spotube/commit/20d70927c909347e84ffa8e456f8fab88d49d179)) +- get rid of overflow errors & status bar dark color ([5bb8231](https://github.com/KRTirtho/spotube/commit/5bb8231782287faf75c778fadb3a03ac774d14f0)) +- keyboard shortcuts changing route but not update sidebar ([2d93441](https://github.com/KRTirtho/spotube/commit/2d934411887bd104d8265236df5bf595c5ad2278)) +- last track repeats ([ed6ca00](https://github.com/KRTirtho/spotube/commit/ed6ca006ce237ed8d509cde9ed47cd6ea3396b63)) +- minor glitches ([e5d0aaf](https://github.com/KRTirtho/spotube/commit/e5d0aaf80d22b2291b6f7e7c5e18dd99ae1a7a82)) +- not fetching all followed artists ([#759](https://github.com/KRTirtho/spotube/issues/759)) ([c09a572](https://github.com/KRTirtho/spotube/commit/c09a5729251d8df820442d55477455f78c19c52e)) +- use audio_service_mpris plugin ([e29cc25](https://github.com/KRTirtho/spotube/commit/e29cc2578cab36729e235b117c1b5489c3452902)) +- valid non-ASCII characters get removed from downloaded file name [#745](https://github.com/KRTirtho/spotube/issues/745) ([a7e102f](https://github.com/KRTirtho/spotube/commit/a7e102ffc726d00df369560ec9a7f742f9d387bb)) ## [3.1.2](https://github.com/KRTirtho/spotube/compare/v3.1.1...v3.1.2) (2023-09-15) - ### Features -* **player_queue:** filtering track support ([d4f99ec](https://github.com/KRTirtho/spotube/commit/d4f99ec89927ea78f070707509ff3222ec402942)) -* right click to open track option ([1540999](https://github.com/KRTirtho/spotube/commit/1540999f50d7ba78d9706d73127483b98d800d86)) -* search loading animation ([b9d5c70](https://github.com/KRTirtho/spotube/commit/b9d5c70301dd33ec26332e5e9a456ce5bfe73da0)) -* show loading indicator on play track ([d12ea48](https://github.com/KRTirtho/spotube/commit/d12ea48b97596205d6309012d561ce83e5cbc9c1)) - +- **player_queue:** filtering track support ([d4f99ec](https://github.com/KRTirtho/spotube/commit/d4f99ec89927ea78f070707509ff3222ec402942)) +- right click to open track option ([1540999](https://github.com/KRTirtho/spotube/commit/1540999f50d7ba78d9706d73127483b98d800d86)) +- search loading animation ([b9d5c70](https://github.com/KRTirtho/spotube/commit/b9d5c70301dd33ec26332e5e9a456ce5bfe73da0)) +- show loading indicator on play track ([d12ea48](https://github.com/KRTirtho/spotube/commit/d12ea48b97596205d6309012d561ce83e5cbc9c1)) ### Bug Fixes -* add missing dependency in debian package ([#704](https://github.com/KRTirtho/spotube/issues/704)) ([c987ea7](https://github.com/KRTirtho/spotube/commit/c987ea78414f094dead2c2b35ddf8ae83a70f2fe)) -* hour not showing for tracks longer than 60 minutes ([#648](https://github.com/KRTirtho/spotube/issues/648)) ([de335f4](https://github.com/KRTirtho/spotube/commit/de335f48342e45a077d6c3202706ef48dfb0a326)) -* liked tracks card play not working ([d3e1cef](https://github.com/KRTirtho/spotube/commit/d3e1cef8a21ef7d64e74ca4e99b4b57b653b60a7)) -* limit cover image upload to allowed 256kb size ([1c50612](https://github.com/KRTirtho/spotube/commit/1c50612559a78dce9c108f7e7b816d1b84540fe4)) -* playlist grey page ([#707](https://github.com/KRTirtho/spotube/issues/707)) ([0df8d9c](https://github.com/KRTirtho/spotube/commit/0df8d9cacee718fbb4cf3ec7b950b489630f3145)) -* rewind breaks track progress bar ([#695](https://github.com/KRTirtho/spotube/issues/695)) ([e321743](https://github.com/KRTirtho/spotube/commit/e3217436c9985b86c68dab93ea65ee414b32fb49)) -* Windows memory leak due refetchOnStale user-liked-tracks ([#705](https://github.com/KRTirtho/spotube/issues/705)) ([142dc49](https://github.com/KRTirtho/spotube/commit/142dc498f8f9d26e6b370c9c52f790a20832fc38)) +- add missing dependency in debian package ([#704](https://github.com/KRTirtho/spotube/issues/704)) ([c987ea7](https://github.com/KRTirtho/spotube/commit/c987ea78414f094dead2c2b35ddf8ae83a70f2fe)) +- hour not showing for tracks longer than 60 minutes ([#648](https://github.com/KRTirtho/spotube/issues/648)) ([de335f4](https://github.com/KRTirtho/spotube/commit/de335f48342e45a077d6c3202706ef48dfb0a326)) +- liked tracks card play not working ([d3e1cef](https://github.com/KRTirtho/spotube/commit/d3e1cef8a21ef7d64e74ca4e99b4b57b653b60a7)) +- limit cover image upload to allowed 256kb size ([1c50612](https://github.com/KRTirtho/spotube/commit/1c50612559a78dce9c108f7e7b816d1b84540fe4)) +- playlist grey page ([#707](https://github.com/KRTirtho/spotube/issues/707)) ([0df8d9c](https://github.com/KRTirtho/spotube/commit/0df8d9cacee718fbb4cf3ec7b950b489630f3145)) +- rewind breaks track progress bar ([#695](https://github.com/KRTirtho/spotube/issues/695)) ([e321743](https://github.com/KRTirtho/spotube/commit/e3217436c9985b86c68dab93ea65ee414b32fb49)) +- Windows memory leak due refetchOnStale user-liked-tracks ([#705](https://github.com/KRTirtho/spotube/issues/705)) ([142dc49](https://github.com/KRTirtho/spotube/commit/142dc498f8f9d26e6b370c9c52f790a20832fc38)) ## [3.1.1](https://github.com/KRTirtho/spotube/compare/v3.1.0...v3.1.1) (2023-08-28) - ### Features -* ability to toggle system title bar & custom title bar ([#185](https://github.com/KRTirtho/spotube/issues/185)) ([8d46029](https://github.com/KRTirtho/spotube/commit/8d4602962be20ea4bafc20db10eae1160f83ac52)) -* jump to specific time on lyric click ([#590](https://github.com/KRTirtho/spotube/issues/590)) ([a14fb9e](https://github.com/KRTirtho/spotube/commit/a14fb9ec389822e5ffa0c537e162b87cbba34e6c)) -* paginated user albums ([d239d64](https://github.com/KRTirtho/spotube/commit/d239d641ff8f1b3edd64243994fd4a58cf71a5d3)) -* **translations:** add Russian translation ([#661](https://github.com/KRTirtho/spotube/issues/661)) ([e9a0911](https://github.com/KRTirtho/spotube/commit/e9a0911bfcea2374ee282aee738c12ad9ed93b02)), closes [#625](https://github.com/KRTirtho/spotube/issues/625) -* **translations:** added Portuguese (Brazil) translation ([#634](https://github.com/KRTirtho/spotube/issues/634)) ([76f30a0](https://github.com/KRTirtho/spotube/commit/76f30a0f20f2b09680d27525cde3d1c9617fad5a)) - +- ability to toggle system title bar & custom title bar ([#185](https://github.com/KRTirtho/spotube/issues/185)) ([8d46029](https://github.com/KRTirtho/spotube/commit/8d4602962be20ea4bafc20db10eae1160f83ac52)) +- jump to specific time on lyric click ([#590](https://github.com/KRTirtho/spotube/issues/590)) ([a14fb9e](https://github.com/KRTirtho/spotube/commit/a14fb9ec389822e5ffa0c537e162b87cbba34e6c)) +- paginated user albums ([d239d64](https://github.com/KRTirtho/spotube/commit/d239d641ff8f1b3edd64243994fd4a58cf71a5d3)) +- **translations:** add Russian translation ([#661](https://github.com/KRTirtho/spotube/issues/661)) ([e9a0911](https://github.com/KRTirtho/spotube/commit/e9a0911bfcea2374ee282aee738c12ad9ed93b02)), closes [#625](https://github.com/KRTirtho/spotube/issues/625) +- **translations:** added Portuguese (Brazil) translation ([#634](https://github.com/KRTirtho/spotube/issues/634)) ([76f30a0](https://github.com/KRTirtho/spotube/commit/76f30a0f20f2b09680d27525cde3d1c9617fad5a)) ### Bug Fixes -* always fetching SponsorBlock if no segments found & download failing ([6ced0a0](https://github.com/KRTirtho/spotube/commit/6ced0a0fad06f9f431636ca0fe5dae83eafe33ce)) -* debian bookworm invalid dependencies ([633415d](https://github.com/KRTirtho/spotube/commit/633415dd3e702a38c5a7e7d7b3b1c2713d9c9cc9)) -* disable android auto for playstore version :"( ([0f0d240](https://github.com/KRTirtho/spotube/commit/0f0d240c04d77db6f7c127d59ba8b331d5534469)) -* infinite route push glitch ([e90eceb](https://github.com/KRTirtho/spotube/commit/e90eceb285a84028df690c25a687ff9b5168bba8)) -* jump to track going to wrong track ([190df17](https://github.com/KRTirtho/spotube/commit/190df17adcf4c01cb2bcebfdec47908828b33816)) -* last track of queue never plays & repeat playlist never works ([c3c09f5](https://github.com/KRTirtho/spotube/commit/c3c09f5b76c9547a306d15cd3768dacc1622876d)) -* lyrics page text contrast ([179d536](https://github.com/KRTirtho/spotube/commit/179d536ccc10a5e63f11a63680a6e61c2d1314c8)) -* replace connectivity_plus with internet_connection_checker ([f23e871](https://github.com/KRTirtho/spotube/commit/f23e8719eec7f5bed677ea866cb4bfab7aee5373)) -* sanitize song title for file name ([#644](https://github.com/KRTirtho/spotube/issues/644)) ([1a7ea0c](https://github.com/KRTirtho/spotube/commit/1a7ea0ce6aae1a7cbe195f6b2fae7d99082bb828)) -* sorting by date crashes app ([#551](https://github.com/KRTirtho/spotube/issues/551)) ([48e90a4](https://github.com/KRTirtho/spotube/commit/48e90a42294a6287cad65f840a7cc305988d34ff)) -* window size remains same after exiting mini player ([#618](https://github.com/KRTirtho/spotube/issues/618)) ([fb36003](https://github.com/KRTirtho/spotube/commit/fb360035ade09c270b46a0c3b99ab1594ece07c0)) +- always fetching SponsorBlock if no segments found & download failing ([6ced0a0](https://github.com/KRTirtho/spotube/commit/6ced0a0fad06f9f431636ca0fe5dae83eafe33ce)) +- debian bookworm invalid dependencies ([633415d](https://github.com/KRTirtho/spotube/commit/633415dd3e702a38c5a7e7d7b3b1c2713d9c9cc9)) +- disable android auto for playstore version :"( ([0f0d240](https://github.com/KRTirtho/spotube/commit/0f0d240c04d77db6f7c127d59ba8b331d5534469)) +- infinite route push glitch ([e90eceb](https://github.com/KRTirtho/spotube/commit/e90eceb285a84028df690c25a687ff9b5168bba8)) +- jump to track going to wrong track ([190df17](https://github.com/KRTirtho/spotube/commit/190df17adcf4c01cb2bcebfdec47908828b33816)) +- last track of queue never plays & repeat playlist never works ([c3c09f5](https://github.com/KRTirtho/spotube/commit/c3c09f5b76c9547a306d15cd3768dacc1622876d)) +- lyrics page text contrast ([179d536](https://github.com/KRTirtho/spotube/commit/179d536ccc10a5e63f11a63680a6e61c2d1314c8)) +- replace connectivity_plus with internet_connection_checker ([f23e871](https://github.com/KRTirtho/spotube/commit/f23e8719eec7f5bed677ea866cb4bfab7aee5373)) +- sanitize song title for file name ([#644](https://github.com/KRTirtho/spotube/issues/644)) ([1a7ea0c](https://github.com/KRTirtho/spotube/commit/1a7ea0ce6aae1a7cbe195f6b2fae7d99082bb828)) +- sorting by date crashes app ([#551](https://github.com/KRTirtho/spotube/issues/551)) ([48e90a4](https://github.com/KRTirtho/spotube/commit/48e90a42294a6287cad65f840a7cc305988d34ff)) +- window size remains same after exiting mini player ([#618](https://github.com/KRTirtho/spotube/issues/618)) ([fb36003](https://github.com/KRTirtho/spotube/commit/fb360035ade09c270b46a0c3b99ab1594ece07c0)) ## [3.1.0](https://github.com/KRTirtho/spotube/compare/v3.0.1...v3.1.0) (2023-08-18) - ### Features -* add android auto media session control support ([0f5748a](https://github.com/KRTirtho/spotube/commit/0f5748a24b4b5a32862f7b2f28151e2d42bcce33)) -* better track matching on youtube API ([904a0d3](https://github.com/KRTirtho/spotube/commit/904a0d3e15a8e4a76d0842e978809d2838439f86)) -* blazingly™ fast download manager ([#619](https://github.com/KRTirtho/spotube/issues/619)) ([38dc4be](https://github.com/KRTirtho/spotube/commit/38dc4beb44827a20044afd120d7c32f097938660)) -* paginated user playlists ([e7c6813](https://github.com/KRTirtho/spotube/commit/e7c6813ccb2afcda9a8b044570a1c0a27785a59f)) -* show error dialog on piped API 500 error ([c69f81e](https://github.com/KRTirtho/spotube/commit/c69f81ec6f01f0f67ad06446e890aa1351516626)) -* **translation:** add catalan translations ([#621](https://github.com/KRTirtho/spotube/issues/621)) ([c94e5ba](https://github.com/KRTirtho/spotube/commit/c94e5ba4301ed0cb760daff56c56d2701b35131f)) -* **translations:** add polish translation ([#631](https://github.com/KRTirtho/spotube/issues/631)) ([f90e9be](https://github.com/KRTirtho/spotube/commit/f90e9bee3104a3812c2f775dd16cabbb56f668cb)) -* web compatibility ([cf7b849](https://github.com/KRTirtho/spotube/commit/cf7b849cddca3260d9c3a6a064418b0ba2d63270)) - +- add android auto media session control support ([0f5748a](https://github.com/KRTirtho/spotube/commit/0f5748a24b4b5a32862f7b2f28151e2d42bcce33)) +- better track matching on youtube API ([904a0d3](https://github.com/KRTirtho/spotube/commit/904a0d3e15a8e4a76d0842e978809d2838439f86)) +- blazingly™ fast download manager ([#619](https://github.com/KRTirtho/spotube/issues/619)) ([38dc4be](https://github.com/KRTirtho/spotube/commit/38dc4beb44827a20044afd120d7c32f097938660)) +- paginated user playlists ([e7c6813](https://github.com/KRTirtho/spotube/commit/e7c6813ccb2afcda9a8b044570a1c0a27785a59f)) +- show error dialog on piped API 500 error ([c69f81e](https://github.com/KRTirtho/spotube/commit/c69f81ec6f01f0f67ad06446e890aa1351516626)) +- **translation:** add catalan translations ([#621](https://github.com/KRTirtho/spotube/issues/621)) ([c94e5ba](https://github.com/KRTirtho/spotube/commit/c94e5ba4301ed0cb760daff56c56d2701b35131f)) +- **translations:** add polish translation ([#631](https://github.com/KRTirtho/spotube/issues/631)) ([f90e9be](https://github.com/KRTirtho/spotube/commit/f90e9bee3104a3812c2f775dd16cabbb56f668cb)) +- web compatibility ([cf7b849](https://github.com/KRTirtho/spotube/commit/cf7b849cddca3260d9c3a6a064418b0ba2d63270)) ### Bug Fixes -* always showing play in playlist/album views ([8521cc5](https://github.com/KRTirtho/spotube/commit/8521cc5c88730caa9db74da6c04b679bf29ed56d)) -* **android:** android 13 local tracks not showing up ([e3f4344](https://github.com/KRTirtho/spotube/commit/e3f4344ae9c1ec93860d4c5d1b8de1a803b29569)) -* default to youtube API by default ([5a8a1e4](https://github.com/KRTirtho/spotube/commit/5a8a1e41e93fb74756b8c88f6325b8b46d7af131)) -* generate playlist page max width ([4adf695](https://github.com/KRTirtho/spotube/commit/4adf6951d9ee78ac4b198d541dada28dc00ca0cb)) -* tracks doesn't change when ended ([aa4ac86](https://github.com/KRTirtho/spotube/commit/aa4ac8641a7dceb4626ab675ba376b24a3480d30)) -* windows media controls not working ([ae5edd1](https://github.com/KRTirtho/spotube/commit/ae5edd17ef24f2a38ec1cc9c9623868b6ee9e352)) +- always showing play in playlist/album views ([8521cc5](https://github.com/KRTirtho/spotube/commit/8521cc5c88730caa9db74da6c04b679bf29ed56d)) +- **android:** android 13 local tracks not showing up ([e3f4344](https://github.com/KRTirtho/spotube/commit/e3f4344ae9c1ec93860d4c5d1b8de1a803b29569)) +- default to youtube API by default ([5a8a1e4](https://github.com/KRTirtho/spotube/commit/5a8a1e41e93fb74756b8c88f6325b8b46d7af131)) +- generate playlist page max width ([4adf695](https://github.com/KRTirtho/spotube/commit/4adf6951d9ee78ac4b198d541dada28dc00ca0cb)) +- tracks doesn't change when ended ([aa4ac86](https://github.com/KRTirtho/spotube/commit/aa4ac8641a7dceb4626ab675ba376b24a3480d30)) +- windows media controls not working ([ae5edd1](https://github.com/KRTirtho/spotube/commit/ae5edd17ef24f2a38ec1cc9c9623868b6ee9e352)) ## [3.0.1](https://github.com/KRTirtho/spotube/compare/v3.0.0...v3.1.0) (2023-08-04) - ### Features -* Force High Refresh Rate on some Android devices ([#607](https://github.com/KRTirtho/spotube/issues/607)) ([6dff099](https://github.com/KRTirtho/spotube/commit/6dff0996bdfee603acf242b1316f8793d625267c)) -* **translations:** add spanish translations ([#585](https://github.com/KRTirtho/spotube/issues/585)) ([042d7a4](https://github.com/KRTirtho/spotube/commit/042d7a4a10c78dd93a56a2f32d18a0fb74dbe697)) -* **translations:** add Simplified Chinese translation. ([#556](https://github.com/KRTirtho/spotube/issues/556)) ([26dbd52](https://github.com/KRTirtho/spotube/commit/26dbd523737d868114a47e82acd412cdae622b7c)) - +- Force High Refresh Rate on some Android devices ([#607](https://github.com/KRTirtho/spotube/issues/607)) ([6dff099](https://github.com/KRTirtho/spotube/commit/6dff0996bdfee603acf242b1316f8793d625267c)) +- **translations:** add spanish translations ([#585](https://github.com/KRTirtho/spotube/issues/585)) ([042d7a4](https://github.com/KRTirtho/spotube/commit/042d7a4a10c78dd93a56a2f32d18a0fb74dbe697)) +- **translations:** add Simplified Chinese translation. ([#556](https://github.com/KRTirtho/spotube/issues/556)) ([26dbd52](https://github.com/KRTirtho/spotube/commit/26dbd523737d868114a47e82acd412cdae622b7c)) ### Bug Fixes -* alternative track source textfield safe area ([b8c6d7e](https://github.com/KRTirtho/spotube/commit/b8c6d7eb6ae1c54bdc83a455850dfca0f27bd881)) -* avoid sponsor block for first few seconds to not break the stream ([d8cf2ae](https://github.com/KRTirtho/spotube/commit/d8cf2ae1315dc3848fe1ac12286faafe90fdbed7)) -* cache segments casting error ([dfd60bd](https://github.com/KRTirtho/spotube/commit/dfd60bd4cc0fe8fe90e0cbfd26331df505cde2aa)) -* duration is always zero in PlayerView ([4885dca](https://github.com/KRTirtho/spotube/commit/4885dca04f06658391d1063e6c5a009547391a6f)) -* flags not showing up and html in descriptions ([5a563ef](https://github.com/KRTirtho/spotube/commit/5a563ef4289423ceb5c44ba13f3cfda34b2d16dd)) -* **linux:** crash when no secret service provider found ([#608](https://github.com/KRTirtho/spotube/issues/608)) ([888a4b1](https://github.com/KRTirtho/spotube/commit/888a4b1162c25371d7f6e88fae3a2473cabf1434)) -* login dialog stays after login, mention sp_gaid in tutorial ([b492840](https://github.com/KRTirtho/spotube/commit/b4928405122ae5e5d4d4560f316f2a546a2fabe4)) -* **album_sync**: negative index exception in update palette ([#561](https://github.com/KRTirtho/spotube/issues/561)) ([0089d47](https://github.com/KRTirtho/spotube/commit/0089d471ae6d595e058061e3ac44caecdba12f61)) -* remove adaptive widgets ([#520](https://github.com/KRTirtho/spotube/issues/520)) ([e4cbdd3](https://github.com/KRTirtho/spotube/commit/e4cbdd37479a572198c1ca27fcbbba0232275513)) -* shuffle not working ([#562](https://github.com/KRTirtho/spotube/issues/562)) ([dc76634](https://github.com/KRTirtho/spotube/commit/dc76634a6e4ccdca0f09d63a2db82cce53d950d7)) -* track not skipping to next even when source is available ([0b7affd](https://github.com/KRTirtho/spotube/commit/0b7affdc058c028982266d5c93215697301846bd)) +- alternative track source textfield safe area ([b8c6d7e](https://github.com/KRTirtho/spotube/commit/b8c6d7eb6ae1c54bdc83a455850dfca0f27bd881)) +- avoid sponsor block for first few seconds to not break the stream ([d8cf2ae](https://github.com/KRTirtho/spotube/commit/d8cf2ae1315dc3848fe1ac12286faafe90fdbed7)) +- cache segments casting error ([dfd60bd](https://github.com/KRTirtho/spotube/commit/dfd60bd4cc0fe8fe90e0cbfd26331df505cde2aa)) +- duration is always zero in PlayerView ([4885dca](https://github.com/KRTirtho/spotube/commit/4885dca04f06658391d1063e6c5a009547391a6f)) +- flags not showing up and html in descriptions ([5a563ef](https://github.com/KRTirtho/spotube/commit/5a563ef4289423ceb5c44ba13f3cfda34b2d16dd)) +- **linux:** crash when no secret service provider found ([#608](https://github.com/KRTirtho/spotube/issues/608)) ([888a4b1](https://github.com/KRTirtho/spotube/commit/888a4b1162c25371d7f6e88fae3a2473cabf1434)) +- login dialog stays after login, mention sp_gaid in tutorial ([b492840](https://github.com/KRTirtho/spotube/commit/b4928405122ae5e5d4d4560f316f2a546a2fabe4)) +- **album_sync**: negative index exception in update palette ([#561](https://github.com/KRTirtho/spotube/issues/561)) ([0089d47](https://github.com/KRTirtho/spotube/commit/0089d471ae6d595e058061e3ac44caecdba12f61)) +- remove adaptive widgets ([#520](https://github.com/KRTirtho/spotube/issues/520)) ([e4cbdd3](https://github.com/KRTirtho/spotube/commit/e4cbdd37479a572198c1ca27fcbbba0232275513)) +- shuffle not working ([#562](https://github.com/KRTirtho/spotube/issues/562)) ([dc76634](https://github.com/KRTirtho/spotube/commit/dc76634a6e4ccdca0f09d63a2db82cce53d950d7)) +- track not skipping to next even when source is available ([0b7affd](https://github.com/KRTirtho/spotube/commit/0b7affdc058c028982266d5c93215697301846bd)) ## [3.0.0](https://github.com/KRTirtho/spotube/compare/v2.7.1...v3.0.0) (2023-07-02) - ### Features -* adaptive controllers ([c8b7de0](https://github.com/KRTirtho/spotube/commit/c8b7de087917ec3037c015d5b55693cb3dbdecca)) -* adaptive popup and bottom sheet list widget ([ddc1c5f](https://github.com/KRTirtho/spotube/commit/ddc1c5f373a4d72cc231c35dd70c3d577b84f7f5)) -* add generated to playlist(s) ([c91d8c8](https://github.com/KRTirtho/spotube/commit/c91d8c8efa8b526c64881fa829992b8c250e7c89)) -* add german locale ([ba3f428](https://github.com/KRTirtho/spotube/commit/ba3f4281f1a6bc7ddf38775e9d40dad863ed3692)) -* add piped search mode ([17a25a5](https://github.com/KRTirtho/spotube/commit/17a25a501e0d5e2512d8de0921fd602ea906d30f)) -* add sleep timer support ([4a75f3d](https://github.com/KRTirtho/spotube/commit/4a75f3dbd1e7e6f68899de001df70e809533f142)) -* adjust lyric page blurriness and player playbutton ([54d5907](https://github.com/KRTirtho/spotube/commit/54d5907f14df04f3f983ba2b0401ba05785da03b)) -* album art dominant color as accent color ([#447](https://github.com/KRTirtho/spotube/issues/447)) ([31b9249](https://github.com/KRTirtho/spotube/commit/31b9249cc8f7313a132a514a9ca825c2ae1e2256)) -* **android:** add splash screen ([c232fcc](https://github.com/KRTirtho/spotube/commit/c232fcc6dd1479ed33a8baa9887de2702a8ea22e)) -* **android:** disable battery optimization for better playback ([fe5b429](https://github.com/KRTirtho/spotube/commit/fe5b429ddacc576fc9fdb5e66718782cda163b27)) -* artist card redesign ([92a418c](https://github.com/KRTirtho/spotube/commit/92a418c8a8a9df99e27407b628e5e3cc9ccb4115)) -* Better download manager with download progress ([6752adc](https://github.com/KRTirtho/spotube/commit/6752adc9398818f51b69fced226b4b8410fb9e9b)) -* better language picker, adaptive select tile and settings section contrast ([6430a25](https://github.com/KRTirtho/spotube/commit/6430a2587075aa24483ab26ce6f0f6b2b630e139)) -* cache encryption for sensitive data ([b110d83](https://github.com/KRTirtho/spotube/commit/b110d834561ac53129ac9ec80238c014c84832ec)) -* color scheme picker dialog vertical list view instead of wrap ([bb60b01](https://github.com/KRTirtho/spotube/commit/bb60b01ef2f2ba3da8f6fe3a19add168b2ee8a4e)) -* compact and adaptive playbutton card design ([eeb8cab](https://github.com/KRTirtho/spotube/commit/eeb8cabf491d5242bd434b3c71c39363f24bdcf9)) -* compact button tabbar ([67380f6](https://github.com/KRTirtho/spotube/commit/67380f68765f18e4dcd3d60117083c7e9c6761c2)) -* create a basic installer script ([1763a36](https://github.com/KRTirtho/spotube/commit/1763a36a262178306df61d4588c17ff795a32790)) -* curved navigation bar ([776edf8](https://github.com/KRTirtho/spotube/commit/776edf84afcf99f96cf6e337b0c84ed89034ca8e)) -* custom error toast ([96f04c1](https://github.com/KRTirtho/spotube/commit/96f04c17565c0ab7f115d5c1f167f6660a69480d)) -* custom playlist generator ([f4b0d13](https://github.com/KRTirtho/spotube/commit/f4b0d134ca724b75bf65b885bce4dba206f1e090)) -* desktop mini player support ([471812d](https://github.com/KRTirtho/spotube/commit/471812d789eb2c861268ab8451d50104ac2fbe2e)) -* **desktop:** close button for minimize notification ([1688f99](https://github.com/KRTirtho/spotube/commit/1688f99096af940ead65e67832c6f061a6f635ac)) -* **desktop:** show minimized to system tray notification ([296f96c](https://github.com/KRTirtho/spotube/commit/296f96cf17cf21ff09b406cc24952ff60da52d5e)) -* disable/enable smtc on demand ([7fa50e5](https://github.com/KRTirtho/spotube/commit/7fa50e5c5ee9ca3ba95dca55f8a4831047f17570)) -* download button on each track ([925fa86](https://github.com/KRTirtho/spotube/commit/925fa86271fa10ff77b8137ba8d09b8067d0e819)) -* enable caching of queue ([ec11af5](https://github.com/KRTirtho/spotube/commit/ec11af53a16c435fbea3d0c81910dca371be9ce7)) -* heart button animation ([8432dc6](https://github.com/KRTirtho/spotube/commit/8432dc6286fbdfda52bbeb39c6d4ababa05881bc)) -* improved track item API and UI ([617aa89](https://github.com/KRTirtho/spotube/commit/617aa89409ce29eb3c197aee5c1189d763a3913c)) -* **installer:** get latest version from Github API ([957c085](https://github.com/KRTirtho/spotube/commit/957c085e1243ec0af5bc45d38691c74e2ba91ad8)) -* **local_tracks:** delete local track ([#484](https://github.com/KRTirtho/spotube/issues/484)) ([52835b2](https://github.com/KRTirtho/spotube/commit/52835b2ce2212925f80e1a1595c0ca30e6860a8d)) -* locale category/genre title ([88137f0](https://github.com/KRTirtho/spotube/commit/88137f01b27150b306327a01e67ec8a35a60e82e)) -* **locale:** add bengali translations for search page ([a1cdbad](https://github.com/KRTirtho/spotube/commit/a1cdbad18782a74b43f0625facfe3e35c516bf43)) -* **locale:** localize search, library, lyrics, artist with both Bengali and English ([11fe9ec](https://github.com/KRTirtho/spotube/commit/11fe9ec74462441b67a0ab0df73824f36dd15e2d)) -* **locale:** player, playlist view, track tile bengali and english translations ([c55133d](https://github.com/KRTirtho/spotube/commit/c55133dc8bba307823e5b67a30cfab03c923cb7f)) -* localize settings, about, login, player queue with Bengali and English translations ([a5c36bb](https://github.com/KRTirtho/spotube/commit/a5c36bbb20cc69d609bfb5ab973c7e288c1ea9de)) -* logs page in settings ([b78e7f5](https://github.com/KRTirtho/spotube/commit/b78e7f57a05db344aae59206cbb0f43b3ee199a9)) -* macos title bar spacing and lyrics page margin separate ([a0b3771](https://github.com/KRTirtho/spotube/commit/a0b377104f9822561d3b46dbc6551bb561842480)) -* make snackbar floating ([9dbb817](https://github.com/KRTirtho/spotube/commit/9dbb8171a6d6b81120ca7ccd74577e5c890ff930)) -* merge floating player with nav bar and nav bar translucent bg ([a90261e](https://github.com/KRTirtho/spotube/commit/a90261ed199f6cff9e8d0fe24934f8f1d8e9ed98)) -* **mini_player:** remove window shadow ([6259014](https://github.com/KRTirtho/spotube/commit/625901482ada4b441b838f640c0ab7167119b321)) -* **mini_player:** show/hide UI on hover toggle ([2e8b647](https://github.com/KRTirtho/spotube/commit/2e8b647a51f87840c2bd39f0a1dc25ddc91528fc)) -* new sidebar widget and translucent bottom player ([4ba1e70](https://github.com/KRTirtho/spotube/commit/4ba1e70636b4ba43697663128fc5422b1d0b2a2f)) -* newly released albums of user followed artist ([33cb794](https://github.com/KRTirtho/spotube/commit/33cb7947d63d0a2692a004f87a2ccd5777bf054e)) -* optimize image load + genre page and reduce page size of loaded categories ([7131efa](https://github.com/KRTirtho/spotube/commit/7131efa07fdbcf17965fc59ff635a6198b0e5e25)) -* persistent volume percentage ([3724bd5](https://github.com/KRTirtho/spotube/commit/3724bd5a10eef7a099d6f596fd038e6fea228359)) -* personal playlist recommendations ([ae820a2](https://github.com/KRTirtho/spotube/commit/ae820a22f291082c49554d621c25cc62212a6708)) -* piped instance picker on settings ([bed0d3b](https://github.com/KRTirtho/spotube/commit/bed0d3bd70438df413633ee03fd258a2ca4a1688)) -* platform specific title bar buttons ([6267720](https://github.com/KRTirtho/spotube/commit/62677209a23172162defb7a8e542d981569eba08)) -* **playback:** integrate android, ios, macos with JustAudio ([d487fe5](https://github.com/KRTirtho/spotube/commit/d487fe55630993e2b729050ebd0bf4e1e4be1fb3)) -* **playback:** use assets_audio_player to fix macos double duration problems and android high loading latency ([1fff0f1](https://github.com/KRTirtho/spotube/commit/1fff0f1bd0d811c348f293f733cbcb7cd57e02f8)) -* player details dialog and separate location of lyrics button in player page ([ce38233](https://github.com/KRTirtho/spotube/commit/ce38233de8f4775018a1d01e951b1635776fe743)) -* **player:** add playlist related methods to audio player ([f1080e1](https://github.com/KRTirtho/spotube/commit/f1080e1675aee1208d05658adfabfbed04ff45b6)) -* **player:** animated gradient background ([49b5d0e](https://github.com/KRTirtho/spotube/commit/49b5d0e6948d80abb8ee09203e0d655d68377245)) -* **player:** custom playlist implementation for media_kit to replace unpredictable playlist of mpv ([eaf65b6](https://github.com/KRTirtho/spotube/commit/eaf65b6db208aaad745821d4d42afc05f51cee7c)) -* **player:** proper coloring of elements ([b2c4ea1](https://github.com/KRTirtho/spotube/commit/b2c4ea13f6157c2b7bec3957e1f7f50fbf0002c7)) -* **player:** replace bg blur with gradient, proper fg color and align title and artist name ([159f03e](https://github.com/KRTirtho/spotube/commit/159f03e7ca62e6b3b86389e2795da84de61fba78)) -* playlist create support for generated playlist ([91c72f9](https://github.com/KRTirtho/spotube/commit/91c72f9ec9556f301c5d129fc82e19e791a02fbe)) -* playlist generation all parameters support ([9877d5f](https://github.com/KRTirtho/spotube/commit/9877d5f51736db03d5839dadf164d11d0cce82f0)) -* **playlist,album page:** play and shuffle take full width on smaller screens, add new xs breakpoint ([dce1b88](https://github.com/KRTirtho/spotube/commit/dce1b88694cfcb6b7e63d6ee614ac1dbbd017f6e)) -* **queue:** add track(s) for playing next ([#460](https://github.com/KRTirtho/spotube/issues/460)) ([cac8ea6](https://github.com/KRTirtho/spotube/commit/cac8ea638812f5d9cb4305144b6351141a2cf407)) -* **queue:** reorder tracks support ([441b43b](https://github.com/KRTirtho/spotube/commit/441b43bef6b92fd7df6c4e1bef39d67b4a76cd22)) -* re-designed playlist/album page ([0cedc7a](https://github.com/KRTirtho/spotube/commit/0cedc7a4187771efce8152003f890e242116c78c)) -* re-introduce youtube API along with piped ([b54ee96](https://github.com/KRTirtho/spotube/commit/b54ee96233b29d7517eba66e3f8dd9270c2790df)) -* reactive volume slider and slicker bottom bar with lowered height ([9d14517](https://github.com/KRTirtho/spotube/commit/9d14517202d5c9d993a947808bf0c6520ed54ea3)) -* remove SponsorBlock in favor of YT Music and remove pocketbase backend track support ([fb780da](https://github.com/KRTirtho/spotube/commit/fb780da327a213d7a82cbc3b567ece858dc2f0e8)) -* repeat button all 3 mode and disable player controls when track is fetching ([1418378](https://github.com/KRTirtho/spotube/commit/14183781dd3f1e16c121e78ad637a326de7b5dcf)) -* replace YouTube API with piped API ([1ecc36d](https://github.com/KRTirtho/spotube/commit/1ecc36da57af61fd9c2ca928589088cd4325f605)) -* responsive playlist generate page and scrollable multi autocomplete ([d57aad5](https://github.com/KRTirtho/spotube/commit/d57aad5612f7622dcd638ea8c0ec4d96f741de2b)) -* search alternative track source ([dfea195](https://github.com/KRTirtho/spotube/commit/dfea195ec178de733717cfe3226cede7521ee2d3)) -* setup localization (l10n) and language switcher, add sidebar and navbar locale ([f12d812](https://github.com/KRTirtho/spotube/commit/f12d81259f9e7005e681a7ca9867291d9228a8b1)) -* show album release year ([#387](https://github.com/KRTirtho/spotube/issues/387)) and fix layout of artist's album ([6a6ddf6](https://github.com/KRTirtho/spotube/commit/6a6ddf6e1f6dc72b794cae49adf8348da272babd)) -* show country code piped instance list ([60328a6](https://github.com/KRTirtho/spotube/commit/60328a6bafcbff1b7d0ee5099825f0e3d545b60f)) -* show loading when track metadata is being fetched, android, ios, macos enable shuffling ([bf59570](https://github.com/KRTirtho/spotube/commit/bf59570251720a80efe0aa6be481899864da5079)) -* sort tracks by newest and oldest dates ([b4713e3](https://github.com/KRTirtho/spotube/commit/b4713e377a938cbebe70089874216f86fe550c34)) -* supabase integration ([8bcce92](https://github.com/KRTirtho/spotube/commit/8bcce9282eae08c5996a27f16f89cbc187a06823)) -* system tray support ([#31](https://github.com/KRTirtho/spotube/issues/31)) ([06a0437](https://github.com/KRTirtho/spotube/commit/06a043764d9f65fb448fcf088ccf2737145e23e8)) -* track populate sibling support ([3aeb026](https://github.com/KRTirtho/spotube/commit/3aeb026776716b6e2eb89c8406a4996a86c7ca60)) -* **translation:** add hindi and french translations using ChatGPT ([6d836bd](https://github.com/KRTirtho/spotube/commit/6d836bdb658c180ca8e2c71e7e290fafa3520727)) -* **translation:** add Japanase locale ([4b52a71](https://github.com/KRTirtho/spotube/commit/4b52a71c0914bda6c831d8f637a5934f7bcf8fcb)) -* use system color scheme ([862c4b8](https://github.com/KRTirtho/spotube/commit/862c4b8faf2c751d803e373e29981a116bf08ed5)) -* volume slider in player page ([7abe2c1](https://github.com/KRTirtho/spotube/commit/7abe2c10735bc38c644487139557a731d25e80e6)) -* windows OS media control panel support ([f0b426a](https://github.com/KRTirtho/spotube/commit/f0b426ae89f2e01f4a9c8757ef4e0b4a21b50c7b)) - +- adaptive controllers ([c8b7de0](https://github.com/KRTirtho/spotube/commit/c8b7de087917ec3037c015d5b55693cb3dbdecca)) +- adaptive popup and bottom sheet list widget ([ddc1c5f](https://github.com/KRTirtho/spotube/commit/ddc1c5f373a4d72cc231c35dd70c3d577b84f7f5)) +- add generated to playlist(s) ([c91d8c8](https://github.com/KRTirtho/spotube/commit/c91d8c8efa8b526c64881fa829992b8c250e7c89)) +- add german locale ([ba3f428](https://github.com/KRTirtho/spotube/commit/ba3f4281f1a6bc7ddf38775e9d40dad863ed3692)) +- add piped search mode ([17a25a5](https://github.com/KRTirtho/spotube/commit/17a25a501e0d5e2512d8de0921fd602ea906d30f)) +- add sleep timer support ([4a75f3d](https://github.com/KRTirtho/spotube/commit/4a75f3dbd1e7e6f68899de001df70e809533f142)) +- adjust lyric page blurriness and player playbutton ([54d5907](https://github.com/KRTirtho/spotube/commit/54d5907f14df04f3f983ba2b0401ba05785da03b)) +- album art dominant color as accent color ([#447](https://github.com/KRTirtho/spotube/issues/447)) ([31b9249](https://github.com/KRTirtho/spotube/commit/31b9249cc8f7313a132a514a9ca825c2ae1e2256)) +- **android:** add splash screen ([c232fcc](https://github.com/KRTirtho/spotube/commit/c232fcc6dd1479ed33a8baa9887de2702a8ea22e)) +- **android:** disable battery optimization for better playback ([fe5b429](https://github.com/KRTirtho/spotube/commit/fe5b429ddacc576fc9fdb5e66718782cda163b27)) +- artist card redesign ([92a418c](https://github.com/KRTirtho/spotube/commit/92a418c8a8a9df99e27407b628e5e3cc9ccb4115)) +- Better download manager with download progress ([6752adc](https://github.com/KRTirtho/spotube/commit/6752adc9398818f51b69fced226b4b8410fb9e9b)) +- better language picker, adaptive select tile and settings section contrast ([6430a25](https://github.com/KRTirtho/spotube/commit/6430a2587075aa24483ab26ce6f0f6b2b630e139)) +- cache encryption for sensitive data ([b110d83](https://github.com/KRTirtho/spotube/commit/b110d834561ac53129ac9ec80238c014c84832ec)) +- color scheme picker dialog vertical list view instead of wrap ([bb60b01](https://github.com/KRTirtho/spotube/commit/bb60b01ef2f2ba3da8f6fe3a19add168b2ee8a4e)) +- compact and adaptive playbutton card design ([eeb8cab](https://github.com/KRTirtho/spotube/commit/eeb8cabf491d5242bd434b3c71c39363f24bdcf9)) +- compact button tabbar ([67380f6](https://github.com/KRTirtho/spotube/commit/67380f68765f18e4dcd3d60117083c7e9c6761c2)) +- create a basic installer script ([1763a36](https://github.com/KRTirtho/spotube/commit/1763a36a262178306df61d4588c17ff795a32790)) +- curved navigation bar ([776edf8](https://github.com/KRTirtho/spotube/commit/776edf84afcf99f96cf6e337b0c84ed89034ca8e)) +- custom error toast ([96f04c1](https://github.com/KRTirtho/spotube/commit/96f04c17565c0ab7f115d5c1f167f6660a69480d)) +- custom playlist generator ([f4b0d13](https://github.com/KRTirtho/spotube/commit/f4b0d134ca724b75bf65b885bce4dba206f1e090)) +- desktop mini player support ([471812d](https://github.com/KRTirtho/spotube/commit/471812d789eb2c861268ab8451d50104ac2fbe2e)) +- **desktop:** close button for minimize notification ([1688f99](https://github.com/KRTirtho/spotube/commit/1688f99096af940ead65e67832c6f061a6f635ac)) +- **desktop:** show minimized to system tray notification ([296f96c](https://github.com/KRTirtho/spotube/commit/296f96cf17cf21ff09b406cc24952ff60da52d5e)) +- disable/enable smtc on demand ([7fa50e5](https://github.com/KRTirtho/spotube/commit/7fa50e5c5ee9ca3ba95dca55f8a4831047f17570)) +- download button on each track ([925fa86](https://github.com/KRTirtho/spotube/commit/925fa86271fa10ff77b8137ba8d09b8067d0e819)) +- enable caching of queue ([ec11af5](https://github.com/KRTirtho/spotube/commit/ec11af53a16c435fbea3d0c81910dca371be9ce7)) +- heart button animation ([8432dc6](https://github.com/KRTirtho/spotube/commit/8432dc6286fbdfda52bbeb39c6d4ababa05881bc)) +- improved track item API and UI ([617aa89](https://github.com/KRTirtho/spotube/commit/617aa89409ce29eb3c197aee5c1189d763a3913c)) +- **installer:** get latest version from Github API ([957c085](https://github.com/KRTirtho/spotube/commit/957c085e1243ec0af5bc45d38691c74e2ba91ad8)) +- **local_tracks:** delete local track ([#484](https://github.com/KRTirtho/spotube/issues/484)) ([52835b2](https://github.com/KRTirtho/spotube/commit/52835b2ce2212925f80e1a1595c0ca30e6860a8d)) +- locale category/genre title ([88137f0](https://github.com/KRTirtho/spotube/commit/88137f01b27150b306327a01e67ec8a35a60e82e)) +- **locale:** add bengali translations for search page ([a1cdbad](https://github.com/KRTirtho/spotube/commit/a1cdbad18782a74b43f0625facfe3e35c516bf43)) +- **locale:** localize search, library, lyrics, artist with both Bengali and English ([11fe9ec](https://github.com/KRTirtho/spotube/commit/11fe9ec74462441b67a0ab0df73824f36dd15e2d)) +- **locale:** player, playlist view, track tile bengali and english translations ([c55133d](https://github.com/KRTirtho/spotube/commit/c55133dc8bba307823e5b67a30cfab03c923cb7f)) +- localize settings, about, login, player queue with Bengali and English translations ([a5c36bb](https://github.com/KRTirtho/spotube/commit/a5c36bbb20cc69d609bfb5ab973c7e288c1ea9de)) +- logs page in settings ([b78e7f5](https://github.com/KRTirtho/spotube/commit/b78e7f57a05db344aae59206cbb0f43b3ee199a9)) +- macos title bar spacing and lyrics page margin separate ([a0b3771](https://github.com/KRTirtho/spotube/commit/a0b377104f9822561d3b46dbc6551bb561842480)) +- make snackbar floating ([9dbb817](https://github.com/KRTirtho/spotube/commit/9dbb8171a6d6b81120ca7ccd74577e5c890ff930)) +- merge floating player with nav bar and nav bar translucent bg ([a90261e](https://github.com/KRTirtho/spotube/commit/a90261ed199f6cff9e8d0fe24934f8f1d8e9ed98)) +- **mini_player:** remove window shadow ([6259014](https://github.com/KRTirtho/spotube/commit/625901482ada4b441b838f640c0ab7167119b321)) +- **mini_player:** show/hide UI on hover toggle ([2e8b647](https://github.com/KRTirtho/spotube/commit/2e8b647a51f87840c2bd39f0a1dc25ddc91528fc)) +- new sidebar widget and translucent bottom player ([4ba1e70](https://github.com/KRTirtho/spotube/commit/4ba1e70636b4ba43697663128fc5422b1d0b2a2f)) +- newly released albums of user followed artist ([33cb794](https://github.com/KRTirtho/spotube/commit/33cb7947d63d0a2692a004f87a2ccd5777bf054e)) +- optimize image load + genre page and reduce page size of loaded categories ([7131efa](https://github.com/KRTirtho/spotube/commit/7131efa07fdbcf17965fc59ff635a6198b0e5e25)) +- persistent volume percentage ([3724bd5](https://github.com/KRTirtho/spotube/commit/3724bd5a10eef7a099d6f596fd038e6fea228359)) +- personal playlist recommendations ([ae820a2](https://github.com/KRTirtho/spotube/commit/ae820a22f291082c49554d621c25cc62212a6708)) +- piped instance picker on settings ([bed0d3b](https://github.com/KRTirtho/spotube/commit/bed0d3bd70438df413633ee03fd258a2ca4a1688)) +- platform specific title bar buttons ([6267720](https://github.com/KRTirtho/spotube/commit/62677209a23172162defb7a8e542d981569eba08)) +- **playback:** integrate android, ios, macos with JustAudio ([d487fe5](https://github.com/KRTirtho/spotube/commit/d487fe55630993e2b729050ebd0bf4e1e4be1fb3)) +- **playback:** use assets_audio_player to fix macos double duration problems and android high loading latency ([1fff0f1](https://github.com/KRTirtho/spotube/commit/1fff0f1bd0d811c348f293f733cbcb7cd57e02f8)) +- player details dialog and separate location of lyrics button in player page ([ce38233](https://github.com/KRTirtho/spotube/commit/ce38233de8f4775018a1d01e951b1635776fe743)) +- **player:** add playlist related methods to audio player ([f1080e1](https://github.com/KRTirtho/spotube/commit/f1080e1675aee1208d05658adfabfbed04ff45b6)) +- **player:** animated gradient background ([49b5d0e](https://github.com/KRTirtho/spotube/commit/49b5d0e6948d80abb8ee09203e0d655d68377245)) +- **player:** custom playlist implementation for media_kit to replace unpredictable playlist of mpv ([eaf65b6](https://github.com/KRTirtho/spotube/commit/eaf65b6db208aaad745821d4d42afc05f51cee7c)) +- **player:** proper coloring of elements ([b2c4ea1](https://github.com/KRTirtho/spotube/commit/b2c4ea13f6157c2b7bec3957e1f7f50fbf0002c7)) +- **player:** replace bg blur with gradient, proper fg color and align title and artist name ([159f03e](https://github.com/KRTirtho/spotube/commit/159f03e7ca62e6b3b86389e2795da84de61fba78)) +- playlist create support for generated playlist ([91c72f9](https://github.com/KRTirtho/spotube/commit/91c72f9ec9556f301c5d129fc82e19e791a02fbe)) +- playlist generation all parameters support ([9877d5f](https://github.com/KRTirtho/spotube/commit/9877d5f51736db03d5839dadf164d11d0cce82f0)) +- **playlist,album page:** play and shuffle take full width on smaller screens, add new xs breakpoint ([dce1b88](https://github.com/KRTirtho/spotube/commit/dce1b88694cfcb6b7e63d6ee614ac1dbbd017f6e)) +- **queue:** add track(s) for playing next ([#460](https://github.com/KRTirtho/spotube/issues/460)) ([cac8ea6](https://github.com/KRTirtho/spotube/commit/cac8ea638812f5d9cb4305144b6351141a2cf407)) +- **queue:** reorder tracks support ([441b43b](https://github.com/KRTirtho/spotube/commit/441b43bef6b92fd7df6c4e1bef39d67b4a76cd22)) +- re-designed playlist/album page ([0cedc7a](https://github.com/KRTirtho/spotube/commit/0cedc7a4187771efce8152003f890e242116c78c)) +- re-introduce youtube API along with piped ([b54ee96](https://github.com/KRTirtho/spotube/commit/b54ee96233b29d7517eba66e3f8dd9270c2790df)) +- reactive volume slider and slicker bottom bar with lowered height ([9d14517](https://github.com/KRTirtho/spotube/commit/9d14517202d5c9d993a947808bf0c6520ed54ea3)) +- remove SponsorBlock in favor of YT Music and remove pocketbase backend track support ([fb780da](https://github.com/KRTirtho/spotube/commit/fb780da327a213d7a82cbc3b567ece858dc2f0e8)) +- repeat button all 3 mode and disable player controls when track is fetching ([1418378](https://github.com/KRTirtho/spotube/commit/14183781dd3f1e16c121e78ad637a326de7b5dcf)) +- replace YouTube API with piped API ([1ecc36d](https://github.com/KRTirtho/spotube/commit/1ecc36da57af61fd9c2ca928589088cd4325f605)) +- responsive playlist generate page and scrollable multi autocomplete ([d57aad5](https://github.com/KRTirtho/spotube/commit/d57aad5612f7622dcd638ea8c0ec4d96f741de2b)) +- search alternative track source ([dfea195](https://github.com/KRTirtho/spotube/commit/dfea195ec178de733717cfe3226cede7521ee2d3)) +- setup localization (l10n) and language switcher, add sidebar and navbar locale ([f12d812](https://github.com/KRTirtho/spotube/commit/f12d81259f9e7005e681a7ca9867291d9228a8b1)) +- show album release year ([#387](https://github.com/KRTirtho/spotube/issues/387)) and fix layout of artist's album ([6a6ddf6](https://github.com/KRTirtho/spotube/commit/6a6ddf6e1f6dc72b794cae49adf8348da272babd)) +- show country code piped instance list ([60328a6](https://github.com/KRTirtho/spotube/commit/60328a6bafcbff1b7d0ee5099825f0e3d545b60f)) +- show loading when track metadata is being fetched, android, ios, macos enable shuffling ([bf59570](https://github.com/KRTirtho/spotube/commit/bf59570251720a80efe0aa6be481899864da5079)) +- sort tracks by newest and oldest dates ([b4713e3](https://github.com/KRTirtho/spotube/commit/b4713e377a938cbebe70089874216f86fe550c34)) +- supabase integration ([8bcce92](https://github.com/KRTirtho/spotube/commit/8bcce9282eae08c5996a27f16f89cbc187a06823)) +- system tray support ([#31](https://github.com/KRTirtho/spotube/issues/31)) ([06a0437](https://github.com/KRTirtho/spotube/commit/06a043764d9f65fb448fcf088ccf2737145e23e8)) +- track populate sibling support ([3aeb026](https://github.com/KRTirtho/spotube/commit/3aeb026776716b6e2eb89c8406a4996a86c7ca60)) +- **translation:** add hindi and french translations using ChatGPT ([6d836bd](https://github.com/KRTirtho/spotube/commit/6d836bdb658c180ca8e2c71e7e290fafa3520727)) +- **translation:** add Japanase locale ([4b52a71](https://github.com/KRTirtho/spotube/commit/4b52a71c0914bda6c831d8f637a5934f7bcf8fcb)) +- use system color scheme ([862c4b8](https://github.com/KRTirtho/spotube/commit/862c4b8faf2c751d803e373e29981a116bf08ed5)) +- volume slider in player page ([7abe2c1](https://github.com/KRTirtho/spotube/commit/7abe2c10735bc38c644487139557a731d25e80e6)) +- windows OS media control panel support ([f0b426a](https://github.com/KRTirtho/spotube/commit/f0b426ae89f2e01f4a9c8757ef4e0b4a21b50c7b)) ### Bug Fixes -* add to playlist dialog not showing playlist name ([8944581](https://github.com/KRTirtho/spotube/commit/8944581c09eec0162220e7ff684205484fafb599)) -* album sync not working ([74906f3](https://github.com/KRTirtho/spotube/commit/74906f393250934c36530a73ad7312f59f8627ed)) -* alternative track source not playing new source ([a9b5a71](https://github.com/KRTirtho/spotube/commit/a9b5a714e47d40407d799966ae95f84338f9b59a)) -* **android:** use multi assetAudioPlayer instance fix patch and disable Pre-download and play by default in Android too ([cdb3268](https://github.com/KRTirtho/spotube/commit/cdb32685e4bbb899706ed16d58ef9a3a074e283a)) -* **artist:** follower count shows as float when < 1000 ([#482](https://github.com/KRTirtho/spotube/issues/482)) ([fd1846e](https://github.com/KRTirtho/spotube/commit/fd1846eecf9632e59e4b70fb70e97c556b6374f5)) -* bottom navbar first item icon color not changing on primary color change ([6eb4244](https://github.com/KRTirtho/spotube/commit/6eb4244f3244a96fe6858261534cc03eb3de803c)) -* cached currently playing track infinite loading ([9401718](https://github.com/KRTirtho/spotube/commit/94017189c6b9bf55ec62cbf29cd6b0e9fffca42a)) -* cached queue tracks expired stream ([ed29ab5](https://github.com/KRTirtho/spotube/commit/ed29ab5137416d9fb2e7e9fe840f56ef52df6f61)) -* collection currently playing state persist on restart ([1c89e3e](https://github.com/KRTirtho/spotube/commit/1c89e3efb0f05c648fc1c8e09039e62333de18d1)) -* color not syncing and add new screenshot ([6205501](https://github.com/KRTirtho/spotube/commit/62055018feade0b895663a0bfc5f85f265ae2154)) -* content going below bottom player or nav bar ([1bdce9f](https://github.com/KRTirtho/spotube/commit/1bdce9fe964de88a667bb160846c11dc70b77c00)) -* disable background_downloader due to android build failures ([7d23bee](https://github.com/KRTirtho/spotube/commit/7d23beec5ef07c4d649185a69e7a2b9697dc6953)) -* disable play when loading track and buffering event ([30c933c](https://github.com/KRTirtho/spotube/commit/30c933cdf3d4524be164e171094afdd27b0252b7)) -* error log ([e3d8239](https://github.com/KRTirtho/spotube/commit/e3d8239b9f5700bfb17c4758d95ba1db1f0e718a)) -* excessive repaints caused by Player progress bar ([09b24cf](https://github.com/KRTirtho/spotube/commit/09b24cf1fd1644c549f85904545db54b39cc2431)) -* failed download no error icon ([1266a3f](https://github.com/KRTirtho/spotube/commit/1266a3f1607de11e793a294071850996527d494a)) -* **home:** bottom player transparency ([20c424c](https://github.com/KRTirtho/spotube/commit/20c424c77fe273a693213ebf88d50e4025bc8608)) -* language changer not working ([7b7b1f2](https://github.com/KRTirtho/spotube/commit/7b7b1f2647591b7cd4cc7841526716c6a2877e55)) -* less frequent position updates ([0a49b56](https://github.com/KRTirtho/spotube/commit/0a49b56566abd00cf7703e4207cfa90f93c381fd)) -* linux mpris not showing up and overall media notification service ([1abcad1](https://github.com/KRTirtho/spotube/commit/1abcad1de510c209a34196f2de17045af4dd3bc2)) -* local tracks getting fetched on first load ([73c012c](https://github.com/KRTirtho/spotube/commit/73c012c71ab5050636f79e010d654b4390978ee7)) -* local tracks not working when there's a invalid music file in the folder ([5855820](https://github.com/KRTirtho/spotube/commit/5855820569dfad7cd26f1e0f0c985babd0d9485d)) -* lyrics page blur in player and cut off text when line too big ([6b4584e](https://github.com/KRTirtho/spotube/commit/6b4584e91bd4f4aee0c56e48a7aec7015c7c418b)) -* macos build by removing media_kit native event loop ([62fc773](https://github.com/KRTirtho/spotube/commit/62fc7739b508f0e874978408a2bab0a1d422deb6)) -* macos build error, mobile player duration and playing state and background disposal of player ([be91e33](https://github.com/KRTirtho/spotube/commit/be91e33828630c7062886cd15e4d57496daaa4d5)) -* **macos,ios:** use regular shared prefs ([1b5bfec](https://github.com/KRTirtho/spotube/commit/1b5bfec27fbcfe9faabff64d46296bdeebe00161)) -* memoize child of animated widget and make player bg animation faster ([fcb5c8f](https://github.com/KRTirtho/spotube/commit/fcb5c8f8dabd0d4e3033f80ea3e5d006243cdfb5)) -* mini player not working in release mode ([28ff321](https://github.com/KRTirtho/spotube/commit/28ff3216efee81184798eedfbb10ba66395bbf36)) -* **mkPlayer:** remove method and wrong active index on modifying playlist ([3bafa7b](https://github.com/KRTirtho/spotube/commit/3bafa7b80c963fa52b90ed4cb1393fb121cac713)) -* mobile audio notification not working ([8f9303b](https://github.com/KRTirtho/spotube/commit/8f9303bc0fddb9d179303a1f0eb76dd5b02410e7)) -* multiple instance of theme ([4ec0424](https://github.com/KRTirtho/spotube/commit/4ec04240a5bde6af5c920a61ab6260e7a93bfc54)) -* navigation to settings not working ([ce10aa1](https://github.com/KRTirtho/spotube/commit/ce10aa1fe2c95d4738835687f613930cf7829f3a)) -* no progress update when track changed ([6ae8964](https://github.com/KRTirtho/spotube/commit/6ae896441a787fce1bc6e5eb5379856dc2f4e96d)) -* null exception on proxy playlist and audio player ([a455a89](https://github.com/KRTirtho/spotube/commit/a455a89c5861fd455f6950c7b68beae24bdcc6ed)) -* overflowing clickable artists links ([4077fac](https://github.com/KRTirtho/spotube/commit/4077fac39fb667b87e959e53d2dcaceefb63cd2d)) -* personalized playlists not loading ([caa3408](https://github.com/KRTirtho/spotube/commit/caa340803fdf3859fe5a8a996abae1502ef2e4e7)) -* playback not moving to next track after a track ends ([27e8acb](https://github.com/KRTirtho/spotube/commit/27e8acbfe75a37c0a8fa69a444fdd86e92dbe4f0)) -* **player:** gradient bg not taking full height ([62ad86e](https://github.com/KRTirtho/spotube/commit/62ad86e88d74b5114af78138b221314192e5a801)) -* **player:** playback element placement ([5e47faa](https://github.com/KRTirtho/spotube/commit/5e47faa6060d7a8aa0d143060e812dc06b8dd790)) -* **player:** queue button not showing when not logged in ([6c2d655](https://github.com/KRTirtho/spotube/commit/6c2d65587b0e6e167be1d0b086df103c7e72d4b2)) -* **player:** volume slider, prefetching of media_kit and stuttering on sponsorblock skip ([1f32554](https://github.com/KRTirtho/spotube/commit/1f3255481f058c50968561db88172e56b58494f4)) -* playlist generate slider shape ([2b35c04](https://github.com/KRTirtho/spotube/commit/2b35c044adb15a97a58692e7880694a251899732)) -* pop sheet list not scrollable ([cca5625](https://github.com/KRTirtho/spotube/commit/cca5625df7e432da8581a4504306baad154deb48)) -* re-enable add to queue and play next support, favorite button query exceptions ([e529c79](https://github.com/KRTirtho/spotube/commit/e529c79c4f0cd964b7d89e010d3fe51378ea7222)) -* re-enable download manager ([ea45c4f](https://github.com/KRTirtho/spotube/commit/ea45c4f42ae89b8991e470e84a5290b3be3b0f36)) -* remove unnecessary broadcast stream conversions ([bf04962](https://github.com/KRTirtho/spotube/commit/bf04962e90ea5345a2bc0d4793999f7db712cab2)) -* remove useBreakpoints as it clogs up memory with unnecessary state updates ([e1c0f5c](https://github.com/KRTirtho/spotube/commit/e1c0f5cf1e4cece2c4aa235bfbf8511ad7b1fe59)) -* replace download multiple pops and add translations ([4a21249](https://github.com/KRTirtho/spotube/commit/4a21249ee386426b0974451542a93e84f532fb3f)) -* screen breakpoints and persist lyrics delay across screens ([df79638](https://github.com/KRTirtho/spotube/commit/df79638fb622a55aaa2b36c9a1425c2d9c4a8e52)) -* sidebar task counter badge and bottom player play button progress color ([af278d8](https://github.com/KRTirtho/spotube/commit/af278d8feaa08c14528627766bce6d724b846954)) -* status bar color of playlist/album page ([65fa3cb](https://github.com/KRTirtho/spotube/commit/65fa3cb624c240360de5a06778a1f72ad10bbe2d)) -* system color scheme not persisting on restart when system color scheme changed ([e04515d](https://github.com/KRTirtho/spotube/commit/e04515d8e213b4c7f85d11385959a33b042bd9b1)) -* track collection view status bar not transparent ([9251121](https://github.com/KRTirtho/spotube/commit/9251121ba0154599975e33819a43719477c644f8)) -* track doesn't play after change ([17e5ab6](https://github.com/KRTirtho/spotube/commit/17e5ab611cc417cce7c17cafc7045f5aa2eb970e)) -* track stops at last second ([f554f6d](https://github.com/KRTirtho/spotube/commit/f554f6d43bb714f662a27977f501d7ad44b070c3)) -* **track_collection_view:** keyboard focus on scroll and no space for search results in playlist/album ([7a8bd92](https://github.com/KRTirtho/spotube/commit/7a8bd921047e3766dbbf24449e2873afe3dbecf8)) -* track_table_view table headers ([d88d287](https://github.com/KRTirtho/spotube/commit/d88d287fc586ec33351de9f3b4359f189054868b)) -* track_tile active and blacklist color, playbutton card action positioning ([3f5a1b9](https://github.com/KRTirtho/spotube/commit/3f5a1b9587efe9b7b2c69008345867933f79ec67)) -* use id based source getters instead of index ([a074463](https://github.com/KRTirtho/spotube/commit/a0744630ba2dc713babdb8db6500f9dd0f1e6096)) +- add to playlist dialog not showing playlist name ([8944581](https://github.com/KRTirtho/spotube/commit/8944581c09eec0162220e7ff684205484fafb599)) +- album sync not working ([74906f3](https://github.com/KRTirtho/spotube/commit/74906f393250934c36530a73ad7312f59f8627ed)) +- alternative track source not playing new source ([a9b5a71](https://github.com/KRTirtho/spotube/commit/a9b5a714e47d40407d799966ae95f84338f9b59a)) +- **android:** use multi assetAudioPlayer instance fix patch and disable Pre-download and play by default in Android too ([cdb3268](https://github.com/KRTirtho/spotube/commit/cdb32685e4bbb899706ed16d58ef9a3a074e283a)) +- **artist:** follower count shows as float when < 1000 ([#482](https://github.com/KRTirtho/spotube/issues/482)) ([fd1846e](https://github.com/KRTirtho/spotube/commit/fd1846eecf9632e59e4b70fb70e97c556b6374f5)) +- bottom navbar first item icon color not changing on primary color change ([6eb4244](https://github.com/KRTirtho/spotube/commit/6eb4244f3244a96fe6858261534cc03eb3de803c)) +- cached currently playing track infinite loading ([9401718](https://github.com/KRTirtho/spotube/commit/94017189c6b9bf55ec62cbf29cd6b0e9fffca42a)) +- cached queue tracks expired stream ([ed29ab5](https://github.com/KRTirtho/spotube/commit/ed29ab5137416d9fb2e7e9fe840f56ef52df6f61)) +- collection currently playing state persist on restart ([1c89e3e](https://github.com/KRTirtho/spotube/commit/1c89e3efb0f05c648fc1c8e09039e62333de18d1)) +- color not syncing and add new screenshot ([6205501](https://github.com/KRTirtho/spotube/commit/62055018feade0b895663a0bfc5f85f265ae2154)) +- content going below bottom player or nav bar ([1bdce9f](https://github.com/KRTirtho/spotube/commit/1bdce9fe964de88a667bb160846c11dc70b77c00)) +- disable background_downloader due to android build failures ([7d23bee](https://github.com/KRTirtho/spotube/commit/7d23beec5ef07c4d649185a69e7a2b9697dc6953)) +- disable play when loading track and buffering event ([30c933c](https://github.com/KRTirtho/spotube/commit/30c933cdf3d4524be164e171094afdd27b0252b7)) +- error log ([e3d8239](https://github.com/KRTirtho/spotube/commit/e3d8239b9f5700bfb17c4758d95ba1db1f0e718a)) +- excessive repaints caused by Player progress bar ([09b24cf](https://github.com/KRTirtho/spotube/commit/09b24cf1fd1644c549f85904545db54b39cc2431)) +- failed download no error icon ([1266a3f](https://github.com/KRTirtho/spotube/commit/1266a3f1607de11e793a294071850996527d494a)) +- **home:** bottom player transparency ([20c424c](https://github.com/KRTirtho/spotube/commit/20c424c77fe273a693213ebf88d50e4025bc8608)) +- language changer not working ([7b7b1f2](https://github.com/KRTirtho/spotube/commit/7b7b1f2647591b7cd4cc7841526716c6a2877e55)) +- less frequent position updates ([0a49b56](https://github.com/KRTirtho/spotube/commit/0a49b56566abd00cf7703e4207cfa90f93c381fd)) +- linux mpris not showing up and overall media notification service ([1abcad1](https://github.com/KRTirtho/spotube/commit/1abcad1de510c209a34196f2de17045af4dd3bc2)) +- local tracks getting fetched on first load ([73c012c](https://github.com/KRTirtho/spotube/commit/73c012c71ab5050636f79e010d654b4390978ee7)) +- local tracks not working when there's a invalid music file in the folder ([5855820](https://github.com/KRTirtho/spotube/commit/5855820569dfad7cd26f1e0f0c985babd0d9485d)) +- lyrics page blur in player and cut off text when line too big ([6b4584e](https://github.com/KRTirtho/spotube/commit/6b4584e91bd4f4aee0c56e48a7aec7015c7c418b)) +- macos build by removing media_kit native event loop ([62fc773](https://github.com/KRTirtho/spotube/commit/62fc7739b508f0e874978408a2bab0a1d422deb6)) +- macos build error, mobile player duration and playing state and background disposal of player ([be91e33](https://github.com/KRTirtho/spotube/commit/be91e33828630c7062886cd15e4d57496daaa4d5)) +- **macos,ios:** use regular shared prefs ([1b5bfec](https://github.com/KRTirtho/spotube/commit/1b5bfec27fbcfe9faabff64d46296bdeebe00161)) +- memoize child of animated widget and make player bg animation faster ([fcb5c8f](https://github.com/KRTirtho/spotube/commit/fcb5c8f8dabd0d4e3033f80ea3e5d006243cdfb5)) +- mini player not working in release mode ([28ff321](https://github.com/KRTirtho/spotube/commit/28ff3216efee81184798eedfbb10ba66395bbf36)) +- **mkPlayer:** remove method and wrong active index on modifying playlist ([3bafa7b](https://github.com/KRTirtho/spotube/commit/3bafa7b80c963fa52b90ed4cb1393fb121cac713)) +- mobile audio notification not working ([8f9303b](https://github.com/KRTirtho/spotube/commit/8f9303bc0fddb9d179303a1f0eb76dd5b02410e7)) +- multiple instance of theme ([4ec0424](https://github.com/KRTirtho/spotube/commit/4ec04240a5bde6af5c920a61ab6260e7a93bfc54)) +- navigation to settings not working ([ce10aa1](https://github.com/KRTirtho/spotube/commit/ce10aa1fe2c95d4738835687f613930cf7829f3a)) +- no progress update when track changed ([6ae8964](https://github.com/KRTirtho/spotube/commit/6ae896441a787fce1bc6e5eb5379856dc2f4e96d)) +- null exception on proxy playlist and audio player ([a455a89](https://github.com/KRTirtho/spotube/commit/a455a89c5861fd455f6950c7b68beae24bdcc6ed)) +- overflowing clickable artists links ([4077fac](https://github.com/KRTirtho/spotube/commit/4077fac39fb667b87e959e53d2dcaceefb63cd2d)) +- personalized playlists not loading ([caa3408](https://github.com/KRTirtho/spotube/commit/caa340803fdf3859fe5a8a996abae1502ef2e4e7)) +- playback not moving to next track after a track ends ([27e8acb](https://github.com/KRTirtho/spotube/commit/27e8acbfe75a37c0a8fa69a444fdd86e92dbe4f0)) +- **player:** gradient bg not taking full height ([62ad86e](https://github.com/KRTirtho/spotube/commit/62ad86e88d74b5114af78138b221314192e5a801)) +- **player:** playback element placement ([5e47faa](https://github.com/KRTirtho/spotube/commit/5e47faa6060d7a8aa0d143060e812dc06b8dd790)) +- **player:** queue button not showing when not logged in ([6c2d655](https://github.com/KRTirtho/spotube/commit/6c2d65587b0e6e167be1d0b086df103c7e72d4b2)) +- **player:** volume slider, prefetching of media_kit and stuttering on sponsorblock skip ([1f32554](https://github.com/KRTirtho/spotube/commit/1f3255481f058c50968561db88172e56b58494f4)) +- playlist generate slider shape ([2b35c04](https://github.com/KRTirtho/spotube/commit/2b35c044adb15a97a58692e7880694a251899732)) +- pop sheet list not scrollable ([cca5625](https://github.com/KRTirtho/spotube/commit/cca5625df7e432da8581a4504306baad154deb48)) +- re-enable add to queue and play next support, favorite button query exceptions ([e529c79](https://github.com/KRTirtho/spotube/commit/e529c79c4f0cd964b7d89e010d3fe51378ea7222)) +- re-enable download manager ([ea45c4f](https://github.com/KRTirtho/spotube/commit/ea45c4f42ae89b8991e470e84a5290b3be3b0f36)) +- remove unnecessary broadcast stream conversions ([bf04962](https://github.com/KRTirtho/spotube/commit/bf04962e90ea5345a2bc0d4793999f7db712cab2)) +- remove useBreakpoints as it clogs up memory with unnecessary state updates ([e1c0f5c](https://github.com/KRTirtho/spotube/commit/e1c0f5cf1e4cece2c4aa235bfbf8511ad7b1fe59)) +- replace download multiple pops and add translations ([4a21249](https://github.com/KRTirtho/spotube/commit/4a21249ee386426b0974451542a93e84f532fb3f)) +- screen breakpoints and persist lyrics delay across screens ([df79638](https://github.com/KRTirtho/spotube/commit/df79638fb622a55aaa2b36c9a1425c2d9c4a8e52)) +- sidebar task counter badge and bottom player play button progress color ([af278d8](https://github.com/KRTirtho/spotube/commit/af278d8feaa08c14528627766bce6d724b846954)) +- status bar color of playlist/album page ([65fa3cb](https://github.com/KRTirtho/spotube/commit/65fa3cb624c240360de5a06778a1f72ad10bbe2d)) +- system color scheme not persisting on restart when system color scheme changed ([e04515d](https://github.com/KRTirtho/spotube/commit/e04515d8e213b4c7f85d11385959a33b042bd9b1)) +- track collection view status bar not transparent ([9251121](https://github.com/KRTirtho/spotube/commit/9251121ba0154599975e33819a43719477c644f8)) +- track doesn't play after change ([17e5ab6](https://github.com/KRTirtho/spotube/commit/17e5ab611cc417cce7c17cafc7045f5aa2eb970e)) +- track stops at last second ([f554f6d](https://github.com/KRTirtho/spotube/commit/f554f6d43bb714f662a27977f501d7ad44b070c3)) +- **track_collection_view:** keyboard focus on scroll and no space for search results in playlist/album ([7a8bd92](https://github.com/KRTirtho/spotube/commit/7a8bd921047e3766dbbf24449e2873afe3dbecf8)) +- track_table_view table headers ([d88d287](https://github.com/KRTirtho/spotube/commit/d88d287fc586ec33351de9f3b4359f189054868b)) +- track_tile active and blacklist color, playbutton card action positioning ([3f5a1b9](https://github.com/KRTirtho/spotube/commit/3f5a1b9587efe9b7b2c69008345867933f79ec67)) +- use id based source getters instead of index ([a074463](https://github.com/KRTirtho/spotube/commit/a0744630ba2dc713babdb8db6500f9dd0f1e6096)) ### [2.7.1](https://github.com/KRTirtho/spotube/compare/v2.7.0...v2.7.1) (2023-04-10) - ### Bug Fixes -* fallback for lyrics when anonymous ([f160ec7](https://github.com/KRTirtho/spotube/commit/f160ec767d9941d33f83aba1752b28df629d0e10)) -* **android:** audio notification stuck in play state ([448c9b3](https://github.com/KRTirtho/spotube/commit/448c9b39f407668ad92a695afe3c9741baeca20d)) -* **macos:** crashing on startup ([c46b428](https://github.com/KRTirtho/spotube/commit/c46b4284b1d46a614cbcebc8c2f2e52714921b9b)) -* spotify query hooks overriding default query params ([ec9a02e](https://github.com/KRTirtho/spotube/commit/ec9a02e8b8d988e15ed58027054d2a9090d98873)) +- fallback for lyrics when anonymous ([f160ec7](https://github.com/KRTirtho/spotube/commit/f160ec767d9941d33f83aba1752b28df629d0e10)) +- **android:** audio notification stuck in play state ([448c9b3](https://github.com/KRTirtho/spotube/commit/448c9b39f407668ad92a695afe3c9741baeca20d)) +- **macos:** crashing on startup ([c46b428](https://github.com/KRTirtho/spotube/commit/c46b4284b1d46a614cbcebc8c2f2e52714921b9b)) +- spotify query hooks overriding default query params ([ec9a02e](https://github.com/KRTirtho/spotube/commit/ec9a02e8b8d988e15ed58027054d2a9090d98873)) ## [2.7.0](https://github.com/KRTirtho/spotube/compare/v2.6.0...v2.7.0) (2023-03-07) - ### Features -* add or remove track, playlist or album to queue support ([b8f3493](https://github.com/KRTirtho/spotube/commit/b8f3493138a9acd91d19efe67cfd1c0c7c269ae6)) -* basic command line argument support ([025c1ae](https://github.com/KRTirtho/spotube/commit/025c1ae20461c2ac9124b3ef41e21ff01f100498)) -* black list artist or track ([947c143](https://github.com/KRTirtho/spotube/commit/947c14353e15227400a6310673f3b850b2ff024f)) -* bring pre download on desktop, disable pre download for long videos ([1d82bb0](https://github.com/KRTirtho/spotube/commit/1d82bb098717c7321d3e338f071c7661987fc3be)) -* category/genre filter ([1dfec05](https://github.com/KRTirtho/spotube/commit/1dfec05eec7ee60cc9f6a3a97af37aef112063f1)) -* centralized icon collection with new icon set and nav bar labels hidden ([e7acb9e](https://github.com/KRTirtho/spotube/commit/e7acb9ed5cb02826b8da559818f1fccfcf7f143c)) -* compact search bar for genres and user_local_tracks page ([c343ccc](https://github.com/KRTirtho/spotube/commit/c343ccc2932868e3c1205d8cc625a9dfe9d78707)) -* compatibility with fl-query nextPage method change ([7617439](https://github.com/KRTirtho/spotube/commit/761743991520609dd2b2dcb12cd6e4e75a8f6925)) -* configure pocketbase, generate dart types, update playback to use server instead of hive cache ([ad90c11](https://github.com/KRTirtho/spotube/commit/ad90c11ab0c9f1aaba9ae9226d6076ea590f1a29)) -* failsafe pocketbase requests, removal of unneeded preferences options & vertical playbutton actions ([d68d150](https://github.com/KRTirtho/spotube/commit/d68d150d3f42260f889d86927378c2f746bb6993)) -* **home:** personalized section ([9080441](https://github.com/KRTirtho/spotube/commit/9080441b875ceb91260bbad79291365a98d5be95)) -* individual shuffle and repeat/loop button of player ([f79223c](https://github.com/KRTirtho/spotube/commit/f79223cd41c61d9836d25e7bc2811c6515ba00c8)) -* **lyrics:** use official spotify API for fetching lyrics and add zoom controls ([10d0660](https://github.com/KRTirtho/spotube/commit/10d0660972f008df0d11c280b681ce3b78f05d0b)) -* **mobile:** pull to refresh support in all refreshable list views ([9f959ce](https://github.com/KRTirtho/spotube/commit/9f959ce77cd95cfc34d01af1f5cf53dd4206b6a6)) -* new logo and compact search in playlist/album in mobile ([dc96cb3](https://github.com/KRTirtho/spotube/commit/dc96cb38cea8dc13738083f4850d22792d071019)) -* search/filter tracks inside playlist or album ([a06cd0d](https://github.com/KRTirtho/spotube/commit/a06cd0da84cc03a2a7cadbc80d70556cb0cf9310)) -* show snackbar on adding playlist or tracks to queue ([6bc1d32](https://github.com/KRTirtho/spotube/commit/6bc1d32a88ae516f77d149b83bcd536d2c888513)) -* **theme:** use material3 monet for colors and remove background color preference ([60ede5f](https://github.com/KRTirtho/spotube/commit/60ede5f92b732691d53850290d9667435298a857)) -* use catcher to handle exceptions ([84d94b0](https://github.com/KRTirtho/spotube/commit/84d94b05bc269a1676a261df2b12e508e10e4c0e)) -* use typed assets instead of hard coded paths ([59561ab](https://github.com/KRTirtho/spotube/commit/59561abdc2540576fc95b34b3b55def63567000a)) -* user local tracks searchbar ([e7f3f4e](https://github.com/KRTirtho/spotube/commit/e7f3f4eae49fe27a52fc3866fa4f6f2efb2aa479)) -* **user-library:** filtering support for user albums and user artists ([0b58155](https://github.com/KRTirtho/spotube/commit/0b58155d434f2de6359be77d7beee4484dbb7b2a)) -* **user-library:** search for user playlists ([af4d56f](https://github.com/KRTirtho/spotube/commit/af4d56fd41e57cbe6d87883e87e6b4469aaba52f)) - +- add or remove track, playlist or album to queue support ([b8f3493](https://github.com/KRTirtho/spotube/commit/b8f3493138a9acd91d19efe67cfd1c0c7c269ae6)) +- basic command line argument support ([025c1ae](https://github.com/KRTirtho/spotube/commit/025c1ae20461c2ac9124b3ef41e21ff01f100498)) +- black list artist or track ([947c143](https://github.com/KRTirtho/spotube/commit/947c14353e15227400a6310673f3b850b2ff024f)) +- bring pre download on desktop, disable pre download for long videos ([1d82bb0](https://github.com/KRTirtho/spotube/commit/1d82bb098717c7321d3e338f071c7661987fc3be)) +- category/genre filter ([1dfec05](https://github.com/KRTirtho/spotube/commit/1dfec05eec7ee60cc9f6a3a97af37aef112063f1)) +- centralized icon collection with new icon set and nav bar labels hidden ([e7acb9e](https://github.com/KRTirtho/spotube/commit/e7acb9ed5cb02826b8da559818f1fccfcf7f143c)) +- compact search bar for genres and user_local_tracks page ([c343ccc](https://github.com/KRTirtho/spotube/commit/c343ccc2932868e3c1205d8cc625a9dfe9d78707)) +- compatibility with fl-query nextPage method change ([7617439](https://github.com/KRTirtho/spotube/commit/761743991520609dd2b2dcb12cd6e4e75a8f6925)) +- configure pocketbase, generate dart types, update playback to use server instead of hive cache ([ad90c11](https://github.com/KRTirtho/spotube/commit/ad90c11ab0c9f1aaba9ae9226d6076ea590f1a29)) +- failsafe pocketbase requests, removal of unneeded preferences options & vertical playbutton actions ([d68d150](https://github.com/KRTirtho/spotube/commit/d68d150d3f42260f889d86927378c2f746bb6993)) +- **home:** personalized section ([9080441](https://github.com/KRTirtho/spotube/commit/9080441b875ceb91260bbad79291365a98d5be95)) +- individual shuffle and repeat/loop button of player ([f79223c](https://github.com/KRTirtho/spotube/commit/f79223cd41c61d9836d25e7bc2811c6515ba00c8)) +- **lyrics:** use official spotify API for fetching lyrics and add zoom controls ([10d0660](https://github.com/KRTirtho/spotube/commit/10d0660972f008df0d11c280b681ce3b78f05d0b)) +- **mobile:** pull to refresh support in all refreshable list views ([9f959ce](https://github.com/KRTirtho/spotube/commit/9f959ce77cd95cfc34d01af1f5cf53dd4206b6a6)) +- new logo and compact search in playlist/album in mobile ([dc96cb3](https://github.com/KRTirtho/spotube/commit/dc96cb38cea8dc13738083f4850d22792d071019)) +- search/filter tracks inside playlist or album ([a06cd0d](https://github.com/KRTirtho/spotube/commit/a06cd0da84cc03a2a7cadbc80d70556cb0cf9310)) +- show snackbar on adding playlist or tracks to queue ([6bc1d32](https://github.com/KRTirtho/spotube/commit/6bc1d32a88ae516f77d149b83bcd536d2c888513)) +- **theme:** use material3 monet for colors and remove background color preference ([60ede5f](https://github.com/KRTirtho/spotube/commit/60ede5f92b732691d53850290d9667435298a857)) +- use catcher to handle exceptions ([84d94b0](https://github.com/KRTirtho/spotube/commit/84d94b05bc269a1676a261df2b12e508e10e4c0e)) +- use typed assets instead of hard coded paths ([59561ab](https://github.com/KRTirtho/spotube/commit/59561abdc2540576fc95b34b3b55def63567000a)) +- user local tracks searchbar ([e7f3f4e](https://github.com/KRTirtho/spotube/commit/e7f3f4eae49fe27a52fc3866fa4f6f2efb2aa479)) +- **user-library:** filtering support for user albums and user artists ([0b58155](https://github.com/KRTirtho/spotube/commit/0b58155d434f2de6359be77d7beee4484dbb7b2a)) +- **user-library:** search for user playlists ([af4d56f](https://github.com/KRTirtho/spotube/commit/af4d56fd41e57cbe6d87883e87e6b4469aaba52f)) ### Bug Fixes -* **about:** license text hidden in the bottom of smaller screen devices ([e158dd0](https://github.com/KRTirtho/spotube/commit/e158dd0cec5657e495b538e86c412b06974a9f49)) -* **about:** wrong link of License ([a4a7f1a](https://github.com/KRTirtho/spotube/commit/a4a7f1a74f9df82927403ca93aec508a13315ae8)) -* genre and sidebar user logo not loading ([710f172](https://github.com/KRTirtho/spotube/commit/710f172dee45f60ed3e5ed83017eb538d6a626bf)) -* lyrics modal sheet out of safe area so use 80% of screen height instead of full ([3db28f4](https://github.com/KRTirtho/spotube/commit/3db28f43b4200d03f7758e8c395d8430e0f89333)) -* lyrics not changing on track change ([c809d2d](https://github.com/KRTirtho/spotube/commit/c809d2daba4beaea7c4f16c6bb0edef9efa825b8)) -* lyrics not refetching when tracked changed while being in another page and sidebar user avatar not showing on startup ([bd12675](https://github.com/KRTirtho/spotube/commit/bd126751e9594fbc926bbcad7b9a2c577fce074a)) -* macOS logo placement ([c6a5d5f](https://github.com/KRTirtho/spotube/commit/c6a5d5f7b1b1fad3a0b5e63c02c847a149e72efe)) -* mobile track collection search bar position and page_window_title_bar exception on mobile platforms ([d0aaa97](https://github.com/KRTirtho/spotube/commit/d0aaa971fe358b9cb5dc7a35cc82eaf6520f7ab4)) -* **play_overlay:** show progress indicator on song loading ([7803a48](https://github.com/KRTirtho/spotube/commit/7803a48237c91f2a57bcc86fbd30ad879142c8ff)) -* **playback:** not skipping track's sponsorblock segments ([60a5847](https://github.com/KRTirtho/spotube/commit/60a5847ae68836bbbeef748254c674c81fa5c3ea)) -* playbutton card play state not changing ([ee46d09](https://github.com/KRTirtho/spotube/commit/ee46d0970be9e227793494a41e25c0c469847cd0)) -* **playbutton_card:** play and add to queue needs 2 clicks work ([bdd7098](https://github.com/KRTirtho/spotube/commit/bdd70984e6670813e508786e74cd2ea4a1fe1d53)) -* **playbutton_card:** play and non play state correction ([b327ffb](https://github.com/KRTirtho/spotube/commit/b327ffb1084b43e5c78e13994f65fb30b3a7e67e)) -* **playbutton_card:** title text overflow ([39ee0a9](https://github.com/KRTirtho/spotube/commit/39ee0a92a8f3d74d243db206fe034330f75c0588)) -* **playbutton:** playing state is not updating when playlist is actually playing ([9bad8c9](https://github.com/KRTirtho/spotube/commit/9bad8c9eb88f7c91091a669b642b92474df0f128)) -* **player_queue:** large clear button and macos exception ([0e43504](https://github.com/KRTirtho/spotube/commit/0e43504e18d2315fb1b7975b67bd2c596cbfb1bc)) -* **playlist_queue:** load method not preserving the active track before filtering blacklisted tracks ([42b3e11](https://github.com/KRTirtho/spotube/commit/42b3e111f844f6de6a145de2760ccfd7e97e623b)) -* pre downloading not working properly, audio service circular deps and sibling not loading for backend track ([3ccb525](https://github.com/KRTirtho/spotube/commit/3ccb525260a83ba54021a353b15ed3cda6e9c876)) -* search track play button isn't working ([0751f5e](https://github.com/KRTirtho/spotube/commit/0751f5e3173882f3aeed67027854e5054b689693)) -* **search:** grey screen, only tracks update on new search string, playlists,albums,artists show up before hitting return/submit ([a774817](https://github.com/KRTirtho/spotube/commit/a774817240ef813cb95f82f53ccb798ef9acb51d)) -* **search:** has to submit twice for search results ([f5dc76a](https://github.com/KRTirtho/spotube/commit/f5dc76a98f55f0f032a6fe4208465899f932355a)) -* titlebar maximize+restore button not working and less responsive title bar buttons ([8a6ba3b](https://github.com/KRTirtho/spotube/commit/8a6ba3b35f0b6b42cf60920e945ac2065c886ecb)) -* **track_collection_view:** hide search bar when sliver is collapsed ([3d6d244](https://github.com/KRTirtho/spotube/commit/3d6d2444beed153a2b6663d6153684b2974f4152)) -* **track_tile:** cannot see track index above 99 ([78b3273](https://github.com/KRTirtho/spotube/commit/78b3273e441cdaa6d4a410ddfe29837dc1aa7000)) -* **track_tile:** track action popup not showing on narrow screens ([0c54f2d](https://github.com/KRTirtho/spotube/commit/0c54f2dcd4474b63db4c517b0e7332cbd3ab51e9)) -* **ui:** scaffold exception in fluent_ui ([8ce2192](https://github.com/KRTirtho/spotube/commit/8ce2192e5cb08e3a8be5ead510ab35b274bef2ef)) -* use chosen market for new release ([c6bf9b6](https://github.com/KRTirtho/spotube/commit/c6bf9b67995161a8bf7c3782188d01e8859c18e9)) +- **about:** license text hidden in the bottom of smaller screen devices ([e158dd0](https://github.com/KRTirtho/spotube/commit/e158dd0cec5657e495b538e86c412b06974a9f49)) +- **about:** wrong link of License ([a4a7f1a](https://github.com/KRTirtho/spotube/commit/a4a7f1a74f9df82927403ca93aec508a13315ae8)) +- genre and sidebar user logo not loading ([710f172](https://github.com/KRTirtho/spotube/commit/710f172dee45f60ed3e5ed83017eb538d6a626bf)) +- lyrics modal sheet out of safe area so use 80% of screen height instead of full ([3db28f4](https://github.com/KRTirtho/spotube/commit/3db28f43b4200d03f7758e8c395d8430e0f89333)) +- lyrics not changing on track change ([c809d2d](https://github.com/KRTirtho/spotube/commit/c809d2daba4beaea7c4f16c6bb0edef9efa825b8)) +- lyrics not refetching when tracked changed while being in another page and sidebar user avatar not showing on startup ([bd12675](https://github.com/KRTirtho/spotube/commit/bd126751e9594fbc926bbcad7b9a2c577fce074a)) +- macOS logo placement ([c6a5d5f](https://github.com/KRTirtho/spotube/commit/c6a5d5f7b1b1fad3a0b5e63c02c847a149e72efe)) +- mobile track collection search bar position and page_window_title_bar exception on mobile platforms ([d0aaa97](https://github.com/KRTirtho/spotube/commit/d0aaa971fe358b9cb5dc7a35cc82eaf6520f7ab4)) +- **play_overlay:** show progress indicator on song loading ([7803a48](https://github.com/KRTirtho/spotube/commit/7803a48237c91f2a57bcc86fbd30ad879142c8ff)) +- **playback:** not skipping track's sponsorblock segments ([60a5847](https://github.com/KRTirtho/spotube/commit/60a5847ae68836bbbeef748254c674c81fa5c3ea)) +- playbutton card play state not changing ([ee46d09](https://github.com/KRTirtho/spotube/commit/ee46d0970be9e227793494a41e25c0c469847cd0)) +- **playbutton_card:** play and add to queue needs 2 clicks work ([bdd7098](https://github.com/KRTirtho/spotube/commit/bdd70984e6670813e508786e74cd2ea4a1fe1d53)) +- **playbutton_card:** play and non play state correction ([b327ffb](https://github.com/KRTirtho/spotube/commit/b327ffb1084b43e5c78e13994f65fb30b3a7e67e)) +- **playbutton_card:** title text overflow ([39ee0a9](https://github.com/KRTirtho/spotube/commit/39ee0a92a8f3d74d243db206fe034330f75c0588)) +- **playbutton:** playing state is not updating when playlist is actually playing ([9bad8c9](https://github.com/KRTirtho/spotube/commit/9bad8c9eb88f7c91091a669b642b92474df0f128)) +- **player_queue:** large clear button and macos exception ([0e43504](https://github.com/KRTirtho/spotube/commit/0e43504e18d2315fb1b7975b67bd2c596cbfb1bc)) +- **playlist_queue:** load method not preserving the active track before filtering blacklisted tracks ([42b3e11](https://github.com/KRTirtho/spotube/commit/42b3e111f844f6de6a145de2760ccfd7e97e623b)) +- pre downloading not working properly, audio service circular deps and sibling not loading for backend track ([3ccb525](https://github.com/KRTirtho/spotube/commit/3ccb525260a83ba54021a353b15ed3cda6e9c876)) +- search track play button isn't working ([0751f5e](https://github.com/KRTirtho/spotube/commit/0751f5e3173882f3aeed67027854e5054b689693)) +- **search:** grey screen, only tracks update on new search string, playlists,albums,artists show up before hitting return/submit ([a774817](https://github.com/KRTirtho/spotube/commit/a774817240ef813cb95f82f53ccb798ef9acb51d)) +- **search:** has to submit twice for search results ([f5dc76a](https://github.com/KRTirtho/spotube/commit/f5dc76a98f55f0f032a6fe4208465899f932355a)) +- titlebar maximize+restore button not working and less responsive title bar buttons ([8a6ba3b](https://github.com/KRTirtho/spotube/commit/8a6ba3b35f0b6b42cf60920e945ac2065c886ecb)) +- **track_collection_view:** hide search bar when sliver is collapsed ([3d6d244](https://github.com/KRTirtho/spotube/commit/3d6d2444beed153a2b6663d6153684b2974f4152)) +- **track_tile:** cannot see track index above 99 ([78b3273](https://github.com/KRTirtho/spotube/commit/78b3273e441cdaa6d4a410ddfe29837dc1aa7000)) +- **track_tile:** track action popup not showing on narrow screens ([0c54f2d](https://github.com/KRTirtho/spotube/commit/0c54f2dcd4474b63db4c517b0e7332cbd3ab51e9)) +- **ui:** scaffold exception in fluent_ui ([8ce2192](https://github.com/KRTirtho/spotube/commit/8ce2192e5cb08e3a8be5ead510ab35b274bef2ef)) +- use chosen market for new release ([c6bf9b6](https://github.com/KRTirtho/spotube/commit/c6bf9b67995161a8bf7c3782188d01e8859c18e9)) ## [2.6.0](https://github.com/KRTirtho/spotube/compare/v2.5.0...v2.6.0) (2022-12-09) - ### Features -* add selected tracks to playlists, optimistic playlist remove track ([3386dac](https://github.com/KRTirtho/spotube/commit/3386dac78ee49b9e3504f5c05bf1e7b362a2e8a2)) -* added shuffle button in playlist and album section ([1fad95f](https://github.com/KRTirtho/spotube/commit/1fad95f6e370606a9faf6f2bb9738dc360e23918)) -* **android-playback:** option to download track bytes and play instead of Streaming ([dcc8ba5](https://github.com/KRTirtho/spotube/commit/dcc8ba5a54286b252d53c9c14918971bf7bea8cc)) -* change default platform option and platform specific back button ([36c5e02](https://github.com/KRTirtho/spotube/commit/36c5e02f18374100f61cc3f2957c27bfc0d8511f)) -* dialog logo for macos, settings more width for country picker ([5e96913](https://github.com/KRTirtho/spotube/commit/5e96913ba34230e7a15d62060d5dae28d80e3630)) -* initial platform_ui integration ([9eee573](https://github.com/KRTirtho/spotube/commit/9eee573ce928aa6c03dcb50bf1521350d2de32cc)) -* libadwaita theming, track tile and PlayButtonCard play button icon fix ([e795e23](https://github.com/KRTirtho/spotube/commit/e795e23e42e5f1f832963b2a4506df89b7df5baa)) -* **lyrics:** tabs for both synced and static lyrics [#182](https://github.com/KRTirtho/spotube/issues/182) ([6b6907a](https://github.com/KRTirtho/spotube/commit/6b6907af3fdb327312ad4dd9e16b3e2a850ed896)) -* new refined about page, update checker only check for same update channel ([4cadfa9](https://github.com/KRTirtho/spotube/commit/4cadfa93750cc9b3f8fbe7b60f8161a77d2a12f6)) -* pause track when seeking forward/back and keep audio session alive when paused/interrupted ([bc8a04e](https://github.com/KRTirtho/spotube/commit/bc8a04e5442ba3abe1b04ab325769559f37d9802)) -* platform bottom navigation bar add ([ff14469](https://github.com/KRTirtho/spotube/commit/ff1446982f0260c6fe231970aa6ed61c273fdf07)) -* platform slider and progress indicator integration ([46b00ba](https://github.com/KRTirtho/spotube/commit/46b00bafdf71a4add61cf50168a32198c3293181)) -* platform title bar buttons add ([54048cb](https://github.com/KRTirtho/spotube/commit/54048cbfc37d9a40c020eb81ab67b9dd5428f0d7)) -* **playback:** change current track youtube source panel and tooltips for player icon buttons ([4b21cc8](https://github.com/KRTirtho/spotube/commit/4b21cc829954fb079d1a0081b9147377063da3ec)) -* Player and Playbutton theme respect to platform ([512446d](https://github.com/KRTirtho/spotube/commit/512446dcab72aa1d7bce18e5a5793f6be8f30fcb)) -* player queue and sibling tracks platform decoration ([39a7794](https://github.com/KRTirtho/spotube/commit/39a77945d132d90ff323b0a22edc2a12a4749888)) -* **PlayerView:** shortcut button for opening lyrics [#273](https://github.com/KRTirtho/spotube/issues/273) ([1d4847a](https://github.com/KRTirtho/spotube/commit/1d4847ab0a0b18d5bc27257b3db863b995dc5843)) -* rename files to snake_case and reorganize folder structure ([7c25e1c](https://github.com/KRTirtho/spotube/commit/7c25e1cc8a35eb8aee2268799c05f299204fa3f5)) -* replace all types of buttons with platform buttons ([69739b4](https://github.com/KRTirtho/spotube/commit/69739b457296a6b209aa6f73beb378ae1f089ac5)) -* rpm packaging support ([067e9ac](https://github.com/KRTirtho/spotube/commit/067e9ac53ee85775c9ec35457fac9e064e72e4c0)) -* **search:** infinite scroll for tracks, artists, playlists and albums ([e6761a6](https://github.com/KRTirtho/spotube/commit/e6761a6f8eadf4ab260723253a8e00121b6365b5)) -* set platform to default platform on start up ([472da6b](https://github.com/KRTirtho/spotube/commit/472da6b8b1c3e06b666da58351f3feafbfe6c98a)) -* shuffle keep playing track at top, linux title bar drag no working ([1223cf2](https://github.com/KRTirtho/spotube/commit/1223cf2629c6615b0c48e9e6742b68341f33c7f8)) -* sidebar download count and proper progress color in playbutton ([a10bc5b](https://github.com/KRTirtho/spotube/commit/a10bc5b8d89207ac86872dd25f3258e47c2141a5)) -* static shimmer for track tile, playbutton card and track tile ([3ed8b0f](https://github.com/KRTirtho/spotube/commit/3ed8b0fda2cb8145a2bc6c8f8c6af82db6a40547)) -* tablet mode navigation bar & windows semi transparent bg, ([3282370](https://github.com/KRTirtho/spotube/commit/3282370f74f323c00116fa8626fd1440ee9d4922)) -* **title_bar:** platform specific title bar ([e659e3c](https://github.com/KRTirtho/spotube/commit/e659e3c56fb02ad8a1c5114bf80074f0324cc4f1)) -* titlebar complete compatibility, platform specific login, library tabbar in titlebar ([b3c27d1](https://github.com/KRTirtho/spotube/commit/b3c27d1fca233ea079803fe49134abc528376df3)) -* use platform checkbox ([2211505](https://github.com/KRTirtho/spotube/commit/2211505d713cef752d07cafb9791a49e8095eee2)) -* window blur effect add ([b0db5e7](https://github.com/KRTirtho/spotube/commit/b0db5e7d8246f98835e7ce6656c8aa7620e46bec)) - +- add selected tracks to playlists, optimistic playlist remove track ([3386dac](https://github.com/KRTirtho/spotube/commit/3386dac78ee49b9e3504f5c05bf1e7b362a2e8a2)) +- added shuffle button in playlist and album section ([1fad95f](https://github.com/KRTirtho/spotube/commit/1fad95f6e370606a9faf6f2bb9738dc360e23918)) +- **android-playback:** option to download track bytes and play instead of Streaming ([dcc8ba5](https://github.com/KRTirtho/spotube/commit/dcc8ba5a54286b252d53c9c14918971bf7bea8cc)) +- change default platform option and platform specific back button ([36c5e02](https://github.com/KRTirtho/spotube/commit/36c5e02f18374100f61cc3f2957c27bfc0d8511f)) +- dialog logo for macos, settings more width for country picker ([5e96913](https://github.com/KRTirtho/spotube/commit/5e96913ba34230e7a15d62060d5dae28d80e3630)) +- initial platform_ui integration ([9eee573](https://github.com/KRTirtho/spotube/commit/9eee573ce928aa6c03dcb50bf1521350d2de32cc)) +- libadwaita theming, track tile and PlayButtonCard play button icon fix ([e795e23](https://github.com/KRTirtho/spotube/commit/e795e23e42e5f1f832963b2a4506df89b7df5baa)) +- **lyrics:** tabs for both synced and static lyrics [#182](https://github.com/KRTirtho/spotube/issues/182) ([6b6907a](https://github.com/KRTirtho/spotube/commit/6b6907af3fdb327312ad4dd9e16b3e2a850ed896)) +- new refined about page, update checker only check for same update channel ([4cadfa9](https://github.com/KRTirtho/spotube/commit/4cadfa93750cc9b3f8fbe7b60f8161a77d2a12f6)) +- pause track when seeking forward/back and keep audio session alive when paused/interrupted ([bc8a04e](https://github.com/KRTirtho/spotube/commit/bc8a04e5442ba3abe1b04ab325769559f37d9802)) +- platform bottom navigation bar add ([ff14469](https://github.com/KRTirtho/spotube/commit/ff1446982f0260c6fe231970aa6ed61c273fdf07)) +- platform slider and progress indicator integration ([46b00ba](https://github.com/KRTirtho/spotube/commit/46b00bafdf71a4add61cf50168a32198c3293181)) +- platform title bar buttons add ([54048cb](https://github.com/KRTirtho/spotube/commit/54048cbfc37d9a40c020eb81ab67b9dd5428f0d7)) +- **playback:** change current track youtube source panel and tooltips for player icon buttons ([4b21cc8](https://github.com/KRTirtho/spotube/commit/4b21cc829954fb079d1a0081b9147377063da3ec)) +- Player and Playbutton theme respect to platform ([512446d](https://github.com/KRTirtho/spotube/commit/512446dcab72aa1d7bce18e5a5793f6be8f30fcb)) +- player queue and sibling tracks platform decoration ([39a7794](https://github.com/KRTirtho/spotube/commit/39a77945d132d90ff323b0a22edc2a12a4749888)) +- **PlayerView:** shortcut button for opening lyrics [#273](https://github.com/KRTirtho/spotube/issues/273) ([1d4847a](https://github.com/KRTirtho/spotube/commit/1d4847ab0a0b18d5bc27257b3db863b995dc5843)) +- rename files to snake_case and reorganize folder structure ([7c25e1c](https://github.com/KRTirtho/spotube/commit/7c25e1cc8a35eb8aee2268799c05f299204fa3f5)) +- replace all types of buttons with platform buttons ([69739b4](https://github.com/KRTirtho/spotube/commit/69739b457296a6b209aa6f73beb378ae1f089ac5)) +- rpm packaging support ([067e9ac](https://github.com/KRTirtho/spotube/commit/067e9ac53ee85775c9ec35457fac9e064e72e4c0)) +- **search:** infinite scroll for tracks, artists, playlists and albums ([e6761a6](https://github.com/KRTirtho/spotube/commit/e6761a6f8eadf4ab260723253a8e00121b6365b5)) +- set platform to default platform on start up ([472da6b](https://github.com/KRTirtho/spotube/commit/472da6b8b1c3e06b666da58351f3feafbfe6c98a)) +- shuffle keep playing track at top, linux title bar drag no working ([1223cf2](https://github.com/KRTirtho/spotube/commit/1223cf2629c6615b0c48e9e6742b68341f33c7f8)) +- sidebar download count and proper progress color in playbutton ([a10bc5b](https://github.com/KRTirtho/spotube/commit/a10bc5b8d89207ac86872dd25f3258e47c2141a5)) +- static shimmer for track tile, playbutton card and track tile ([3ed8b0f](https://github.com/KRTirtho/spotube/commit/3ed8b0fda2cb8145a2bc6c8f8c6af82db6a40547)) +- tablet mode navigation bar & windows semi transparent bg, ([3282370](https://github.com/KRTirtho/spotube/commit/3282370f74f323c00116fa8626fd1440ee9d4922)) +- **title_bar:** platform specific title bar ([e659e3c](https://github.com/KRTirtho/spotube/commit/e659e3c56fb02ad8a1c5114bf80074f0324cc4f1)) +- titlebar complete compatibility, platform specific login, library tabbar in titlebar ([b3c27d1](https://github.com/KRTirtho/spotube/commit/b3c27d1fca233ea079803fe49134abc528376df3)) +- use platform checkbox ([2211505](https://github.com/KRTirtho/spotube/commit/2211505d713cef752d07cafb9791a49e8095eee2)) +- window blur effect add ([b0db5e7](https://github.com/KRTirtho/spotube/commit/b0db5e7d8246f98835e7ce6656c8aa7620e46bec)) ### Bug Fixes -* **ArtistCard:** linux shadow ([c186881](https://github.com/KRTirtho/spotube/commit/c1868817e5abb8a4152646f00a0395933fee7823)) -* **auth:** refresh access token timer not working ([b3ac5ca](https://github.com/KRTirtho/spotube/commit/b3ac5ca3bbb6d5af154f4b5d715d1f19ca2f46e2)) -* bottom navigation bar settings tile not active when selected ([43557e4](https://github.com/KRTirtho/spotube/commit/43557e40df269757c2d5236a455308ea6478d95a)) -* dialog logo in android, lyrics visible timer adjust button ([3c6803b](https://github.com/KRTirtho/spotube/commit/3c6803bb3fac8eee9166764089724194a48509c6)) -* heart button showing when not logged in, wrong login redirect ([4dc26af](https://github.com/KRTirtho/spotube/commit/4dc26af23d12f76cbfdfbf4e37b0c11fcc484d3f)) -* horizontal infinite lists doesn't fill the screen ([69995be](https://github.com/KRTirtho/spotube/commit/69995bea1c6342c9212e5b22ef50bdfd6e7eba45)) -* ios dialog action buttons, local tracks crashing app, shimmer color and android wrong status bar color ([90c1200](https://github.com/KRTirtho/spotube/commit/90c1200a087f796690de0cfc8cc607d2bff44282)) -* **login:** not working in android in Brazil or Ukraine regions ([0b79a11](https://github.com/KRTirtho/spotube/commit/0b79a1181c37cf06fbfa3bfb3854cfd47097016e)) -* **macos:** black text in dark mode ([fb9c0e4](https://github.com/KRTirtho/spotube/commit/fb9c0e44be93997fc852bf0260e8a8608000c023)) -* **macos:** white text color in dark mode, text field white background ([e086b52](https://github.com/KRTirtho/spotube/commit/e086b520e745e65771136cbfa842ae0693c44872)) -* **mobile:** SafeArea bugs and back button color ([a8330ef](https://github.com/KRTirtho/spotube/commit/a8330ef2e1112012bbae19ee6a5c27a26c5fb719)) -* null exception in themes ([9465d92](https://github.com/KRTirtho/spotube/commit/9465d92fa032b8598a0752767dcec9af2541d222)) -* platform_ui local path ([00d0d38](https://github.com/KRTirtho/spotube/commit/00d0d38b5450aeb877195afdfb9424f83762d178)) -* player view artist link when local playlist is playing, lyric delay adjust button alignment ([ee5c417](https://github.com/KRTirtho/spotube/commit/ee5c417ac396ef0b1796fa74a6a494181e6e0396)) -* remove windows background ([6942964](https://github.com/KRTirtho/spotube/commit/694296418787c460bb3fa63ab30f3b0eed9184dc)) -* search field ios dark icon , lyrics tabbar ios background color ([be56ad4](https://github.com/KRTirtho/spotube/commit/be56ad44773ebcd14777d80b61e26875698dc18a)) -* settings Title alignment and play button card ripple effect in other platforms ([3b6bf27](https://github.com/KRTirtho/spotube/commit/3b6bf27a984f5d4836143638396ed4b467c0eae7)) -* shuffle play logic ([65cad07](https://github.com/KRTirtho/spotube/commit/65cad07e3a6e2188c53159057f9c3d4fe89706ea)) -* small minwidth of window in desktop, linux wrong light theme accent color, search field transparent background ([5b0e22c](https://github.com/KRTirtho/spotube/commit/5b0e22c1b639f2f57d92cb70cd11f56e30e0a457)) -* tooltips of menu and adaptive pop up menu ([261aaf1](https://github.com/KRTirtho/spotube/commit/261aaf191c51bc12b28c602ee160d53d3eacf3a5)) -* update download dialog blocking the UI ([3925f74](https://github.com/KRTirtho/spotube/commit/3925f743951e51f138cc3ca865fa167c34e776ef)) -* user playlists not updating after creating/deleting, artist follow not updating after follow/unfollow ([6cc2a18](https://github.com/KRTirtho/spotube/commit/6cc2a185d0c4c19f176e6f65b8ada19ebc76af5e)) -* **windows:** windows global title bar ([bd18f19](https://github.com/KRTirtho/spotube/commit/bd18f198217538f0089d5a1c4288dd97f982661b)) +- **ArtistCard:** linux shadow ([c186881](https://github.com/KRTirtho/spotube/commit/c1868817e5abb8a4152646f00a0395933fee7823)) +- **auth:** refresh access token timer not working ([b3ac5ca](https://github.com/KRTirtho/spotube/commit/b3ac5ca3bbb6d5af154f4b5d715d1f19ca2f46e2)) +- bottom navigation bar settings tile not active when selected ([43557e4](https://github.com/KRTirtho/spotube/commit/43557e40df269757c2d5236a455308ea6478d95a)) +- dialog logo in android, lyrics visible timer adjust button ([3c6803b](https://github.com/KRTirtho/spotube/commit/3c6803bb3fac8eee9166764089724194a48509c6)) +- heart button showing when not logged in, wrong login redirect ([4dc26af](https://github.com/KRTirtho/spotube/commit/4dc26af23d12f76cbfdfbf4e37b0c11fcc484d3f)) +- horizontal infinite lists doesn't fill the screen ([69995be](https://github.com/KRTirtho/spotube/commit/69995bea1c6342c9212e5b22ef50bdfd6e7eba45)) +- ios dialog action buttons, local tracks crashing app, shimmer color and android wrong status bar color ([90c1200](https://github.com/KRTirtho/spotube/commit/90c1200a087f796690de0cfc8cc607d2bff44282)) +- **login:** not working in android in Brazil or Ukraine regions ([0b79a11](https://github.com/KRTirtho/spotube/commit/0b79a1181c37cf06fbfa3bfb3854cfd47097016e)) +- **macos:** black text in dark mode ([fb9c0e4](https://github.com/KRTirtho/spotube/commit/fb9c0e44be93997fc852bf0260e8a8608000c023)) +- **macos:** white text color in dark mode, text field white background ([e086b52](https://github.com/KRTirtho/spotube/commit/e086b520e745e65771136cbfa842ae0693c44872)) +- **mobile:** SafeArea bugs and back button color ([a8330ef](https://github.com/KRTirtho/spotube/commit/a8330ef2e1112012bbae19ee6a5c27a26c5fb719)) +- null exception in themes ([9465d92](https://github.com/KRTirtho/spotube/commit/9465d92fa032b8598a0752767dcec9af2541d222)) +- platform_ui local path ([00d0d38](https://github.com/KRTirtho/spotube/commit/00d0d38b5450aeb877195afdfb9424f83762d178)) +- player view artist link when local playlist is playing, lyric delay adjust button alignment ([ee5c417](https://github.com/KRTirtho/spotube/commit/ee5c417ac396ef0b1796fa74a6a494181e6e0396)) +- remove windows background ([6942964](https://github.com/KRTirtho/spotube/commit/694296418787c460bb3fa63ab30f3b0eed9184dc)) +- search field ios dark icon , lyrics tabbar ios background color ([be56ad4](https://github.com/KRTirtho/spotube/commit/be56ad44773ebcd14777d80b61e26875698dc18a)) +- settings Title alignment and play button card ripple effect in other platforms ([3b6bf27](https://github.com/KRTirtho/spotube/commit/3b6bf27a984f5d4836143638396ed4b467c0eae7)) +- shuffle play logic ([65cad07](https://github.com/KRTirtho/spotube/commit/65cad07e3a6e2188c53159057f9c3d4fe89706ea)) +- small minwidth of window in desktop, linux wrong light theme accent color, search field transparent background ([5b0e22c](https://github.com/KRTirtho/spotube/commit/5b0e22c1b639f2f57d92cb70cd11f56e30e0a457)) +- tooltips of menu and adaptive pop up menu ([261aaf1](https://github.com/KRTirtho/spotube/commit/261aaf191c51bc12b28c602ee160d53d3eacf3a5)) +- update download dialog blocking the UI ([3925f74](https://github.com/KRTirtho/spotube/commit/3925f743951e51f138cc3ca865fa167c34e776ef)) +- user playlists not updating after creating/deleting, artist follow not updating after follow/unfollow ([6cc2a18](https://github.com/KRTirtho/spotube/commit/6cc2a185d0c4c19f176e6f65b8ada19ebc76af5e)) +- **windows:** windows global title bar ([bd18f19](https://github.com/KRTirtho/spotube/commit/bd18f198217538f0089d5a1c4288dd97f982661b)) ## [2.5.0](https://github.com/KRTirtho/spotube/compare/v2.4.1...v2.5.0) (2022-10-13) - ### Features -* animated transition of root PageWindowTitleBar ([ff35e06](https://github.com/KRTirtho/spotube/commit/ff35e06a6605fc7ec762e716fb7bdf6f7eb45732)) -* **auth:** new authentication flow using cookies and webview in android ([756b910](https://github.com/KRTirtho/spotube/commit/756b91007ee747c10ed10aa7060af49b555a2eaf)) -* **downloader:** replace /skip all choice for downloaded tracks ([88d7ce5](https://github.com/KRTirtho/spotube/commit/88d7ce55a59f673d60cd9e85ab062bcb1b7dcbc3)) -* implemented go_route shell/nested route ([3e498a4](https://github.com/KRTirtho/spotube/commit/3e498a4827a1118e0b23faec7cf114272f7838d4)) -* **keyboard shortcuts:** play/pause on space, seek position on left/right ([2734454](https://github.com/KRTirtho/spotube/commit/2734454717bbfb5d0621c6ea72fa755ef4fc8602)) -* **keyboard-shortcuts:** home sidebar tab navigation and close app ([8f258e7](https://github.com/KRTirtho/spotube/commit/8f258e709ada418dbeef8d272af370b1741afd9c)) -* smoother list using fl_query and waypoint ([c77b0e1](https://github.com/KRTirtho/spotube/commit/c77b0e198b215180d863747e35998a17aff92720)) -* sort tracks in playlist, album and local tracks ([cb4bd25](https://github.com/KRTirtho/spotube/commit/cb4bd25df154455d225c426cfeaaea36ac09e9b7)) -* use of smaller sized images in `TrackTile` ([0ca97b4](https://github.com/KRTirtho/spotube/commit/0ca97b495f2a9ece8356f7813fc0e37d1cdb8608)) -* volume slider mouse scroll and preference for Rotating Album Art [#255](https://github.com/KRTirtho/spotube/issues/255) ([edb6f3c](https://github.com/KRTirtho/spotube/commit/edb6f3cd1c9ee2961040b2fe7a91c48577cee4f7)) - +- animated transition of root PageWindowTitleBar ([ff35e06](https://github.com/KRTirtho/spotube/commit/ff35e06a6605fc7ec762e716fb7bdf6f7eb45732)) +- **auth:** new authentication flow using cookies and webview in android ([756b910](https://github.com/KRTirtho/spotube/commit/756b91007ee747c10ed10aa7060af49b555a2eaf)) +- **downloader:** replace /skip all choice for downloaded tracks ([88d7ce5](https://github.com/KRTirtho/spotube/commit/88d7ce55a59f673d60cd9e85ab062bcb1b7dcbc3)) +- implemented go_route shell/nested route ([3e498a4](https://github.com/KRTirtho/spotube/commit/3e498a4827a1118e0b23faec7cf114272f7838d4)) +- **keyboard shortcuts:** play/pause on space, seek position on left/right ([2734454](https://github.com/KRTirtho/spotube/commit/2734454717bbfb5d0621c6ea72fa755ef4fc8602)) +- **keyboard-shortcuts:** home sidebar tab navigation and close app ([8f258e7](https://github.com/KRTirtho/spotube/commit/8f258e709ada418dbeef8d272af370b1741afd9c)) +- smoother list using fl_query and waypoint ([c77b0e1](https://github.com/KRTirtho/spotube/commit/c77b0e198b215180d863747e35998a17aff92720)) +- sort tracks in playlist, album and local tracks ([cb4bd25](https://github.com/KRTirtho/spotube/commit/cb4bd25df154455d225c426cfeaaea36ac09e9b7)) +- use of smaller sized images in `TrackTile` ([0ca97b4](https://github.com/KRTirtho/spotube/commit/0ca97b495f2a9ece8356f7813fc0e37d1cdb8608)) +- volume slider mouse scroll and preference for Rotating Album Art [#255](https://github.com/KRTirtho/spotube/issues/255) ([edb6f3c](https://github.com/KRTirtho/spotube/commit/edb6f3cd1c9ee2961040b2fe7a91c48577cee4f7)) ### Bug Fixes -* **android:** file_picker and permission_handler failure for sdk < 33 ([139d4dc](https://github.com/KRTirtho/spotube/commit/139d4dc033d9aaa1d6882bf0f53e96a3b1e87c95)) -* cached local track is fetched from network ([abf4a57](https://github.com/KRTirtho/spotube/commit/abf4a5763a2faeedeb93d54e66c1f2482295b326)) -* categories not showing for oauth exception ([4df917e](https://github.com/KRTirtho/spotube/commit/4df917e65ee20cbcf42394cc141b1cdcdd6cc914)) -* **desktop:** maximized window size is stored and window maximized state doesn't persist ([91d5d10](https://github.com/KRTirtho/spotube/commit/91d5d1003b09530ff3bc9a0aa93e382e943977e0)) -* local audio doesn't get refreshed after getting permission ([618c6da](https://github.com/KRTirtho/spotube/commit/618c6da0ebddf3cc8e216743bbbb9220bcf40521)) -* no appropriate output when playlist is empty [#201](https://github.com/KRTirtho/spotube/issues/201) ([dbb81de](https://github.com/KRTirtho/spotube/commit/dbb81de763df60eba62ef1256a7161ea6ca59b66)) -* PlayerOverlay not hiding when not playing and unneeded bottom space in TrackTableView ([0ebac05](https://github.com/KRTirtho/spotube/commit/0ebac05a4be8e8f744a6c672d3bb9807d6f02e10)) -* **web:** not building due to metadata_god ffi ([1191bf2](https://github.com/KRTirtho/spotube/commit/1191bf232d0797aaae7eff2f5d570acd49ce61bd)) +- **android:** file_picker and permission_handler failure for sdk < 33 ([139d4dc](https://github.com/KRTirtho/spotube/commit/139d4dc033d9aaa1d6882bf0f53e96a3b1e87c95)) +- cached local track is fetched from network ([abf4a57](https://github.com/KRTirtho/spotube/commit/abf4a5763a2faeedeb93d54e66c1f2482295b326)) +- categories not showing for oauth exception ([4df917e](https://github.com/KRTirtho/spotube/commit/4df917e65ee20cbcf42394cc141b1cdcdd6cc914)) +- **desktop:** maximized window size is stored and window maximized state doesn't persist ([91d5d10](https://github.com/KRTirtho/spotube/commit/91d5d1003b09530ff3bc9a0aa93e382e943977e0)) +- local audio doesn't get refreshed after getting permission ([618c6da](https://github.com/KRTirtho/spotube/commit/618c6da0ebddf3cc8e216743bbbb9220bcf40521)) +- no appropriate output when playlist is empty [#201](https://github.com/KRTirtho/spotube/issues/201) ([dbb81de](https://github.com/KRTirtho/spotube/commit/dbb81de763df60eba62ef1256a7161ea6ca59b66)) +- PlayerOverlay not hiding when not playing and unneeded bottom space in TrackTableView ([0ebac05](https://github.com/KRTirtho/spotube/commit/0ebac05a4be8e8f744a6c672d3bb9807d6f02e10)) +- **web:** not building due to metadata_god ffi ([1191bf2](https://github.com/KRTirtho/spotube/commit/1191bf232d0797aaae7eff2f5d570acd49ce61bd)) ## [2.4.1](https://github.com/KRTirtho/spotube/compare/v2.4.0...v2.4.1) (2022-09-13) - ### Features -* add macos audio metadata tags support ([5866b0f](https://github.com/KRTirtho/spotube/commit/5866b0fcd661cf32060bb1485ea81634fbb9b90a)) -* remove macos bounds for reading and writing audio metadata ([16064f6](https://github.com/KRTirtho/spotube/commit/16064f68e882b091401ace4b895e387f46635800)) -* **search:** horizontal swipe scroll support for Desktop platform ([d5ff927](https://github.com/KRTirtho/spotube/commit/d5ff927c7273b6e72c5d775ee777f2cbd0d6d05c)) - +- add macos audio metadata tags support ([5866b0f](https://github.com/KRTirtho/spotube/commit/5866b0fcd661cf32060bb1485ea81634fbb9b90a)) +- remove macos bounds for reading and writing audio metadata ([16064f6](https://github.com/KRTirtho/spotube/commit/16064f68e882b091401ace4b895e387f46635800)) +- **search:** horizontal swipe scroll support for Desktop platform ([d5ff927](https://github.com/KRTirtho/spotube/commit/d5ff927c7273b6e72c5d775ee777f2cbd0d6d05c)) ### Bug Fixes -* **artist-page:** SpotubeMarqueeText used in ArtistCard crashes the app ([4279541](https://github.com/KRTirtho/spotube/commit/427954150ab65b250e79fc844fc864abff5b6972)) -* **layout:** Fix adaptive UI not working correctly by providing a overriding option ([8c7adde](https://github.com/KRTirtho/spotube/commit/8c7adde890105e0267b71994b7928277f84553e5)) -* **local-track:** throwing exception when downloadLocation is empty ([1a3556d](https://github.com/KRTirtho/spotube/commit/1a3556d39e8473cadb6143192c48465dc6485599)) +- **artist-page:** SpotubeMarqueeText used in ArtistCard crashes the app ([4279541](https://github.com/KRTirtho/spotube/commit/427954150ab65b250e79fc844fc864abff5b6972)) +- **layout:** Fix adaptive UI not working correctly by providing a overriding option ([8c7adde](https://github.com/KRTirtho/spotube/commit/8c7adde890105e0267b71994b7928277f84553e5)) +- **local-track:** throwing exception when downloadLocation is empty ([1a3556d](https://github.com/KRTirtho/spotube/commit/1a3556d39e8473cadb6143192c48465dc6485599)) ## [2.4.0](https://github.com/KRTirtho/spotube/compare/v2.3.0...v2.4.0) (2022-09-09) - ### Features -* Ability to change download location added ([816707c](https://github.com/KRTirtho/spotube/commit/816707c643f8d60d25bc08fd4c8005daa2ba9e63)) -* add download multi tracks support for mobile platform ([0476bf7](https://github.com/KRTirtho/spotube/commit/0476bf7ceece034a927d1df6099d8b33036f8a9b)) -* add download queue for desktop & initial playlist download support ([08f913e](https://github.com/KRTirtho/spotube/commit/08f913e9761d0f5c447af9dfb6eedb44b675498c)) -* add download tab on library ([8d77b69](https://github.com/KRTirtho/spotube/commit/8d77b6900a81aab020e19397e788964b0ac499ff)) -* add web support although nothing works just as expected ([2818ed5](https://github.com/KRTirtho/spotube/commit/2818ed5c9dadb9185a52762599c1dd0acd81e6bf)) -* **broken:** Broken Warning! Initial Local Audio Player ([c3bf511](https://github.com/KRTirtho/spotube/commit/c3bf5119ebb7c17e8c32f149598508674b0acd39)) -* **download:** track table view multi select improvement, tap to play track support, existing track replace confirmation dialog and bulk download confirmation dialog ([e217553](https://github.com/KRTirtho/spotube/commit/e21755322f2cd5f1fba00c5c8cd5c5d1f79e459d)) -* **local-tracks:** complete support for local tracks ([e206f16](https://github.com/KRTirtho/spotube/commit/e206f16723ac989ad58006c1b3c90c6691d8cab3)) -* **mpris:** MPRIS metadata are now updated in realtime ([d9addcd](https://github.com/KRTirtho/spotube/commit/d9addcda8e9562803bd73016148fab22560ee050)) -* **playback:** add repeat track support [#166](https://github.com/KRTirtho/spotube/issues/166) ([cae9993](https://github.com/KRTirtho/spotube/commit/cae99934299bd197c68f626d6c10158d449770b9)) -* **synced-lyrics:** animated active text size ([531fae6](https://github.com/KRTirtho/spotube/commit/531fae64f94b21551a7a0da363a9ab0d44f5d3b1)) -* **ui:** adaptive TrackTile actions & Setting ListTile ([615d5ce](https://github.com/KRTirtho/spotube/commit/615d5ce901eb0512e84a120b7309c9053238ee36)) - +- Ability to change download location added ([816707c](https://github.com/KRTirtho/spotube/commit/816707c643f8d60d25bc08fd4c8005daa2ba9e63)) +- add download multi tracks support for mobile platform ([0476bf7](https://github.com/KRTirtho/spotube/commit/0476bf7ceece034a927d1df6099d8b33036f8a9b)) +- add download queue for desktop & initial playlist download support ([08f913e](https://github.com/KRTirtho/spotube/commit/08f913e9761d0f5c447af9dfb6eedb44b675498c)) +- add download tab on library ([8d77b69](https://github.com/KRTirtho/spotube/commit/8d77b6900a81aab020e19397e788964b0ac499ff)) +- add web support although nothing works just as expected ([2818ed5](https://github.com/KRTirtho/spotube/commit/2818ed5c9dadb9185a52762599c1dd0acd81e6bf)) +- **broken:** Broken Warning! Initial Local Audio Player ([c3bf511](https://github.com/KRTirtho/spotube/commit/c3bf5119ebb7c17e8c32f149598508674b0acd39)) +- **download:** track table view multi select improvement, tap to play track support, existing track replace confirmation dialog and bulk download confirmation dialog ([e217553](https://github.com/KRTirtho/spotube/commit/e21755322f2cd5f1fba00c5c8cd5c5d1f79e459d)) +- **local-tracks:** complete support for local tracks ([e206f16](https://github.com/KRTirtho/spotube/commit/e206f16723ac989ad58006c1b3c90c6691d8cab3)) +- **mpris:** MPRIS metadata are now updated in realtime ([d9addcd](https://github.com/KRTirtho/spotube/commit/d9addcda8e9562803bd73016148fab22560ee050)) +- **playback:** add repeat track support [#166](https://github.com/KRTirtho/spotube/issues/166) ([cae9993](https://github.com/KRTirtho/spotube/commit/cae99934299bd197c68f626d6c10158d449770b9)) +- **synced-lyrics:** animated active text size ([531fae6](https://github.com/KRTirtho/spotube/commit/531fae64f94b21551a7a0da363a9ab0d44f5d3b1)) +- **ui:** adaptive TrackTile actions & Setting ListTile ([615d5ce](https://github.com/KRTirtho/spotube/commit/615d5ce901eb0512e84a120b7309c9053238ee36)) ### Bug Fixes -* **adaptive-list-tile:** dialog content not updating when content has changed ([a1d4230](https://github.com/KRTirtho/spotube/commit/a1d423090c854ebe319a0fa03fd6e5c4007b1387)) -* album & playlist card, player view and album view play button logic ([55852bd](https://github.com/KRTirtho/spotube/commit/55852bd15bc709d61fbba8cbea01ceca791d154c)) -* **docs:** indentions ([4a291d5](https://github.com/KRTirtho/spotube/commit/4a291d5f20dabe68f3ed64071624dcbed8327329)) -* **downloader:** downloaded track is corrupted for tagging ([2ab1fba](https://github.com/KRTirtho/spotube/commit/2ab1fba3d64147e3c5cf34756dce1cf6046d410a)) -* **downloader:** flutter downloader exception on desktop platform and too much width of TrackTile index no. ([d668760](https://github.com/KRTirtho/spotube/commit/d6687603d148ad936530cca4d09e128a59b79b19)) -* dropped flutter_downloader deps due to slow download speed and UserDownloads not showing for anonymous ([307a8e2](https://github.com/KRTirtho/spotube/commit/307a8e21df1e39123a1dca4c1b063eab50359581)) -* flutter_downloader manifest configuration breaking android support ([f3a0f78](https://github.com/KRTirtho/spotube/commit/f3a0f78fb92ff7ee38b5a9ef9954575d4282f954)) -* login screen not using safearea and no dialog bg-color found on light mode in AdaptivePopupMenuButton ([92bc611](https://github.com/KRTirtho/spotube/commit/92bc611c5e901dcabf34086be9287ac20317259a)) -* **performance:** always running marquee text causes high GPU usage [#175](https://github.com/KRTirtho/spotube/issues/175) and UserArtist overflow on smaller displays ([a23ce61](https://github.com/KRTirtho/spotube/commit/a23ce614467b4297f495b824f0958ff07c21ae92)) -* **playback:** shuffle button sometimes gets stuck and stops working [#183](https://github.com/KRTirtho/spotube/issues/183) ([4240433](https://github.com/KRTirtho/spotube/commit/4240433e3dde6ab948d2674e07e41c27c1f6eac8)) -* **player-overlay:** flickering when a track is changed or navigated to another page ([e48b67c](https://github.com/KRTirtho/spotube/commit/e48b67cd47ae54ad9268aead268e444836a67b0d)) -* **sidebar:** user image url ([747efc6](https://github.com/KRTirtho/spotube/commit/747efc6ee66bc6c7c917cc02bd134968a0781701)) -* **synced-lyrics:** active lyrics contrast ratio ([aba1ba9](https://github.com/KRTirtho/spotube/commit/aba1ba932592923720a36395c057f78820dafecf)) -* tabbar overflow in small screen, artist card too small title and synced lyrics contrast increased ([585de8c](https://github.com/KRTirtho/spotube/commit/585de8c1def9750826568317109b242a5e18f28c)) +- **adaptive-list-tile:** dialog content not updating when content has changed ([a1d4230](https://github.com/KRTirtho/spotube/commit/a1d423090c854ebe319a0fa03fd6e5c4007b1387)) +- album & playlist card, player view and album view play button logic ([55852bd](https://github.com/KRTirtho/spotube/commit/55852bd15bc709d61fbba8cbea01ceca791d154c)) +- **docs:** indentions ([4a291d5](https://github.com/KRTirtho/spotube/commit/4a291d5f20dabe68f3ed64071624dcbed8327329)) +- **downloader:** downloaded track is corrupted for tagging ([2ab1fba](https://github.com/KRTirtho/spotube/commit/2ab1fba3d64147e3c5cf34756dce1cf6046d410a)) +- **downloader:** flutter downloader exception on desktop platform and too much width of TrackTile index no. ([d668760](https://github.com/KRTirtho/spotube/commit/d6687603d148ad936530cca4d09e128a59b79b19)) +- dropped flutter_downloader deps due to slow download speed and UserDownloads not showing for anonymous ([307a8e2](https://github.com/KRTirtho/spotube/commit/307a8e21df1e39123a1dca4c1b063eab50359581)) +- flutter_downloader manifest configuration breaking android support ([f3a0f78](https://github.com/KRTirtho/spotube/commit/f3a0f78fb92ff7ee38b5a9ef9954575d4282f954)) +- login screen not using safearea and no dialog bg-color found on light mode in AdaptivePopupMenuButton ([92bc611](https://github.com/KRTirtho/spotube/commit/92bc611c5e901dcabf34086be9287ac20317259a)) +- **performance:** always running marquee text causes high GPU usage [#175](https://github.com/KRTirtho/spotube/issues/175) and UserArtist overflow on smaller displays ([a23ce61](https://github.com/KRTirtho/spotube/commit/a23ce614467b4297f495b824f0958ff07c21ae92)) +- **playback:** shuffle button sometimes gets stuck and stops working [#183](https://github.com/KRTirtho/spotube/issues/183) ([4240433](https://github.com/KRTirtho/spotube/commit/4240433e3dde6ab948d2674e07e41c27c1f6eac8)) +- **player-overlay:** flickering when a track is changed or navigated to another page ([e48b67c](https://github.com/KRTirtho/spotube/commit/e48b67cd47ae54ad9268aead268e444836a67b0d)) +- **sidebar:** user image url ([747efc6](https://github.com/KRTirtho/spotube/commit/747efc6ee66bc6c7c917cc02bd134968a0781701)) +- **synced-lyrics:** active lyrics contrast ratio ([aba1ba9](https://github.com/KRTirtho/spotube/commit/aba1ba932592923720a36395c057f78820dafecf)) +- tabbar overflow in small screen, artist card too small title and synced lyrics contrast increased ([585de8c](https://github.com/KRTirtho/spotube/commit/585de8c1def9750826568317109b242a5e18f28c)) # v2.3.0 ### New + - Playback Cache Support. So unfinished playlist and tracks remains cached & starts automatically when application is launched again - Login Screen guided tutorial about how to obtain Client ID & Client Secret - Signed Android Application so now longer need to uninstall the old version for installing the new one @@ -813,8 +804,8 @@ All notable changes to this project will be documented in this file. See [standa - New Blur background design adapted to multiple components including Floating Player, Player View & Lyrics Tab - New HighContrast Color Scheme addition which reduces battery consumption on OLED or AMOLED display devices (https://github.com/KRTirtho/spotube/issues/137) - ### Improved + - Loading screens & animations. Now uses Skeleton Loading - Playlist & Album Pages now show Album Art & extra metadata as Header with vibrant gradient background in a Sliver - Playback is now more consistent & the API is simpler. Also its the single source of truth for AudioPlayback instead of the AudioServiceHandler @@ -823,6 +814,7 @@ All notable changes to this project will be documented in this file. See [standa - Track match Cache support for previously played tracks. This dramatically reduces track change latency & load on the YouTube search engine too ### Bug Fixes + - API rate limits inside TrackTile for multiple Follow queries at once - Player doesn't stop when Application is exits or closed - First Track of Playlist doesn't load sometimes @@ -832,9 +824,11 @@ All notable changes to this project will be documented in this file. See [standa # v2.2.1 ### Improved + - Page transitions defaulted to material you design -### Bug fixes +### Bug fixes + - Mini Player flickering on random state updates - Track More Options not showing when not logged in - Wrong link to Client ID & Client Secret tutorial in Login page @@ -843,6 +837,7 @@ All notable changes to this project will be documented in this file. See [standa # v2.2.0 ### New + - Update checker - Share options for playlists & track - Android Skip to Next/Previous track from notification/lockscreen (https://github.com/KRTirtho/spotube/issues/91) @@ -855,10 +850,13 @@ All notable changes to this project will be documented in this file. See [standa - M1 Mac support via MacOS Universal Binary (untested) (https://github.com/KRTirtho/spotube/pull/87) ### Improved -- Authentication is now persistent (no more re-login) + +- Authentication is now persistent (no more re-login) - Settings Page. Shows application details in About Dialog - Playlist Create Dialog Scrollable + ### Bug fixes + - private playlists of current user aren't shown fix (https://github.com/KRTirtho/spotube/issues/92) - refresh token error causing re-login (culprit: internal lib spotify-dart) - Typo in Login instructions URL @@ -866,6 +864,7 @@ All notable changes to this project will be documented in this file. See [standa # v2.1.0 ### New + - Synced Lyrics (with fallback genius lyrics) - Playlist create/delete - Add/Remove tracks to own playlists @@ -874,6 +873,7 @@ All notable changes to this project will be documented in this file. See [standa - Customize Marketplace location ### Improved + - Spotify track to youtube track algorithm - Genius lyrics matching algorithm - Download track. Checks if already exists & replaces on user command @@ -881,14 +881,16 @@ All notable changes to this project will be documented in this file. See [standa - Bigger Title display (replaced word-break with Marquee Text for better visibility) (https://github.com/KRTirtho/spotube/pull/47) ### Bug fixes + - Sequential playlist playback not working with latest webkit2gtk (https://github.com/KRTirtho/spotube/issues/46) - Theme modification state doesn't persist (https://github.com/KRTirtho/spotube/issues/54) - Wrong URI path for "Login with Spotify" tutorial (https://github.com/KRTirtho/spotube/issues/69) -- Card shadow showing in the background of TitleBar & Searchbar +- Card shadow showing in the background of TitleBar & Searchbar -# v2.0.0 +# v2.0.0 ### New + - Android Support https://github.com/KRTirtho/spotube/issues/24 - Responsive UI (Mobile, Tablet) - Anonymous/Guest Account @@ -901,14 +903,16 @@ All notable changes to this project will be documented in this file. See [standa - Android NavigationPanel controls (OS media controls of Android) ### Improved + - Search - now scrolls & paginates for Playlists & Albums - Authentication - allows guest accounts making authentication optional -- Lyrics - can be fetched without requiring GeniusAccessToken. This makes geniusAccessToken optional +- Lyrics - can be fetched without requiring GeniusAccessToken. This makes geniusAccessToken optional - UI snappiness & faster load times - Simpler logic, faster calculations & better caching (flutter_hooks) - shared state management - uses riverpod & hooks combination ### Bug fixes + - Can't play any song in macos https://github.com/KRTirtho/spotube/issues/23 - Downloaded tracks can't be played as they're WebAudio (.weba) instead of MP3 - delay while changing Playlist/Single tracks @@ -916,24 +920,28 @@ All notable changes to this project will be documented in this file. See [standa # v1.2.0 ### New -- Global custom reconfigurable *hotkey* support for playback controls (play-pause/next/previous) + +- Global custom reconfigurable _hotkey_ support for playback controls (play-pause/next/previous) - Credit section in the Settings page with important links + ### Improved + - Macos support - Genius (Lyrics Provider) access_token can be saved in the Login page too - Better theme for dropdown-buttons ### Bug fixes + - broken authentication IPC on Mac OS (https://github.com/KRTirtho/spotube/pull/18) - Mac OS's global appmenu's default APP_NAME replaced with Spotube - location of back button on macOS (https://github.com/KRTirtho/spotube/pull/21) - windows titlebar buttons appears on Mac OS - genius access_token not loading on initial app start - # v1.1.0 ### New + - MacOS support https://github.com/KRTirtho/spotube/pull/7 - Download currently playing track to `/home//Downloads/Spotube` (Linux, MacOS) or `C:\Users\\Downloads\Spotube` (Windows) - Play playlist from any song (index) instead of only the first track @@ -949,27 +957,33 @@ All notable changes to this project will be documented in this file. See [standa - Click to open artist-profile/album everywhere in the application ### Improved + - UserLibrary album & artist tab - PlaylistView simplified layout with `ListView` instead of `TableView` - Control Theme from settings manually - `PageWindowTitleBar` now acts as `appBar` ### Bug fixes + - Unsafe access to album art/artist/user Images with `.first` or `.last` causing accessing empty List error -- `url_launcher`'s unstable `canLaunch` method blocks OAuth login in certain *nix OSs +- `url_launcher`'s unstable `canLaunch` method blocks OAuth login in certain \*nix OSs - Refresh token gets revoked & doesn't get renewed automatically + # v1.0.1 ### Improved + - Placeholder avatar for User section powered by dicebear.com ### Bug fixes + - No fallback/placeholder image causing undefined behavior (#2) - Unsafe access to empty List with List.first/List.last # v1.0.0 ### New + - Complete re-write in Flutter/Dart (799e13c) - mpv & youtube-dl runtime dependencies dropped (07b1891) - just_audio (libwinmedia + libwebkit2gtk-4.0-dev) + youtube_explode based playback & streaming @@ -977,15 +991,17 @@ All notable changes to this project will be documented in this file. See [standa - inno_setup based windows/win32 GUI installer (dbf8a34) ### Improved + - Lower RAM & CPU usage. 2x less RAM usage & 20% less CPU usage - Faster playback & smooth track change with proper shuffling support - Automatic Dark mode support (system) - 54% smaller bundle size (after compression) -- Available through package managers in Linux (Debian, Arch, Flatpak & AppImage) +- Available through package managers in Linux (Debian, Arch, Flatpak & AppImage) # v0.0.3 ### New + - Automated installer for Windows (now doesn't require manual mpv-player install) - Playback caching - Retry button for ManualLyricDialog @@ -993,23 +1009,28 @@ All notable changes to this project will be documented in this file. See [standa - Redirect to youtube video by clicking on the title of the track ### Improved + - Inapp Shortcuts.Now it doesn't interfere while typing in a input box in Search page ### Bug fixes + - Cached image didn't get deleted after exiting certain cache limit fix. Cache gets recreated after exiting the limit # v0.0.2 ### New + - Lyric Seek - Support for images in playlist cards - Infinite Query/Pagination support for Home & Genre pages - Settings for configuring local configuration ### Improved + - Home Page Layout. Fixes the jiggering of Playlist Links on hover ### Bug Fixes + - `access_token not found` Error after OAuth Login with Spotify credentials (used to need a restart of the app to load the access_token) - Volume level wasn't cached even after changing volume @@ -1018,8 +1039,9 @@ All notable changes to this project will be documented in this file. See [standa Spotube v0.0.1 - initial release of the open source software for playing Spotify music using Youtube public API ### New + - Local playback handling - Playback Queue - Save to Liked Tracks/Playlists - Bypass API rate limitation on basic usage using personal developer Apps for spotify API -- Youtube search & get handled using scrape-yt \ No newline at end of file +- Youtube search & get handled using scrape-yt diff --git a/LICENSE b/LICENSE index 0bce62d7..11aea461 100644 --- a/LICENSE +++ b/LICENSE @@ -1,12 +1,12 @@ BSD-4-Clause License -Copyright (c) 2023 Kingkor Roy Tirtho. All rights reserved. +Copyright (c) 2025 Kingkor Roy Tirtho. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. All advertising materials mentioning features or use of this software must display the following acknowledgement: -This product includes software developed by Kingkor Roy Tirtho. + This product includes software developed by Kingkor Roy Tirtho. 4. Neither the name of the Software nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. -THIS SOFTWARE IS PROVIDED BY KINGKOR ROY TIRTHO AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL KINGKOR ROY TIRTHO AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + THIS SOFTWARE IS PROVIDED BY KINGKOR ROY TIRTHO AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL KINGKOR ROY TIRTHO AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile index 25ac3a6d..48626312 100644 --- a/Makefile +++ b/Makefile @@ -45,4 +45,11 @@ gensums: sh -c scripts/gensums.sh migrate: - dart run drift_dev make-migrations \ No newline at end of file + dart run drift_dev make-migrations + +dmg: + flutter build macos &&\ + if [ -f dist/Spotube-macos-universal.dmg ];\ + then rm dist/Spotube-macos-universal.dmg;\ + fi &&\ + appdmg appdmg.json dist/Spotube-macos-universal.dmg \ No newline at end of file diff --git a/README.md b/README.md index deeba646..7e0e0d80 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ This handy table lists all the methods you can use to install Spotube: AppImage - AppImage's lacking stability led to it's temporal removal. More information at https://github.com/KRTirtho/spotube/issues/1082 + AppImage's lacking stability led to it's temporary removal. More information at https://github.com/KRTirtho/spotube/issues/1082 Debian/Ubuntu @@ -207,10 +207,15 @@ If you are concerned, you can [read the reason of choosing this license](https:/ ### Services + 1. [Flutter](https://flutter.dev) - Flutter transforms the app development process. Build, test, and deploy beautiful mobile, web, desktop, and embedded apps from a single codebase +1. [MPV](https://mpv.io) - mpv is a free (as in freedom) media player for the command line. It supports a wide variety of media file formats, audio and video codecs, and subtitle types. 1. [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. [Invidious](https://invidious.io/) - Invidious is an open source alternative front-end to YouTube. 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. [yt-dlp](https://github.com/yt-dlp/yt-dlp) - A feature-rich command-line audio/video downloader + 1. [NewPipeExtractor](https://github.com/TeamNewPipe/NewPipeExtractor) - NewPipe's core library for extracting data from streaming sites 1. [JioSaavn](https://www.jiosaavn.com) - JioSaavn is an Indian online music streaming service and a digital distributor of Bollywood, English and other regional Indian music across the world. Since it was founded in 2007 as Saavn, the company has acquired rights to over 5 crore (50 million) music tracks in 15 languages 1. [SongLink](https://song.link) - SongLink is a free smart link service that helps you share music with your audience. It's a one-stop-shop for creating smart links for music, podcasts, and other audio content 1. [LRCLib](https://lrclib.net/) - A public synced lyric API @@ -220,24 +225,23 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 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. +1. [LastFM](https://last.fm) - Last.fm is a music streaming and discovery platform that helps users discover and share new music. It tracks users' music listening habits across many devices and platforms. ### Dependencies + 1. [app_links](https://github.com/llfbandit/app_links) - Android App Links, Deep Links, iOs Universal Links and Custom URL schemes handler for Flutter (desktop included). 1. [args](https://pub.dev/packages/args) - Library for defining parsers for parsing raw command-line arguments into a set of options and values using GNU and POSIX style options. 1. [async](https://pub.dev/packages/async) - Utility functions and classes related to the 'dart:async' library. 1. [audio_service](https://pub.dev/packages/audio_service) - Flutter plugin to play audio in the background while the screen is off. 1. [audio_service_mpris](https://github.com/bdrazhzhov/audio-service-mpris) - audio_service platform interface supporting Media Player Remote Interfacing Specification. 1. [audio_session](https://github.com/ryanheise/audio_session) - Sets the iOS audio session category and Android audio attributes for your app, and manages your app's audio focus, mixing and ducking behaviour. +1. [auto_route](https://github.com/Milad-Akarie/auto_route_library) - AutoRoute is a declarative routing solution, where everything needed for navigation is automatically generated for you. 1. [auto_size_text](https://github.com/leisim/auto_size_text) - Flutter widget that automatically resizes text to fit perfectly within its bounds. 1. [bonsoir](https://bonsoir.skyost.eu) - A Zeroconf library that allows you to discover network services and to broadcast your own. Based on Apple Bonjour and Android NSD. -1. [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. [collection](https://pub.dev/packages/collection) - Collections and utilities functions and classes related to collections. -1. [curved_navigation_bar](https://github.com/rafalbednarczuk/curved_navigation_bar) - Stunning Animating Curved Shape Navigation Bar. Adjustable color, background color, animation curve, animation duration. +1. [connectivity_plus](https://github.com/fluttercommunity/plus_plugins) - Flutter plugin for discovering the state of the network (WiFi & mobile/cellular) connectivity on Android and iOS. 1. [device_info_plus](https://github.com/fluttercommunity/plus_plugins) - Flutter plugin providing detailed information about the device (make, model, etc.), and Android or iOS version the app is running on. 1. [dio](https://github.com/cfug/dio) - A powerful HTTP networking package,supports Interceptors,Aborting and canceling a request,Custom adapters, Transformers, etc. -1. [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. [drift](https://drift.simonbinder.eu/) - Drift is a reactive library to store relational data in Dart and Flutter applications. 1. [duration](https://github.com/desktop-dart/duration) - Utilities to make working with 'Duration's easier. Formats duration in human readable form and also parses duration in human readable form to Dart's Duration. 1. [encrypt](https://pub.dev/packages/encrypt) - A set of high-level APIs over PointyCastle for two-way cryptography. @@ -245,26 +249,25 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [file_picker](https://github.com/miguelpruivo/plugins_flutter_file_picker) - A package that allows you to use a native file explorer to pick single or multiple absolute file paths, with extension filtering support. 1. [file_selector](https://pub.dev/packages/file_selector) - Flutter plugin for opening and saving files, or selecting directories, using native file selection UI. 1. [fluentui_system_icons](https://github.com/microsoft/fluentui-system-icons/tree/main) - Fluent UI System Icons are a collection of familiar, friendly and modern icons from Microsoft. -1. [flutter_broadcasts](https://pub.dev/packages/flutter_broadcasts) - A plugin for sending and receiving broadcasts with Android intents and iOS notifications. 1. [flutter_cache_manager](https://github.com/Baseflow/flutter_cache_manager/tree/develop/flutter_cache_manager) - Generic cache manager for flutter. Saves web files on the storages of the device and saves the cache info using sqflite. 1. [flutter_discord_rpc](https://pub.dev/packages/flutter_discord_rpc) - Discord RPC support for Flutter desktop platforms 1. [flutter_displaymode](https://github.com/ajinasokan/flutter_displaymode) - A Flutter plugin to set display mode (resolution, refresh rate) on Android platform. Allows to enable high refresh rate on supported devices. 1. [flutter_feather_icons](https://github.com/muj-programmer/flutter_feather_icons) - Feather is a collection of simply beautiful open source icons. Each icon is designed on a 24x24 grid with an emphasis on simplicity, consistency and usability. +1. [flutter_form_builder](https://github.com/flutter-form-builder-ecosystem) - This package helps in creation of forms in Flutter by removing the boilerplate code, reusing validation, react to changes, and collect final user input. 1. [flutter_hooks](https://github.com/rrousselGit/flutter_hooks) - A flutter implementation of React hooks. It adds a new kind of widget with enhanced code reuse. 1. [flutter_inappwebview](https://inappwebview.dev/) - A Flutter plugin that allows you to add an inline webview, to use an headless webview, and to open an in-app browser window. 1. [flutter_native_splash](https://pub.dev/packages/flutter_native_splash) - Customize Flutter's default white native splash screen with background color and splash image. Supports dark mode, full screen, and more. 1. [flutter_riverpod](https://riverpod.dev) - A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze. 1. [flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage) - Flutter Secure Storage provides API to store data in secure storage. Keychain is used in iOS, KeyStore based solution is used in Android. 1. [flutter_sharing_intent](https://github.com/bhagat-techind/flutter_sharing_intent.git) - A flutter plugin that allow flutter apps to receive photos, videos, text, urls or any other file types from another app. -1. [flutter_svg](https://pub.dev/packages/flutter_svg) - An SVG rendering and widget library for Flutter, which allows painting and displaying Scalable Vector Graphics 1.1 files. +1. [flutter_undraw](https://github.com/KRTirtho/flutter_undraw) - Undraw.co Illustrations for Flutter with customization options +1. [form_builder_validators](https://github.com/flutter-form-builder-ecosystem) - Form Builder Validators set of validators for FlutterFormBuilder. Provides common validators and a way to make your own. 1. [form_validator](https://github.com/TheMisir/form-validator) - Simplest form validation library for flutter's form field widgets 1. [freezed_annotation](https://pub.dev/packages/freezed_annotation) - Annotations for the freezed code-generator. This package does nothing without freezed too. 1. [fuzzywuzzy](https://github.com/sphericalkat/dart-fuzzywuzzy) - An implementation of the popular fuzzywuzzy package in Dart, to suit all your fuzzy string matching/searching needs! 1. [gap](https://github.com/letsar/gap) - Flutter widgets for easily adding gaps inside Flex widgets such as Columns and Rows or scrolling views. -1. [go_router](https://pub.dev/packages/go_router) - A declarative router for Flutter based on Navigation 2 supporting deep linking, data-driven routes and more 1. [google_fonts](https://pub.dev/packages/google_fonts) - A Flutter package to use fonts from fonts.google.com. Supports HTTP fetching, caching, and asset bundling. -1. [hive](https://github.com/hivedb/hive/tree/master/hive) - Lightweight and blazing fast key-value database written in pure Dart. Strongly encrypted using AES-256. -1. [hive_flutter](https://github.com/hivedb/hive/tree/master/hive_flutter) - Extension for Hive. Makes it easier to use Hive in Flutter apps. +1. [home_widget](https://pub.dev/packages/home_widget) - A plugin to provide a common interface for creating HomeScreen Widgets for Android and iOS. 1. [hooks_riverpod](https://riverpod.dev) - A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze. 1. [html](https://pub.dev/packages/html) - APIs for parsing and manipulating HTML content outside the browser. 1. [html_unescape](https://github.com/filiph/html_unescape) - A small library for un-escaping HTML. Supports all Named Character References, Decimal Character References and Hexadecimal Character References. @@ -276,6 +279,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [json_annotation](https://pub.dev/packages/json_annotation) - Classes and helper functions that support JSON code generation via the `json_serializable` package. 1. [local_notifier](https://github.com/leanflutter/local_notifier) - This plugin allows Flutter desktop apps to displaying local notifications. 1. [logger](https://pub.dev/packages/logger) - Small, easy to use and extensible logger which prints beautiful logs. +1. [logging](https://pub.dev/packages/logging) - Provides APIs for debugging and error logging, similar to loggers in other languages, such as the Closure JS Logger and java.util.logging.Logger. 1. [lrc](https://pub.dev/packages/lrc) - A Dart-only package that creates, parses, and handles LRC, which is a format that stores song lyrics. 1. [media_kit](https://github.com/media-kit/media-kit) - A cross-platform video player & audio player for Flutter & Dart. Performant, stable, feature-proof & modular. 1. [media_kit_libs_audio](https://github.com/media-kit/media-kit.git) - package:media_kit audio (only) playback native libraries for all platforms. @@ -288,16 +292,16 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [path_provider](https://pub.dev/packages/path_provider) - Flutter plugin for getting commonly used locations on host platform file systems, such as the temp and app data directories. 1. [permission_handler](https://pub.dev/packages/permission_handler) - Permission plugin for Flutter. This plugin provides a cross-platform (iOS, Android) API to request and check permissions. 1. [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. [riverpod](https://riverpod.dev) - A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze. 1. [scroll_to_index](https://github.com/quire-io/scroll-to-index) - Scroll to a specific child of any scrollable widget in Flutter +1. [shadcn_flutter](https://github.com/sunarya-thito/shadcn_flutter) - Beautifully designed components from Shadcn/UI is now available for Flutter 1. [shared_preferences](https://pub.dev/packages/shared_preferences) - Flutter plugin for reading and writing simple key-value pairs. Wraps NSUserDefaults on iOS and SharedPreferences on Android. 1. [shelf](https://pub.dev/packages/shelf) - A model for web server middleware that encourages composition and easy reuse. 1. [shelf_router](https://pub.dev/packages/shelf_router) - A convenient request router for the shelf web-framework, with support for URL-parameters, nested routers and routers generated from source annotations. 1. [shelf_web_socket](https://pub.dev/packages/shelf_web_socket) - A shelf handler that wires up a listener for every connection. -1. [sidebarx](https://github.com/Frezyx/sidebarx) - flutter multiplatform navigation sidebar / side navigationbar / drawer widget 1. [simple_icons](https://teavelopment.com/) - The Simple Icon pack available as Flutter Icons. Provides over 1500 Free SVG icons for popular brands. 1. [skeletonizer](https://github.com/Milad-Akarie/skeletonizer) - Converts already built widgets into skeleton loaders with no extra effort. +1. [sliding_up_panel](https://github.com/akshathjain/sliding_up_panel) - A draggable Flutter widget that makes implementing a SlidingUpPanel much easier! 1. [sliver_tools](https://github.com/Kavantix) - A set of useful sliver tools that are missing from the flutter framework 1. [smtc_windows](https://pub.dev/packages/smtc_windows) - Windows `SystemMediaTransportControls` implementation for Flutter giving access to Windows OS Media Control applet. 1. [spotify](https://github.com/rinukkusu/spotify-dart) - An incomplete dart library for interfacing with the Spotify Web API. @@ -319,26 +323,30 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [win32_registry](https://pub.dev/packages/win32_registry) - A package that provides a friendly Dart API for accessing the Windows Registry. 1. [window_manager](https://github.com/leanflutter/window_manager) - This plugin allows Flutter desktop apps to resizing and repositioning the window. 1. [youtube_explode_dart](https://github.com/Hexer10/youtube_explode_dart) - A port in dart of the youtube explode library. Supports several API functions without the need of Youtube API Key. +1. [http_parser](https://pub.dev/packages/http_parser) - A platform-independent package for parsing and serializing HTTP formats. +1. [collection](https://pub.dev/packages/collection) - Collections and utilities functions and classes related to collections. 1. [build_runner](https://pub.dev/packages/build_runner) - A build system for Dart code generation and modular compilation. 1. [crypto](https://pub.dev/packages/crypto) - Implementations of SHA, MD5, and HMAC cryptographic functions. 1. [envied_generator](https://github.com/petercinibulk/envied) - Generator for the Envied package. See https://pub.dev/packages/envied. 1. [flutter_gen_runner](https://github.com/FlutterGen/flutter_gen) - The Flutter code generator for your assets, fonts, colors, … — Get rid of all String-based APIs. 1. [flutter_launcher_icons](https://github.com/fluttercommunity/flutter_launcher_icons) - A package which simplifies the task of updating your Flutter app's launcher icon. 1. [flutter_lints](https://pub.dev/packages/flutter_lints) - Recommended lints for Flutter apps, packages, and plugins to encourage good coding practices. -1. [hive_generator](https://github.com/hivedb/hive/tree/master/hive_generator) - Extension for Hive. Automatically generates TypeAdapters to store any class. 1. [json_serializable](https://pub.dev/packages/json_serializable) - Automatically generate code for converting to and from JSON by annotating Dart classes. 1. [freezed](https://pub.dev/packages/freezed) - Code generation for immutable classes that has a simple syntax/API without compromising on the features. -1. [custom_lint](https://pub.dev/packages/custom_lint) - Lint rules are a powerful way to improve the maintainability of a project. Custom Lint allows package authors and developers to easily write custom lint rules. -1. [riverpod_lint](https://riverpod.dev) - Riverpod_lint is a developer tool for users of Riverpod, designed to help stop common issues and simplify repetitive tasks. 1. [process_run](https://github.com/tekartik/process_run.dart/blob/master/packages/process_run) - Process run helpers for Linux/Win/Mac and which like feature for finding executables. 1. [pubspec_parse](https://pub.dev/packages/pubspec_parse) - Simple package for parsing pubspec.yaml files with a type-safe API and rich error reporting. 1. [pub_api_client](https://github.com/leoafarias/pub_api_client) - An API Client for Pub to interact with public package information. 1. [xml](https://github.com/renggli/dart-xml) - A lightweight library for parsing, traversing, querying, transforming and building XML documents. 1. [io](https://pub.dev/packages/io) - Utilities for the Dart VM Runtime including support for ANSI colors, file copying, and standard exit code values. 1. [drift_dev](https://drift.simonbinder.eu/) - Dev-dependency for users of drift. Contains the generator and development tools. +1. [auto_route_generator](https://github.com/Milad-Akarie/auto_route_library) - AutoRoute is a declarative routing solution, where everything needed for navigation is automatically generated for you. 1. [desktop_webview_window](https://github.com/MixinNetwork/flutter-plugins/tree/main/packages/desktop_webview_window) - Show a webview window on your flutter desktop application. +1. [disable_battery_optimization](https://github.com/pvsvamsi/Disable-Battery-Optimizations) - Flutter plugin to check and disable battery optimizations. Also shows custom steps to disable the optimizations in devices like mi, xiaomi, samsung, oppo, huawei, oneplus etc 1. [draggable_scrollbar](https://github.com/fluttercommunity/flutter-draggable-scrollbar) - A scrollbar that can be dragged for quickly navigation through a vertical list. Additional option is showing label next to scrollthumb with information about current item. +1. [flutter_broadcasts](https://github.com/KRTirtho/flutter_broadcasts.git) - A plugin for sending and receiving broadcasts with Android intents and iOS notifications. 1. [scrobblenaut](https://github.com/Nebulino/Scrobblenaut) - A deadly simple LastFM API Wrapper for Dart. So deadly simple that it's gonna hit the mark. +1. [yt_dlp_dart](https://github.com/KRTirtho/yt_dlp_dart.git) - yt-dlp binding in Dart +1. [flutter_new_pipe_extractor](https://github.com/KRTirtho/flutter_new_pipe_extractor) - NewPipeExtractor binding for Flutter (Android only)

© Copyright Spotube 2024

diff --git a/analysis_options.yaml b/analysis_options.yaml index 1eda286e..af222653 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -32,8 +32,6 @@ linter: analyzer: errors: invalid_annotation_target: ignore - plugins: - - custom_lint exclude: - "**.freezed.dart" - "**.g.dart" diff --git a/android/app/build.gradle b/android/app/build.gradle index 8ec1872e..5051f5a3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -28,12 +28,17 @@ if (keystorePropertiesFile.exists()) { keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) } -android { - compileSdkVersion 34 +def composeVersion = "1.4.8" - ndkVersion "25.1.8937393" +android { + namespace "oss.krtirtho.spotube" + + compileSdkVersion 35 + + ndkVersion = "27.0.12077973" compileOptions { + coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } @@ -46,10 +51,18 @@ android { main.java.srcDirs += 'src/main/kotlin' } + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion "$composeVersion" // Correlates with org.jetbrains.kotlin.android plugin in settings.gradle + } + defaultConfig { applicationId "oss.krtirtho.spotube" minSdkVersion 24 - targetSdkVersion 34 + targetSdkVersion 35 versionCode flutterVersionCode.toInteger() versionName flutterVersionName multiDexEnabled true @@ -63,6 +76,7 @@ android { storePassword keystoreProperties['storePassword'] } } + buildTypes { release { signingConfig signingConfigs.release @@ -96,15 +110,30 @@ android { } } + packagingOptions { + resources.excludes += "DebugProbesKt.bin" + } } flutter { source '../..' } +def glanceVersion = "1.1.1" dependencies { - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' // other deps so just ignore implementation 'com.android.support:multidex:2.0.1' + + implementation "androidx.glance:glance-appwidget:$glanceVersion" + implementation "androidx.glance:glance-appwidget-preview:$glanceVersion" + implementation "androidx.glance:glance-preview:$glanceVersion" + implementation "androidx.glance:glance-material3:$glanceVersion" + implementation "androidx.glance:glance-material:$glanceVersion" + implementation "androidx.work:work-runtime-ktx:2.8.1" + + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3" + implementation 'com.google.code.gson:gson:2.11.0' } \ No newline at end of file diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 116bc22f..700901e8 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -1 +1,21 @@ --keep class androidx.lifecycle.DefaultLifecycleObserver \ No newline at end of file +-keep class androidx.lifecycle.DefaultLifecycleObserver + +-keepnames class kotlinx.serialization.** { *; } +-keepnames class oss.krtirtho.spotube.glance.models.** { *; } +-keep @kotlinx.serialization.Serializable class * +-keepclassmembers class ** { + @kotlinx.serialization.* ; +} + +## We don't need beans +-dontwarn java.beans.BeanDescriptor +-dontwarn java.beans.BeanInfo +-dontwarn java.beans.IntrospectionException +-dontwarn java.beans.Introspector +-dontwarn java.beans.PropertyDescriptor + +## Rules for NewPipeExtractor +-keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; } +-keep class org.mozilla.javascript.** { *; } +-keep class org.mozilla.classfile.ClassFileWriter +-dontwarn org.mozilla.javascript.tools.** \ No newline at end of file diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 1041f6ca..a32d12af 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,7 +1,6 @@ - - - - + + \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 64c32e28..0effefe2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,4 @@ - + @@ -17,38 +17,36 @@ + android:usesCleartextTraffic="true"> + android:value="false" /> --> + android:windowSoftInputMode="adjustResize"> + Specifies an Android theme to apply to this Activity as soon as + the Android process has started. This theme is visible to the user + while the Flutter UI initializes. After that, this theme continues + to determine the Window background behind the Flutter UI. + --> + android:resource="@style/NormalTheme" /> + @@ -56,12 +54,13 @@ + + + android:scheme="https" /> @@ -72,23 +71,30 @@ + + + + + - + - @@ -96,11 +102,40 @@ - + + + + + + + + + + + + + + + + + - + This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> + \ No newline at end of file diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidget.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidget.kt new file mode 100644 index 00000000..a20af959 --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidget.kt @@ -0,0 +1,207 @@ +package oss.krtirtho.spotube.glance + +import HomeWidgetGlanceState +import HomeWidgetGlanceStateDefinition +import android.R +import android.content.Context +import android.graphics.drawable.Icon +import android.net.Uri +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalSize +import androidx.glance.action.ActionParameters +import androidx.glance.action.actionParametersOf +import androidx.glance.action.clickable +import androidx.glance.background +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.background +import androidx.glance.appwidget.components.CircleIconButton +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview +import androidx.glance.state.GlanceStateDefinition +import com.google.gson.Gson +import es.antonborri.home_widget.HomeWidgetBackgroundIntent +import es.antonborri.home_widget.actionStartActivity +import oss.krtirtho.spotube.MainActivity +import oss.krtirtho.spotube.glance.models.Track +import oss.krtirtho.spotube.glance.widgets.FlutterAssetImageProvider +import oss.krtirtho.spotube.glance.widgets.TrackDetailsView +import oss.krtirtho.spotube.glance.widgets.TrackProgress + +val gson = Gson() +val serverAddressKey = ActionParameters.Key("serverAddress") + +class Breakpoints { + companion object { + val SMALL_SQUARE = DpSize(100.dp, 100.dp) + val HORIZONTAL_RECTANGLE = DpSize(250.dp, 100.dp) + val BIG_SQUARE = DpSize(250.dp, 250.dp) + } +} + +class HomePlayerWidget : GlanceAppWidget() { + + override val sizeMode = SizeMode.Responsive( + setOf( + Breakpoints.SMALL_SQUARE, + Breakpoints.HORIZONTAL_RECTANGLE, + Breakpoints.BIG_SQUARE + ) + ) + + override val stateDefinition: GlanceStateDefinition<*>? + get() = HomeWidgetGlanceStateDefinition() + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + GlanceContent(context, currentState()) + } + } + + + @OptIn(ExperimentalGlancePreviewApi::class) + @Preview(widthDp = 100, heightDp = 100) + @Composable + private fun GlanceContent(context: Context, currentState: HomeWidgetGlanceState) { + val prefs = currentState.preferences + val size = LocalSize.current + + val activeTrackStr = prefs.getString("activeTrack", null) + + val isPlaying = prefs.getBoolean("isPlaying", false) + val playbackServerAddress = prefs.getString("playbackServerAddress", null) ?: "" + + var activeTrack: Track? = null + if (activeTrackStr != null) { + activeTrack = gson.fromJson(activeTrackStr, Track::class.java) + } + + + val playIcon = Icon.createWithResource(context, R.drawable.ic_media_play); + val pauseIcon = Icon.createWithResource(context, R.drawable.ic_media_pause); + val previousIcon = Icon.createWithResource(context, R.drawable.ic_media_previous); + val nextIcon = Icon.createWithResource(context, R.drawable.ic_media_next); + + GlanceTheme { + Box( + modifier = GlanceModifier + .fillMaxSize() + .cornerRadius(8.dp) + .background( + color = GlanceTheme.colors.surface.getColor(context) + ) + .clickable { + actionStartActivity(context) + } + , + ) { + Box( + modifier = GlanceModifier + .background( + color = + GlanceTheme.colors.surface.getColor(context) + .copy(alpha = 0.5f), + ) + .fillMaxSize(), + ) {} + Column( + modifier = GlanceModifier.padding(top = 10.dp, start = 10.dp, end = 10.dp) + ) { + Row(verticalAlignment = Alignment.Vertical.CenterVertically) { + TrackDetailsView(activeTrack) + } + Spacer(modifier = GlanceModifier.size(6.dp)) + if (size != Breakpoints.SMALL_SQUARE) { + TrackProgress(prefs) + } + Spacer(modifier = GlanceModifier.size(6.dp)) + Row( + modifier = GlanceModifier.fillMaxWidth(), + horizontalAlignment = Alignment.Horizontal.CenterHorizontally + ) { + CircleIconButton( + imageProvider = ImageProvider(previousIcon), + contentDescription = "Previous", + onClick = actionRunCallback( + parameters = actionParametersOf(serverAddressKey to playbackServerAddress) + ) + ) + Spacer(modifier = GlanceModifier.size(6.dp)) + CircleIconButton( + imageProvider = + if (isPlaying) ImageProvider(pauseIcon) + else ImageProvider(playIcon), + contentDescription = "Play/Pause", + onClick = actionRunCallback( + parameters = actionParametersOf(serverAddressKey to playbackServerAddress) + ) + ) + Spacer(modifier = GlanceModifier.size(6.dp)) + CircleIconButton( + imageProvider = ImageProvider(nextIcon), + contentDescription = "Previous", + onClick = actionRunCallback( + parameters = actionParametersOf( + serverAddressKey to playbackServerAddress + ) + ) + ) + } + } + } + } + } +} + +class PlayPauseAction : InteractiveAction("toggle-playback") +class NextAction : InteractiveAction("next") +class PreviousAction : InteractiveAction("previous") + + +abstract class InteractiveAction(val command: String) : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + val serverAddress = parameters[serverAddressKey] ?: "" + + Log.d("HomePlayerWidget", "Sending command $command to $serverAddress") + + if (serverAddress == null || serverAddress.isEmpty()) { + return + } + + + val backgroundIntent = HomeWidgetBackgroundIntent.getBroadcast( + context, + Uri.parse("spotube://playback/$command?serverAddress=$serverAddress") + ) + backgroundIntent.send() + } +} diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidgetReceiver.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidgetReceiver.kt new file mode 100644 index 00000000..2d23c64f --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/HomePlayerWidgetReceiver.kt @@ -0,0 +1,7 @@ +package oss.krtirtho.spotube.glance + +import HomeWidgetGlanceWidgetReceiver + +class HomePlayerWidgetReceiver : HomeWidgetGlanceWidgetReceiver() { + override val glanceAppWidget = HomePlayerWidget() +} diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/AlbumSimple.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/AlbumSimple.kt new file mode 100644 index 00000000..4edd69f6 --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/AlbumSimple.kt @@ -0,0 +1,40 @@ +package oss.krtirtho.spotube.glance.models + +import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable + +@Serializable +data class AlbumSimple( + @SerializedName("album_type") + val albumType: AlbumType?, + + @SerializedName("available_markets") + val availableMarkets: List?, + + val href: String?, + val id: String?, + val images: List?, + val name: String?, + + @SerializedName("release_date") + val releaseDate: String?, + + @SerializedName("release_date_precision") + val releaseDatePrecision: DatePrecision?, + + val type: String?, + val uri: String?, +) + +@Serializable +enum class AlbumType { + album, + single, + compilation +} + +enum class DatePrecision { + year, + month, + day +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Artist.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Artist.kt new file mode 100644 index 00000000..ef43ecc8 --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Artist.kt @@ -0,0 +1,25 @@ +package oss.krtirtho.spotube.glance.models + +import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable + +@Serializable +data class Artist( + val href: String?, + val id: String?, + val name: String?, + val type: String?, + val uri: String?, + + val followers: Followers?, + val genres: List?, + val images: List?, + + @SerializedName("popularity") + val popularity: Int? +) + +@Serializable +data class Followers( + val total: Int? +) diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Image.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Image.kt new file mode 100644 index 00000000..de7d5521 --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Image.kt @@ -0,0 +1,10 @@ +package oss.krtirtho.spotube.glance.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Image( + val height: Int?, + val width: Int?, + val path: String, +) \ No newline at end of file diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Track.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Track.kt new file mode 100644 index 00000000..717b790f --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/models/Track.kt @@ -0,0 +1,37 @@ +package oss.krtirtho.spotube.glance.models + +import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable +import kotlin.time.Duration.Companion.milliseconds + +@Serializable +data class Track( + val album: AlbumSimple?, val artists: List?, + + @SerializedName("available_markets") val availableMarkets: List?, + + @SerializedName("disc_number") val discNumber: Int?, + + @SerializedName("duration_ms") val durationMs: Int, + + val explicit: Boolean?, val href: String?, val id: String?, + + @SerializedName("is_playable") val isPlayable: Boolean?, + + val name: String?, + + @SerializedName("popularity") val popularity: Int?, + + @SerializedName("preview_url") val previewUrl: String?, + + @SerializedName("track_number") val trackNumber: Int?, + + val type: String?, val uri: String? +) { + val duration: kotlin.time.Duration + get() = durationMs.toLong().milliseconds +} + +enum class Market { + AD, AE, AF, AG, AI, AL, AM, AO, AQ, AR, AS, AT, AU, AW, AX, AZ, BA, BB, BD, BE, BF, BG, BH, BI, BJ, BL, BM, BN, BO, BQ, BR, BS, BT, BV, BW, BY, BZ, CA, CC, CD, CF, CG, CH, CI, CK, CL, CM, CN, CO, CR, CU, CV, CW, CX, CY, CZ, DE, DJ, DK, DM, DO, DZ, EC, EE, EG, EH, ER, ES, ET, FI, FJ, FK, FM, FO, FR, GA, GB, GD, GE, GF, GG, GH, GI, GL, GM, GN, GP, GQ, GR, GS, GT, GU, GW, GY, HK, HM, HN, HR, HT, HU, ID, IE, IL, IM, IN, IO, IQ, IR, IS, IT, JE, JM, JO, JP, KE, KG, KH, KI, KM, KN, KP, KR, KW, KY, KZ, LA, LB, LC, LI, LK, LR, LS, LT, LU, LV, LY, MA, MC, MD, ME, MF, MG, MH, MK, ML, MM, MN, MO, MP, MQ, MR, MS, MT, MU, MV, MW, MX, MY, MZ, NA, NC, NE, NF, NG, NI, NL, NO, NP, NR, NU, NZ, OM, PA, PE, PF, PG, PH, PK, PL, PM, PN, PR, PS, PT, PW, PY, QA, RE, RO, RS, RU, RW, SA, SB, SC, SD, SE, SG, SH, SI, SJ, SK, SL, SM, SN, SO, SR, SS, ST, SV, SX, SY, SZ, TC, TD, TF, TG, TH, TJ, TK, TL, TM, TN, TO, TR, TT, TV, TW, TZ, UA, UG, UM, US, UY, UZ, VA, VC, VE, VG, VI, VN, VU, WF, WS, XK, YE, YT, ZA, ZM, ZW, +} diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/Base64ImageProvider.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/Base64ImageProvider.kt new file mode 100644 index 00000000..79339cea --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/Base64ImageProvider.kt @@ -0,0 +1,14 @@ +package oss.krtirtho.spotube.glance.widgets + +import android.graphics.BitmapFactory +import android.util.Base64 +import androidx.glance.ImageProvider + +@Suppress("FunctionName") +fun Base64ImageProvider(base64: String): ImageProvider { + var bytes = Base64.decode(base64, Base64.DEFAULT); + + var bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size); + + return ImageProvider(bitmap) +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/FlutterAssetImageProvider.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/FlutterAssetImageProvider.kt new file mode 100644 index 00000000..ad51ca3c --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/FlutterAssetImageProvider.kt @@ -0,0 +1,14 @@ +package oss.krtirtho.spotube.glance.widgets + +import android.content.Context +import android.graphics.BitmapFactory +import androidx.glance.ImageProvider + +@Suppress("FunctionName") +fun FlutterAssetImageProvider(context: Context, path: String): ImageProvider { + var inputStream = context.assets.open("flutter_assets/$path") + + return ImageProvider( + BitmapFactory.decodeStream(inputStream) + ) +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackDetailsView.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackDetailsView.kt new file mode 100644 index 00000000..fdfe8e4b --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackDetailsView.kt @@ -0,0 +1,78 @@ +package oss.krtirtho.spotube.glance.widgets + +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.appwidget.cornerRadius +import androidx.glance.layout.Alignment +import androidx.glance.layout.Row +import androidx.glance.layout.Column +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Spacer +import androidx.glance.layout.size +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import oss.krtirtho.spotube.glance.Breakpoints +import oss.krtirtho.spotube.glance.models.Track + +@Composable +fun TrackDetailsView(activeTrack: Track?) { + val context = LocalContext.current + + val size = LocalSize.current + + val artistStr = activeTrack?.artists?.map { it.name }?.joinToString(", ") ?: "" + val imgLocalPath = activeTrack?.album?.images?.get(0)?.path; + val title = activeTrack?.name ?: "" + + + Image( + provider = + if (imgLocalPath == null) + ImageProvider( + BitmapFactory.decodeResource( + context.resources, + android.R.drawable.ic_delete + ) + ) + else ImageProvider(BitmapFactory.decodeFile(imgLocalPath)), + contentDescription = "Album Art", + modifier = GlanceModifier.cornerRadius(8.dp) + .size( + if (size.height < 200.dp) 50.dp + else 100.dp + ), + contentScale = ContentScale.Fit + ) + Spacer(modifier = GlanceModifier.size(6.dp)) + Column { + Text( + text = title, + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = GlanceTheme.colors.onBackground + ), + ) + if (size != Breakpoints.SMALL_SQUARE) { + Spacer(modifier = GlanceModifier.size(6.dp)) + Text( + text = artistStr, + style = TextStyle( + fontSize = 14.sp, + color = GlanceTheme.colors.onBackground + ), + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackProgress.kt b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackProgress.kt new file mode 100644 index 00000000..b54059b1 --- /dev/null +++ b/android/app/src/main/kotlin/oss/krtirtho/spotube/glance/widgets/TrackProgress.kt @@ -0,0 +1,77 @@ +package oss.krtirtho.spotube.glance.widgets + +import android.content.SharedPreferences +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.LocalSize +import androidx.glance.appwidget.LinearProgressIndicator +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.size +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import kotlin.math.max +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import oss.krtirtho.spotube.glance.Breakpoints + +fun Duration.format(): String { + return this.toComponents { hour, minutes, seconds, nanoseconds -> + var paddedSeconds = seconds.toString().padStart(2, '0') + var paddedMinutes = minutes.toString().padStart(2, '0') + var paddedHour = hour.toString().padStart(2, '0') + if (hour == 0L) { + "$paddedMinutes:$paddedSeconds" + } else { + "$paddedHour:$paddedMinutes:$paddedSeconds" + } + } +} + +@Composable +fun TrackProgress(prefs: SharedPreferences) { + val size = LocalSize.current + val position = prefs.getInt("position", 0).seconds + var duration = prefs.getInt("duration", 0).seconds + + var progress = position.inWholeSeconds.toFloat() / max(duration.inWholeSeconds.toFloat(), 1.0f) + + var textStyle = + TextStyle( + color = GlanceTheme.colors.onBackground, + ) + + if (size == Breakpoints.HORIZONTAL_RECTANGLE) { + Row(modifier = GlanceModifier.fillMaxWidth()) { + Text(text = position.format(), style = textStyle) + Spacer(modifier = GlanceModifier.size(6.dp)) + LinearProgressIndicator( + progress = progress, + modifier = GlanceModifier.defaultWeight(), + color = GlanceTheme.colors.primary, + backgroundColor = GlanceTheme.colors.primaryContainer, + ) + Spacer(modifier = GlanceModifier.size(6.dp)) + Text(text = duration.format(), style = textStyle) + } + } else { + Column(modifier = GlanceModifier.fillMaxWidth()) { + LinearProgressIndicator( + progress = progress, + modifier = GlanceModifier.fillMaxWidth(), + color = GlanceTheme.colors.primary, + backgroundColor = GlanceTheme.colors.primaryContainer, + ) + Spacer(modifier = GlanceModifier.size(6.dp)) + Row(modifier = GlanceModifier.fillMaxWidth()) { + Text(text = position.format(), style = textStyle) + Spacer(modifier = GlanceModifier.defaultWeight()) + Text(text = duration.format(), style = textStyle) + } + } + } +} diff --git a/android/app/src/main/res/xml/home_player_widget_config.xml b/android/app/src/main/res/xml/home_player_widget_config.xml new file mode 100644 index 00000000..c8ec7048 --- /dev/null +++ b/android/app/src/main/res/xml/home_player_widget_config.xml @@ -0,0 +1,7 @@ + + diff --git a/android/app/src/nightly/res/drawable/ic_launcher_monochrome.xml b/android/app/src/nightly/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 00000000..8aae0e6c --- /dev/null +++ b/android/app/src/nightly/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/android/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml index 5f349f7f..83e651db 100644 --- a/android/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,6 @@ - - - + + + + \ No newline at end of file diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml index 1041f6ca..a32d12af 100644 --- a/android/app/src/profile/AndroidManifest.xml +++ b/android/app/src/profile/AndroidManifest.xml @@ -1,7 +1,6 @@ - - - - + + \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index bc157bd1..8f31e8ca 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -15,4 +15,4 @@ subprojects { tasks.register("clean", Delete) { delete rootProject.buildDir -} +} \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 02e5f581..bf6b7385 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Fri Jun 23 08:50:38 CEST 2017 +#Fri Dec 13 21:53:13 BDT 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 89651748..1e8ffbe3 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,8 +18,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.2.1" apply false + id "com.android.application" version '8.7.0' apply false id "org.jetbrains.kotlin.android" version "1.8.22" apply false } -include ":app" \ No newline at end of file +include ':app' \ No newline at end of file diff --git a/assets/backgrounds/xmas-effect.png b/assets/backgrounds/xmas-effect.png new file mode 100644 index 00000000..e7c8eeef Binary files /dev/null and b/assets/backgrounds/xmas-effect.png differ diff --git a/assets/patterns/black_white_visualized.jpg b/assets/patterns/black_white_visualized.jpg new file mode 100644 index 00000000..e56a2780 Binary files /dev/null and b/assets/patterns/black_white_visualized.jpg differ diff --git a/assets/patterns/brazil_carnival.jpg b/assets/patterns/brazil_carnival.jpg new file mode 100644 index 00000000..a7cdb3a1 Binary files /dev/null and b/assets/patterns/brazil_carnival.jpg differ diff --git a/assets/patterns/cotton_balls.jpg b/assets/patterns/cotton_balls.jpg new file mode 100644 index 00000000..db6f02a8 Binary files /dev/null and b/assets/patterns/cotton_balls.jpg differ diff --git a/assets/patterns/cute_worms.jpg b/assets/patterns/cute_worms.jpg new file mode 100644 index 00000000..0c9f4fbb Binary files /dev/null and b/assets/patterns/cute_worms.jpg differ diff --git a/assets/patterns/flash_cross_axis.jpg b/assets/patterns/flash_cross_axis.jpg new file mode 100644 index 00000000..c6e52283 Binary files /dev/null and b/assets/patterns/flash_cross_axis.jpg differ diff --git a/assets/patterns/memphis_shapes.jpg b/assets/patterns/memphis_shapes.jpg new file mode 100644 index 00000000..2db8e775 Binary files /dev/null and b/assets/patterns/memphis_shapes.jpg differ diff --git a/assets/patterns/oval_gloomy.jpg b/assets/patterns/oval_gloomy.jpg new file mode 100644 index 00000000..b44bf945 Binary files /dev/null and b/assets/patterns/oval_gloomy.jpg differ diff --git a/assets/patterns/oval_sunny.jpg b/assets/patterns/oval_sunny.jpg new file mode 100644 index 00000000..bc07ae83 Binary files /dev/null and b/assets/patterns/oval_sunny.jpg differ diff --git a/assets/patterns/red_nimbuses.jpg b/assets/patterns/red_nimbuses.jpg new file mode 100644 index 00000000..6527999c Binary files /dev/null and b/assets/patterns/red_nimbuses.jpg differ diff --git a/assets/patterns/tree_bark.jpg b/assets/patterns/tree_bark.jpg new file mode 100644 index 00000000..0dac37d7 Binary files /dev/null and b/assets/patterns/tree_bark.jpg differ diff --git a/assets/patterns/vibrant_pentagons.jpg b/assets/patterns/vibrant_pentagons.jpg new file mode 100644 index 00000000..d9e8d537 Binary files /dev/null and b/assets/patterns/vibrant_pentagons.jpg differ diff --git a/assets/patterns/wiring_pattern.jpg b/assets/patterns/wiring_pattern.jpg new file mode 100644 index 00000000..9fc3b781 Binary files /dev/null and b/assets/patterns/wiring_pattern.jpg differ diff --git a/assets/patterns/zigzags_gloomy.jpg b/assets/patterns/zigzags_gloomy.jpg new file mode 100644 index 00000000..c6ccd2a3 Binary files /dev/null and b/assets/patterns/zigzags_gloomy.jpg differ diff --git a/assets/patterns/zigzags_sunny.jpg b/assets/patterns/zigzags_sunny.jpg new file mode 100644 index 00000000..7470d5ef Binary files /dev/null and b/assets/patterns/zigzags_sunny.jpg differ diff --git a/aur-struct/.SRCINFO b/aur-struct/.SRCINFO index 4c07a045..772594f6 100644 --- a/aur-struct/.SRCINFO +++ b/aur-struct/.SRCINFO @@ -1,8 +1,8 @@ pkgbase = spotube-bin pkgdesc = Open source Spotify client that doesn't require Premium nor uses Electron! Available for both desktop & mobile! -pkgver = 3.7.1 -pkgrel = 2 -url = https://github.com/KRTirtho/spotube/ +pkgver = 4.0.0 +pkgrel = 1 +url = https://spotube.krtirtho.dev arch = x86_64 license = BSD-4-Clause depends = mpv @@ -12,6 +12,7 @@ depends = jsoncpp depends = libnotify depends = xdg-user-dirs depends = webkit2gtk-4.1 +optdepends = yt-dlp-git source = https://github.com/KRTirtho/spotube/releases/download/v3.7.1/spotube-linux-3.7.1-x86_64.tar.xz md5sums = 475b1ae9b08f27743a4d4749391ae3db diff --git a/aur-struct/PKGBUILD b/aur-struct/PKGBUILD index d7e1052b..cf6c0134 100644 --- a/aur-struct/PKGBUILD +++ b/aur-struct/PKGBUILD @@ -5,13 +5,13 @@ pkgrel=%{{PKGREL}}% epoch= pkgdesc="Open source Spotify client that doesn't require Premium nor uses Electron! Available for both desktop & mobile!" arch=(x86_64) -url="https://github.com/KRTirtho/spotube/" +url="https://spotube.krtirtho.dev" license=('BSD-4-Clause') groups=() depends=('mpv' 'libappindicator-gtk3' 'libsecret' 'jsoncpp' 'libnotify' 'xdg-user-dirs' 'webkit2gtk-4.1') makedepends=() checkdepends=() -optdepends=() +optdepends=('yt-dlp-git') provides=() conflicts=() replaces=() diff --git a/build.yaml b/build.yaml index 17d5bc50..76771f22 100644 --- a/build.yaml +++ b/build.yaml @@ -4,6 +4,16 @@ targets: exclude: - bin/*.dart builders: + auto_route_generator:auto_route_generator: # this for @RoutePage + options: + enable_cached_builds: true + generate_for: + - lib/pages/**/*.dart + auto_route_generator:auto_router_generator: # this for @AutoRouterConfig + options: + enable_cached_builds: true + generate_for: + - lib/collections/routes.dart json_serializable: options: any_map: true diff --git a/choco-struct/spotube.nuspec b/choco-struct/spotube.nuspec index 1cef4354..1ebcd3c7 100644 --- a/choco-struct/spotube.nuspec +++ b/choco-struct/spotube.nuspec @@ -1,5 +1,6 @@ - + @@ -12,34 +13,39 @@ spotube (Install) Kingkor Roy Tirtho - https://github.com/KRTirtho/spotube/ - https://rawcdn.githack.com/KRTirtho/spotube/7edb0bb834eb18c05551e30a891720a6abf53dbe/assets/spotube-logo.png + https://spotube.krtirtho.dev + + https://rawcdn.githack.com/KRTirtho/spotube/7edb0bb834eb18c05551e30a891720a6abf53dbe/assets/spotube-logo.png 2022 Spotube https://github.com/KRTirtho/spotube/blob/master/LICENSE true https://github.com/KRTirtho/spotube - https://github.com/KRTirtho/spotube#readme + https://spotube.krtirtho.dev https://github.com/KRTirtho/spotube/issues/new spotube music audio spotify youtube flutter - 🎧 Open source Spotify client that doesn't require Premium nor uses Electron! Available for both desktop & mobile! + 🎧 Open source Spotify client that doesn't require Premium nor uses Electron! Available + for both desktop & mobile! - Spotube is a Flutter based lightweight spotify client. It utilizes the power - of Spotify & Youtube's public API & creates a hazardless, performant & resource - friendly User Experience + Spotube is a Flutter based lightweight spotify client. It utilizes the power + of Spotify & Youtube's public API & creates a hazardless, performant & resource + friendly User Experience - # Features - - Open source/libre software - - Anonymous/guest login - - Cross platform support - - No telemetry, diagnostics or user data collection - - Lightweight & resource-friendly - - Native performance (Thanks to Flutter+Skia) - - Playback control is done locally instead of on the server - - Small size & less data usage - - No Spotify or YouTube ads since it uses all public & free APIs (It is still recommended to support the creators by watching/liking/subscribing to the artists' YouTube channels or liking their tracks on Spotify. Purchasing Spotify Premium is usually the best way to support their valuable creations.) - - Time synced lyrics - - Downloadable tracks + # Features + - Open source/libre software + - Anonymous/guest login + - Cross platform support + - No telemetry, diagnostics or user data collection + - Lightweight & resource-friendly + - Native performance (Thanks to Flutter+Skia) + - Playback control is done locally instead of on the server + - Small size & less data usage + - No Spotify or YouTube ads since it uses all public & free APIs (It is still recommended + to support the creators by watching/liking/subscribing to the artists' YouTube channels or + liking their tracks on Spotify. Purchasing Spotify Premium is usually the best way to support + their valuable creations.) + - Time synced lyrics + - Downloadable tracks https://github.com/KRTirtho/spotube/releases/tag/v%{{SPOTUBE_VERSION}}% diff --git a/choco-struct/tools/LICENSE.txt b/choco-struct/tools/LICENSE.txt index 6d460a42..1a285107 100644 --- a/choco-struct/tools/LICENSE.txt +++ b/choco-struct/tools/LICENSE.txt @@ -2,7 +2,7 @@ BSD 4-Clause License -Copyright (c) 2022 Kingkor Roy Tirtho. All rights reserved. +Copyright (c) 2025 Kingkor Roy Tirtho. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/cli/commands/build.dart b/cli/commands/build.dart index fdf35a95..e0c254ff 100644 --- a/cli/commands/build.dart +++ b/cli/commands/build.dart @@ -3,7 +3,6 @@ import 'package:args/command_runner.dart'; import 'build/android.dart'; import 'build/ios.dart'; import 'build/linux.dart'; -import 'build/linux_arm.dart'; import 'build/macos.dart'; import 'build/windows.dart'; @@ -18,8 +17,13 @@ class BuildCommand extends Command { addSubcommand(AndroidBuildCommand()); addSubcommand(IosBuildCommand()); addSubcommand(LinuxBuildCommand()); - addSubcommand(LinuxArmBuildCommand()); addSubcommand(MacosBuildCommand()); addSubcommand(WindowsBuildCommand()); + argParser.addOption( + "arch", + abbr: "a", + defaultsTo: "x86", + allowed: ["x86", "arm64", "all"], + ); } } diff --git a/cli/commands/build/common.dart b/cli/commands/build/common.dart index 4c7e3e51..30906b3c 100644 --- a/cli/commands/build/common.dart +++ b/cli/commands/build/common.dart @@ -63,4 +63,6 @@ mixin BuildCommandCommonSteps on Command { """, ); } + + String get architecture => parent?.argResults?.option("arch") as String; } diff --git a/cli/commands/build/linux.dart b/cli/commands/build/linux.dart index a218720c..3fd8a0b9 100644 --- a/cli/commands/build/linux.dart +++ b/cli/commands/build/linux.dart @@ -37,23 +37,32 @@ class LinuxBuildCommand extends Command with BuildCommandCommonSteps { await bootstrap(); await shell.run( - """ - flutter_distributor package --platform=linux --targets=deb - flutter_distributor package --platform=linux --targets=rpm - """, + "flutter_distributor package --platform=linux --targets=deb", ); - final tempDir = join(Directory.systemTemp.path, "spotube-tar"); + if (architecture == "x86") { + await shell.run( + "flutter_distributor package --platform=linux --targets=rpm", + ); + } - final bundleDirPath = - join(cwd.path, "build", "linux", "x64", "release", "bundle"); + final tempDir = join(Directory.systemTemp.path, "spotube-tar"); + final bundleArchName = architecture == "x86" ? "x86_64" : "aarch64"; + final bundleDirPath = join( + cwd.path, + "build", + "linux", + architecture == "x86" ? "x64" : architecture, + "release", + "bundle", + ); final tarFile = File(join( cwd.path, "dist", "spotube-linux-" "${CliEnv.channel == BuildChannel.nightly ? "nightly" : versionWithoutBuildNumber}" - "-x86_64.tar.xz", + "-$bundleArchName.tar.xz", )); await copyPath(bundleDirPath, tempDir); @@ -81,25 +90,31 @@ class LinuxBuildCommand extends Command with BuildCommandCommonSteps { "spotube-${pubspec.version}-linux.deb", ), ); - - final ogRpm = File( + await ogDeb.copy( join( cwd.path, "dist", - pubspec.version.toString(), - "spotube-${pubspec.version}-linux.rpm", + "Spotube-linux-$bundleArchName.deb", ), ); - - await ogDeb.copy( - join(cwd.path, "dist", "Spotube-linux-x86_64.deb"), - ); - await ogRpm.copy( - join(cwd.path, "dist", "Spotube-linux-x86_64.rpm"), - ); - await ogDeb.delete(); - await ogRpm.delete(); + + if (architecture == "x86") { + final ogRpm = File( + join( + cwd.path, + "dist", + pubspec.version.toString(), + "spotube-${pubspec.version}-linux.rpm", + ), + ); + + await ogRpm.copy( + join(cwd.path, "dist", "Spotube-linux-$bundleArchName.rpm"), + ); + + await ogRpm.delete(); + } stdout.writeln("✅ Linux building done"); } diff --git a/cli/commands/build/linux_arm.dart b/cli/commands/build/linux_arm.dart deleted file mode 100644 index a09f0980..00000000 --- a/cli/commands/build/linux_arm.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'dart:async'; - -import 'package:args/command_runner.dart'; -import 'package:path/path.dart'; - -import '../../core/env.dart'; -import 'common.dart'; - -class LinuxArmBuildCommand extends Command with BuildCommandCommonSteps { - @override - String get description => "Build Linux Arm"; - - @override - String get name => "linux_arm"; - - @override - FutureOr? run() async { - await bootstrap(); - - await shell.run( - "docker buildx build --platform=linux/arm64 " - "-f ${join(cwd.path, ".github", "Dockerfile")} ${cwd.path} " - "--build-arg FLUTTER_VERSION=${CliEnv.flutterVersion} " - "--build-arg BUILD_VERSION=${CliEnv.channel == BuildChannel.nightly ? "nightly" : versionWithoutBuildNumber} " - "-t krtirtho/spotube_linux_arm:latest " - "--load", - ); - - await shell.run( - """ - docker images ls - docker create --name spotube_linux_arm krtirtho/spotube_linux_arm:latest - docker cp spotube_linux_arm:/app/dist/ dist/ - """, - ); - } -} diff --git a/cli/commands/install-dependencies.dart b/cli/commands/install-dependencies.dart index dc519cc6..e26b8078 100644 --- a/cli/commands/install-dependencies.dart +++ b/cli/commands/install-dependencies.dart @@ -24,6 +24,13 @@ class InstallDependenciesCommand extends Command { ], mandatory: true, ); + + argParser.addOption( + "arch", + abbr: "a", + allowed: ["x86", "arm64", "all"], + defaultsTo: "x86", + ); } @override @@ -41,14 +48,6 @@ class InstallDependenciesCommand extends Command { """, ); break; - case "linux_arm": - await shell.run( - """ - sudo apt-get update -y - sudo apt-get install -y pkg-config make python3-pip python3-setuptools - """, - ); - break; case "macos": await shell.run( """ diff --git a/drift_schemas/app_db/drift_schema_v4.json b/drift_schemas/app_db/drift_schema_v4.json new file mode 100644 index 00000000..fc50a6f8 --- /dev/null +++ b/drift_schemas/app_db/drift_schema_v4.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"authentication_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"cookie","getter_name":"cookie","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"access_token","getter_name":"accessToken","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}},{"name":"expiration","getter_name":"expiration","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"blacklist_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"element_type","getter_name":"elementType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BlacklistedType.values)","dart_type_name":"BlacklistedType"}},{"name":"element_id","getter_name":"elementId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[],"type":"table","data":{"name":"preferences_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"audio_quality","getter_name":"audioQuality","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceQualities.high.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceQualities.values)","dart_type_name":"SourceQualities"}},{"name":"album_color_sync","getter_name":"albumColorSync","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"album_color_sync\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"album_color_sync\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"amoled_dark_theme","getter_name":"amoledDarkTheme","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"amoled_dark_theme\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"amoled_dark_theme\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"check_update","getter_name":"checkUpdate","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"check_update\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"check_update\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"normalize_audio","getter_name":"normalizeAudio","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"normalize_audio\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"normalize_audio\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"show_system_tray_icon","getter_name":"showSystemTrayIcon","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"show_system_tray_icon\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"show_system_tray_icon\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"system_title_bar","getter_name":"systemTitleBar","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"system_title_bar\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"system_title_bar\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"skip_non_music","getter_name":"skipNonMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"skip_non_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"skip_non_music\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"close_behavior","getter_name":"closeBehavior","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(CloseBehavior.close.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(CloseBehavior.values)","dart_type_name":"CloseBehavior"}},{"name":"accent_color_scheme","getter_name":"accentColorScheme","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"Blue:0xFF2196F3\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SpotubeColorConverter()","dart_type_name":"SpotubeColor"}},{"name":"layout_mode","getter_name":"layoutMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(LayoutMode.adaptive.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(LayoutMode.values)","dart_type_name":"LayoutMode"}},{"name":"locale","getter_name":"locale","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant('{\"languageCode\":\"system\",\"countryCode\":\"system\"}')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const LocaleConverter()","dart_type_name":"Locale"}},{"name":"market","getter_name":"market","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(Market.US.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(Market.values)","dart_type_name":"Market"}},{"name":"search_mode","getter_name":"searchMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SearchMode.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SearchMode.values)","dart_type_name":"SearchMode"}},{"name":"download_location","getter_name":"downloadLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[]},{"name":"local_library_location","getter_name":"localLibraryLocation","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"\")","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}},{"name":"piped_instance","getter_name":"pipedInstance","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"https://pipedapi.kavin.rocks\")","default_client_dart":null,"dsl_features":[]},{"name":"invidious_instance","getter_name":"invidiousInstance","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const Constant(\"https://inv.nadeko.net\")","default_client_dart":null,"dsl_features":[]},{"name":"theme_mode","getter_name":"themeMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(ThemeMode.system.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeMode.values)","dart_type_name":"ThemeMode"}},{"name":"audio_source","getter_name":"audioSource","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(AudioSource.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(AudioSource.values)","dart_type_name":"AudioSource"}},{"name":"youtube_client_engine","getter_name":"youtubeClientEngine","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(YoutubeClientEngine.youtubeExplode.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(YoutubeClientEngine.values)","dart_type_name":"YoutubeClientEngine"}},{"name":"stream_music_codec","getter_name":"streamMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.weba.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"download_music_codec","getter_name":"downloadMusicCodec","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceCodecs.m4a.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceCodecs.values)","dart_type_name":"SourceCodecs"}},{"name":"discord_presence","getter_name":"discordPresence","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"discord_presence\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"discord_presence\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"endless_playback","getter_name":"endlessPlayback","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"endless_playback\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"endless_playback\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]},{"name":"enable_connect","getter_name":"enableConnect","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"enable_connect\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"enable_connect\" IN (0, 1))"},"default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"cache_music","getter_name":"cacheMusic","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"cache_music\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"cache_music\" IN (0, 1))"},"default_dart":"const Constant(true)","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":3,"references":[],"type":"table","data":{"name":"scrobbler_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"username","getter_name":"username","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"password_hash","getter_name":"passwordHash","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"EncryptedTextConverter()","dart_type_name":"DecryptedText"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":4,"references":[],"type":"table","data":{"name":"skip_segment_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"start","getter_name":"start","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"end","getter_name":"end","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":5,"references":[],"type":"table","data":{"name":"source_match_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_id","getter_name":"sourceId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"Constant(SourceType.youtube.name)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(SourceType.values)","dart_type_name":"SourceType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":6,"references":[],"type":"table","data":{"name":"audio_player_state_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"playing","getter_name":"playing","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"playing\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"playing\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"loop_mode","getter_name":"loopMode","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(PlaylistMode.values)","dart_type_name":"PlaylistMode"}},{"name":"shuffled","getter_name":"shuffled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"shuffled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"shuffled\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"collections","getter_name":"collections","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const StringListConverter()","dart_type_name":"List"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":7,"references":[6],"type":"table","data":{"name":"playlist_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"audio_player_state_id","getter_name":"audioPlayerStateId","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES audio_player_state_table (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES audio_player_state_table (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"index","getter_name":"index","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":8,"references":[7],"type":"table","data":{"name":"playlist_media_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"playlist_id","getter_name":"playlistId","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES playlist_table (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES playlist_table (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"uri","getter_name":"uri","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"extras","getter_name":"extras","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}},{"name":"http_headers","getter_name":"httpHeaders","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":9,"references":[],"type":"table","data":{"name":"history_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"currentDateAndTime","default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(HistoryEntryType.values)","dart_type_name":"HistoryEntryType"}},{"name":"item_id","getter_name":"itemId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const MapTypeConverter()","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":10,"references":[],"type":"table","data":{"name":"lyrics_table","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"track_id","getter_name":"trackId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"SubtitleTypeConverter()","dart_type_name":"SubtitleSimple"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":11,"references":[1],"type":"index","data":{"on":1,"name":"unique_blacklist","sql":null,"unique":true,"columns":["element_type","element_id"]}},{"id":12,"references":[5],"type":"index","data":{"on":5,"name":"uniq_track_match","sql":null,"unique":true,"columns":["track_id","source_id","source_type"]}}]} \ No newline at end of file diff --git a/flutter_launcher_icons-stable.yaml b/flutter_launcher_icons-stable.yaml deleted file mode 100644 index 0d205cbd..00000000 --- a/flutter_launcher_icons-stable.yaml +++ /dev/null @@ -1,13 +0,0 @@ -flutter_launcher_icons: - ios: true - android: true - image_path: "assets/spotube-logo.png" - adaptive_icon_foreground: "assets/spotube-logo-foreground.jpg" - adaptive_icon_background: "#242832" - windows: - generate: true - image_path: "assets/spotube-logo.png" - icon_size: 48 # min:48, max:256, default: 48 - macos: - generate: true - image_path: "assets/spotube-logo-macos.png" diff --git a/flutter_launcher_icons.yaml b/flutter_launcher_icons.yaml new file mode 100644 index 00000000..372117b1 --- /dev/null +++ b/flutter_launcher_icons.yaml @@ -0,0 +1,29 @@ +# flutter pub run flutter_launcher_icons +flutter_launcher_icons: + image_path: "assets/spotube-logo.png" + + android: true + # image_path_android: "assets/icon/icon.png" + min_sdk_android: 21 # android min sdk min:16, default 21 + adaptive_icon_background: "#242832" + adaptive_icon_foreground: "assets/spotube-logo-foreground.jpg" + # adaptive_icon_monochrome: "assets/icon/monochrome.png" + + ios: true + # image_path_ios: "assets/icon/icon.png" + remove_alpha_channel_ios: true + # image_path_ios_dark_transparent: "assets/icon/icon_dark.png" + # image_path_ios_tinted_grayscale: "assets/icon/icon_tinted.png" + # desaturate_tinted_to_grayscale_ios: true + + web: + generate: false + + windows: + generate: true + image_path: "assets/spotube-logo.png" + icon_size: 48 # min:48, max:256, default: 48 + + macos: + generate: true + image_path: "assets/spotube-logo-macos.png" diff --git a/ios/HomePlayerWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/HomePlayerWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/ios/HomePlayerWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/HomePlayerWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/HomePlayerWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..23058801 --- /dev/null +++ b/ios/HomePlayerWidget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/HomePlayerWidget/Assets.xcassets/Contents.json b/ios/HomePlayerWidget/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ios/HomePlayerWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/HomePlayerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/ios/HomePlayerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/ios/HomePlayerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/HomePlayerWidget/HomePlayerWidget.swift b/ios/HomePlayerWidget/HomePlayerWidget.swift new file mode 100644 index 00000000..8808aae1 --- /dev/null +++ b/ios/HomePlayerWidget/HomePlayerWidget.swift @@ -0,0 +1,86 @@ +// +// HomePlayerWidget.swift +// HomePlayerWidget +// +// Created by Kingkor Roy Tirtho on 15/12/24. +// + +import WidgetKit +import SwiftUI + +private let widgetGroupId = "group.spotube_home_player_widget" + +struct Provider: TimelineProvider { + func placeholder(in context: Context) -> SimpleEntry { + SimpleEntry(date: Date(), emoji: "😀") + } + + func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { + let entry = SimpleEntry(date: Date(), emoji: "😀") + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + var entries: [SimpleEntry] = [] + + // Generate a timeline consisting of five entries an hour apart, starting from the current date. + let currentDate = Date() + for hourOffset in 0 ..< 5 { + let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! + let entry = SimpleEntry(date: entryDate, emoji: "😀") + entries.append(entry) + } + + let timeline = Timeline(entries: entries, policy: .atEnd) + completion(timeline) + } + +// func relevances() async -> WidgetRelevances { +// // Generate a list containing the contexts this widget is relevant in. +// } +} + +struct SimpleEntry: TimelineEntry { + let date: Date + let emoji: String +} + +struct HomePlayerWidgetEntryView : View { + var entry: Provider.Entry + + var body: some View { + VStack { + Text("Time:") + Text(entry.date, style: .time) + + Text("Emoji:") + Text(entry.emoji) + } + } +} + +struct HomePlayerWidget: Widget { + let kind: String = "HomePlayerWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: Provider()) { entry in + if #available(iOS 17.0, *) { + HomePlayerWidgetEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } else { + HomePlayerWidgetEntryView(entry: entry) + .padding() + .background() + } + } + .configurationDisplayName("My Widget") + .description("This is an example widget.") + } +} + +#Preview(as: .systemSmall) { + HomePlayerWidget() +} timeline: { + SimpleEntry(date: .now, emoji: "😀") + SimpleEntry(date: .now, emoji: "🤩") +} diff --git a/ios/HomePlayerWidget/HomePlayerWidgetBundle.swift b/ios/HomePlayerWidget/HomePlayerWidgetBundle.swift new file mode 100644 index 00000000..68158b53 --- /dev/null +++ b/ios/HomePlayerWidget/HomePlayerWidgetBundle.swift @@ -0,0 +1,16 @@ +// +// HomePlayerWidgetBundle.swift +// HomePlayerWidget +// +// Created by Kingkor Roy Tirtho on 15/12/24. +// + +import WidgetKit +import SwiftUI + +@main +struct HomePlayerWidgetBundle: WidgetBundle { + var body: some Widget { + HomePlayerWidget() + } +} diff --git a/ios/HomePlayerWidget/Info.plist b/ios/HomePlayerWidget/Info.plist new file mode 100644 index 00000000..0f118fb7 --- /dev/null +++ b/ios/HomePlayerWidget/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/ios/HomePlayerWidgetExtension.entitlements b/ios/HomePlayerWidgetExtension.entitlements new file mode 100644 index 00000000..58165678 --- /dev/null +++ b/ios/HomePlayerWidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.spotube_home_player_widget + + + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 104ff767..31ffe436 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -64,6 +64,8 @@ PODS: - Flutter - flutter_sharing_intent (0.0.1): - Flutter + - home_widget (0.0.1): + - Flutter - image_picker_ios (0.0.1): - Flutter - integration_test (0.0.1): @@ -106,12 +108,15 @@ PODS: - sqlite3/common - sqlite3_flutter_libs (0.0.1): - Flutter - - sqlite3 (~> 3.47.0) + - FlutterMacOS + - sqlite3 (~> 3.47.1) - sqlite3/dbstatvtab - sqlite3/fts5 - sqlite3/perf-threadsafe - sqlite3/rtree - SwiftyGif (5.4.4) + - system_theme (0.0.1): + - Flutter - url_launcher_ios (0.0.1): - Flutter @@ -130,6 +135,7 @@ DEPENDENCIES: - 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`) + - home_widget (from `.symlinks/plugins/home_widget/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - media_kit_libs_ios_audio (from `.symlinks/plugins/media_kit_libs_ios_audio/ios`) @@ -141,7 +147,8 @@ DEPENDENCIES: - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/ios`) + - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) + - system_theme (from `.symlinks/plugins/system_theme/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) SPEC REPOS: @@ -182,6 +189,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_secure_storage/ios" flutter_sharing_intent: :path: ".symlinks/plugins/flutter_sharing_intent/ios" + home_widget: + :path: ".symlinks/plugins/home_widget/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" integration_test: @@ -205,7 +214,9 @@ EXTERNAL SOURCES: sqflite_darwin: :path: ".symlinks/plugins/sqflite_darwin/darwin" sqlite3_flutter_libs: - :path: ".symlinks/plugins/sqlite3_flutter_libs/ios" + :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" + system_theme: + :path: ".symlinks/plugins/system_theme/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" @@ -226,6 +237,7 @@ SPEC CHECKSUMS: flutter_native_splash: e8a1e01082d97a8099d973f919f57904c925008a flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 flutter_sharing_intent: e35380d0e1501d7111dbb7e46d5ac6339da6da98 + home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 media_kit_libs_ios_audio: 8f39d96a9c630685dfb844c289bd1d114c486fb3 @@ -240,8 +252,9 @@ SPEC CHECKSUMS: shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d sqlite3: 1e522f0938463e44b7faf50393b40bdc1e1e456d - sqlite3_flutter_libs: b55ef23cfafea5318ae5081e0bf3fbbce8417c94 + sqlite3_flutter_libs: 1b4e98da20ebd4e9b1240269b78cdcf492dbe9f3 SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f + system_theme: bfc1b0913d08f38d8c6bbe94b202a58df599d9f7 url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 34793f68..bbfc1404 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -36,8 +36,21 @@ B536BDD62B4060B3009B3CE4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; B536BDD72B4060B3009B3CE4 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; C36A05AD330BBFAED75A62D5 /* Pods_dev.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4238A4985255EC9F93067739 /* Pods_dev.framework */; }; + E612EC3B2D0F07A90022720C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E612EC3A2D0F07A90022720C /* WidgetKit.framework */; }; + E612EC3D2D0F07A90022720C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E612EC3C2D0F07A90022720C /* SwiftUI.framework */; }; + E612EC482D0F07AD0022720C /* HomePlayerWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E612EC392D0F07A90022720C /* HomePlayerWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + E612EC462D0F07AD0022720C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = E612EC382D0F07A80022720C; + remoteInfo = HomePlayerWidgetExtension; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; @@ -79,6 +92,17 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + E612EC492D0F07AD0022720C /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + E612EC482D0F07AD0022720C /* HomePlayerWidgetExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -148,6 +172,14 @@ D32BAE0F55672DD7669755B8 /* Pods-Runner.debug-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug-stable.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug-stable.xcconfig"; sourceTree = ""; }; D9A69004587D01A7C68666CF /* Pods-dev.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.release.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.release.xcconfig"; sourceTree = ""; }; E0EAB4380EE7C7EA7A350B6F /* Pods-stable.release-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-stable.release-nightly.xcconfig"; path = "Target Support Files/Pods-stable/Pods-stable.release-nightly.xcconfig"; sourceTree = ""; }; + E612EC392D0F07A90022720C /* HomePlayerWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HomePlayerWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + E612EC3A2D0F07A90022720C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + E612EC3C2D0F07A90022720C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + E6F17DB92D0F34E500BC2FA2 /* HomePlayerWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HomePlayerWidgetExtension.entitlements; sourceTree = ""; }; + E6F17DBA2D0F352C00BC2FA2 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + E6F17DBB2D0F356700BC2FA2 /* stable.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = stable.entitlements; sourceTree = ""; }; + E6F17DBC2D0F357500BC2FA2 /* dev.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = dev.entitlements; sourceTree = ""; }; + E6F17DBD2D0F357F00BC2FA2 /* nightly.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = nightly.entitlements; sourceTree = ""; }; E81F11471FD7D807286E33D6 /* Pods-dev.debug-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.debug-dev.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.debug-dev.xcconfig"; sourceTree = ""; }; EB7783C1029CEC13F4B05D36 /* Pods-nightly.debug-nightly.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-nightly.debug-nightly.xcconfig"; path = "Target Support Files/Pods-nightly/Pods-nightly.debug-nightly.xcconfig"; sourceTree = ""; }; EBBED0A8DE0D0E230CD03613 /* Pods-dev.release-stable.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-dev.release-stable.xcconfig"; path = "Target Support Files/Pods-dev/Pods-dev.release-stable.xcconfig"; sourceTree = ""; }; @@ -155,6 +187,20 @@ F7E9EBDD27997A73A4D38EE1 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + E612EC562D0F07AD0022720C /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = E612EC382D0F07A80022720C /* HomePlayerWidgetExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + E612EC3E2D0F07A90022720C /* HomePlayerWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (E612EC562D0F07AD0022720C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = HomePlayerWidget; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -189,6 +235,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E612EC362D0F07A80022720C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E612EC3D2D0F07A90022720C /* SwiftUI.framework in Frameworks */, + E612EC3B2D0F07A90022720C /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -199,6 +254,8 @@ 4238A4985255EC9F93067739 /* Pods_dev.framework */, BAC36FC304DBD4E8A8C00694 /* Pods_nightly.framework */, B5F91A319C771EEC978B238A /* Pods_stable.framework */, + E612EC3A2D0F07A90022720C /* WidgetKit.framework */, + E612EC3C2D0F07A90022720C /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -272,8 +329,13 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( + E6F17DBD2D0F357F00BC2FA2 /* nightly.entitlements */, + E6F17DBC2D0F357500BC2FA2 /* dev.entitlements */, + E6F17DBB2D0F356700BC2FA2 /* stable.entitlements */, + E6F17DB92D0F34E500BC2FA2 /* HomePlayerWidgetExtension.entitlements */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, + E612EC3E2D0F07A90022720C /* HomePlayerWidget */, 97C146EF1CF9000F007C117D /* Products */, 67CBFE209DF24C94A9837AD5 /* Pods */, 0E0B839C4E103F896209E822 /* Frameworks */, @@ -290,6 +352,7 @@ B536BDA02B405DB1009B3CE4 /* stable.app */, B536BDBF2B405FDE009B3CE4 /* dev.app */, B536BDE42B4060B3009B3CE4 /* nightly.app */, + E612EC392D0F07A90022720C /* HomePlayerWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -297,6 +360,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + E6F17DBA2D0F352C00BC2FA2 /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -325,10 +389,13 @@ 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 6E9FEF583EA597C8B76255B2 /* [CP] Embed Pods Frameworks */, 46F6EB27C31C41D86428A28B /* [CP] Copy Pods Resources */, + E612EC492D0F07AD0022720C /* Embed Foundation Extensions */, + E63F9CBC2D10709D00CD9E72 /* ShellScript */, ); buildRules = ( ); dependencies = ( + E612EC472D0F07AD0022720C /* PBXTargetDependency */, ); name = Runner; productName = Runner; @@ -404,12 +471,35 @@ productReference = B536BDE42B4060B3009B3CE4 /* nightly.app */; productType = "com.apple.product-type.application"; }; + E612EC382D0F07A80022720C /* HomePlayerWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = E612EC572D0F07AD0022720C /* Build configuration list for PBXNativeTarget "HomePlayerWidgetExtension" */; + buildPhases = ( + E612EC352D0F07A80022720C /* Sources */, + E612EC362D0F07A80022720C /* Frameworks */, + E612EC372D0F07A80022720C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + E612EC3E2D0F07A90022720C /* HomePlayerWidget */, + ); + name = HomePlayerWidgetExtension; + packageProductDependencies = ( + ); + productName = HomePlayerWidgetExtension; + productReference = E612EC392D0F07A90022720C /* HomePlayerWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { + LastSwiftUpdateCheck = 1620; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { @@ -417,6 +507,9 @@ CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; }; + E612EC382D0F07A80022720C = { + CreatedOnToolsVersion = 16.2; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -436,6 +529,7 @@ B536BD8C2B405DB1009B3CE4 /* stable */, B536BDAB2B405FDE009B3CE4 /* dev */, B536BDCD2B4060B3009B3CE4 /* nightly */, + E612EC382D0F07A80022720C /* HomePlayerWidgetExtension */, ); }; /* End PBXProject section */ @@ -485,6 +579,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E612EC372D0F07A80022720C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -685,7 +786,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; }; A6D446F111DE4C4A202BE7F7 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; @@ -814,6 +915,23 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + E63F9CBC2D10709D00CD9E72 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a sgeneratedPath=\"$SRCROOT/Flutter/Generated.xcconfig\"\n\n# Read and trim versionNumber and buildNumber\nversionNumber=$(grep FLUTTER_BUILD_NAME \"$generatedPath\" | cut -d '=' -f2 | xargs)\nbuildNumber=$(grep FLUTTER_BUILD_NUMBER \"$generatedPath\" | cut -d '=' -f2 | xargs)\n\ninfoPlistPath=\"$SRCROOT/HomePlayerWidget/Info.plist\"\n\n# Check and add CFBundleVersion if it does not exist\n/usr/libexec/PlistBuddy -c \"Print :CFBundleVersion\" \"$infoPlistPath\" 2>/dev/null\nif [ $? != 0 ]; then\n /usr/libexec/PlistBuddy -c \"Add :CFBundleVersion string $buildNumber\" \"$infoPlistPath\"\nelse\n /usr/libexec/PlistBuddy -c \"Set :CFBundleVersion $buildNumber\" \"$infoPlistPath\"\nfi\n\n# Check and add CFBundleShortVersionString if it does not exist\n/usr/libexec/PlistBuddy -c \"Print :CFBundleShortVersionString\" \"$infoPlistPath\" 2>/dev/null\nif [ $? != 0 ]; then\n /usr/libexec/PlistBuddy -c \"Add :CFBundleShortVersionString string $versionNumber\" \"$infoPlistPath\"\nelse\n /usr/libexec/PlistBuddy -c \"Set :CFBundleShortVersionString $versionNumber\" \"$infoPlistPath\"\nfi\n\ncript file from your workspace to insert its path.\n"; + }; F0C8BA10A27CA77E18F842E7 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -875,8 +993,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E612EC352D0F07A80022720C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + E612EC472D0F07AD0022720C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E612EC382D0F07A80022720C /* HomePlayerWidgetExtension */; + targetProxy = E612EC462D0F07AD0022720C /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -953,6 +1086,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1082,6 +1216,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1105,6 +1240,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1127,6 +1263,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = stable.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1150,6 +1287,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = stable.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1172,6 +1310,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = stable.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1249,6 +1388,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1272,6 +1412,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = stable.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1347,6 +1488,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1369,6 +1511,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = stable.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1441,6 +1584,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1463,6 +1607,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = stable.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1485,6 +1630,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = dev.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1508,6 +1654,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = dev.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1531,6 +1678,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = dev.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1553,6 +1701,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = dev.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1575,6 +1724,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = dev.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1597,6 +1747,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = dev.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1674,6 +1825,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1697,6 +1849,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = stable.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1720,6 +1873,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = dev.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1795,6 +1949,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1817,6 +1972,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = stable.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1839,6 +1995,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = dev.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1911,6 +2068,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1933,6 +2091,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = stable.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1955,6 +2114,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = dev.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -1977,6 +2137,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = nightly.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2000,6 +2161,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = nightly.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2023,6 +2185,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = nightly.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2046,6 +2209,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = nightly.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2068,6 +2232,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = nightly.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2090,6 +2255,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = nightly.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2112,6 +2278,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = nightly.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2134,6 +2301,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = nightly.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2156,6 +2324,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = nightly.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2233,6 +2402,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2256,6 +2426,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = stable.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2279,6 +2450,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = dev.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2302,6 +2474,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = nightly.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2377,6 +2550,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2399,6 +2573,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = stable.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2421,6 +2596,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = dev.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2443,6 +2619,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = nightly.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2515,6 +2692,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2537,6 +2715,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = stable.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2559,6 +2738,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = dev.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2581,6 +2761,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-nightly"; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = nightly.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 88NVGSJ5N3; ENABLE_BITCODE = NO; @@ -2597,6 +2778,498 @@ }; name = "Profile-nightly"; }; + E612EC4A2D0F07AD0022720C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = HomePlayerWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + E612EC4B2D0F07AD0022720C /* Debug-nightly */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = HomePlayerWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Debug-nightly"; + }; + E612EC4C2D0F07AD0022720C /* Debug-dev */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = HomePlayerWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Debug-dev"; + }; + E612EC4D2D0F07AD0022720C /* Debug-stable */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = HomePlayerWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Debug-stable"; + }; + E612EC4E2D0F07AD0022720C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = HomePlayerWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + E612EC4F2D0F07AD0022720C /* Release-nightly */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = HomePlayerWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Release-nightly"; + }; + E612EC502D0F07AD0022720C /* Release-dev */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = HomePlayerWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Release-dev"; + }; + E612EC512D0F07AD0022720C /* Release-stable */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = HomePlayerWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Release-stable"; + }; + E612EC522D0F07AD0022720C /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = HomePlayerWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Profile; + }; + E612EC532D0F07AD0022720C /* Profile-nightly */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = HomePlayerWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Profile-nightly"; + }; + E612EC542D0F07AD0022720C /* Profile-dev */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = HomePlayerWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Profile-dev"; + }; + E612EC552D0F07AD0022720C /* Profile-stable */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = HomePlayerWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = HomePlayerWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = HomePlayerWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube.HomePlayerWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Profile-stable"; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -2695,6 +3368,25 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + E612EC572D0F07AD0022720C /* Build configuration list for PBXNativeTarget "HomePlayerWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E612EC4A2D0F07AD0022720C /* Debug */, + E612EC4B2D0F07AD0022720C /* Debug-nightly */, + E612EC4C2D0F07AD0022720C /* Debug-dev */, + E612EC4D2D0F07AD0022720C /* Debug-stable */, + E612EC4E2D0F07AD0022720C /* Release */, + E612EC4F2D0F07AD0022720C /* Release-nightly */, + E612EC502D0F07AD0022720C /* Release-dev */, + E612EC512D0F07AD0022720C /* Release-stable */, + E612EC522D0F07AD0022720C /* Profile */, + E612EC532D0F07AD0022720C /* Profile-nightly */, + E612EC542D0F07AD0022720C /* Profile-dev */, + E612EC552D0F07AD0022720C /* Profile-stable */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 5e31d3d3..c53e2b31 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -48,6 +48,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index b6363034..f512ac86 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,12 +1,17 @@ import UIKit import Flutter -@main +@UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + // Add this to get Documents directory path + if let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.path { + UserDefaults.standard.set(documentsPath, forKey: "download_path") + } + GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } diff --git a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/Contents.json index e8947587..1ce0f517 100644 --- a/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/Contents.json +++ b/ios/Runner/Assets.xcassets/AppIcon-nightly.appiconset/Contents.json @@ -1 +1 @@ -{"images":[{"size":"20x20","idiom":"iphone","filename":"AppIcon-nightly-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"AppIcon-nightly-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-nightly-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-nightly-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-nightly-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"AppIcon-nightly-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"AppIcon-nightly-40x40@3x.png","scale":"3x"},{"size":"50x50","idiom":"ipad","filename":"AppIcon-nightly-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"AppIcon-nightly-50x50@2x.png","scale":"2x"},{"size":"57x57","idiom":"iphone","filename":"AppIcon-nightly-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"AppIcon-nightly-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"AppIcon-nightly-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"AppIcon-nightly-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"AppIcon-nightly-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"AppIcon-nightly-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"AppIcon-nightly-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"AppIcon-nightly-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"AppIcon-nightly-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"AppIcon-nightly-40x40@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"AppIcon-nightly-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"AppIcon-nightly-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"AppIcon-nightly-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"AppIcon-nightly-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"AppIcon-nightly-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"AppIcon-nightly-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file +{"images":[{"size":"20x20","idiom":"iphone","filename":"AppIcon-nightly-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"AppIcon-nightly-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-nightly-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-nightly-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-nightly-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"AppIcon-nightly-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"AppIcon-nightly-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"AppIcon-nightly-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"AppIcon-nightly-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"AppIcon-nightly-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"AppIcon-nightly-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"AppIcon-nightly-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"AppIcon-nightly-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"AppIcon-nightly-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"AppIcon-nightly-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"AppIcon-nightly-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"AppIcon-nightly-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"AppIcon-nightly-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"AppIcon-nightly-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"AppIcon-nightly-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"AppIcon-nightly-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"AppIcon-nightly-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"AppIcon-nightly-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"AppIcon-nightly-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"AppIcon-nightly-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index ffd511a4..91b7ad94 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -72,5 +72,11 @@ _spotube._tcp + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + + UISupportsDocumentBrowser + - \ No newline at end of file + diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 00000000..58165678 --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.spotube_home_player_widget + + + diff --git a/ios/dev.entitlements b/ios/dev.entitlements new file mode 100644 index 00000000..58165678 --- /dev/null +++ b/ios/dev.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.spotube_home_player_widget + + + diff --git a/ios/nightly.entitlements b/ios/nightly.entitlements new file mode 100644 index 00000000..58165678 --- /dev/null +++ b/ios/nightly.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.spotube_home_player_widget + + + diff --git a/ios/stable.entitlements b/ios/stable.entitlements new file mode 100644 index 00000000..58165678 --- /dev/null +++ b/ios/stable.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.spotube_home_player_widget + + + diff --git a/lib/collections/assets.gen.dart b/lib/collections/assets.gen.dart index 6825fbd5..004001f2 100644 --- a/lib/collections/assets.gen.dart +++ b/lib/collections/assets.gen.dart @@ -9,6 +9,17 @@ import 'package:flutter/widgets.dart'; +class $AssetsBackgroundsGen { + const $AssetsBackgroundsGen(); + + /// File path: assets/backgrounds/xmas-effect.png + AssetGenImage get xmasEffect => + const AssetGenImage('assets/backgrounds/xmas-effect.png'); + + /// List of all assets + List get values => [xmasEffect]; +} + class $AssetsLogosGen { const $AssetsLogosGen(); @@ -24,6 +35,84 @@ class $AssetsLogosGen { List get values => [songlinkTransparent, songlink]; } +class $AssetsPatternsGen { + const $AssetsPatternsGen(); + + /// File path: assets/patterns/black_white_visualized.jpg + AssetGenImage get blackWhiteVisualized => + const AssetGenImage('assets/patterns/black_white_visualized.jpg'); + + /// File path: assets/patterns/brazil_carnival.jpg + AssetGenImage get brazilCarnival => + const AssetGenImage('assets/patterns/brazil_carnival.jpg'); + + /// File path: assets/patterns/cotton_balls.jpg + AssetGenImage get cottonBalls => + const AssetGenImage('assets/patterns/cotton_balls.jpg'); + + /// File path: assets/patterns/cute_worms.jpg + AssetGenImage get cuteWorms => + const AssetGenImage('assets/patterns/cute_worms.jpg'); + + /// File path: assets/patterns/flash_cross_axis.jpg + AssetGenImage get flashCrossAxis => + const AssetGenImage('assets/patterns/flash_cross_axis.jpg'); + + /// File path: assets/patterns/memphis_shapes.jpg + AssetGenImage get memphisShapes => + const AssetGenImage('assets/patterns/memphis_shapes.jpg'); + + /// File path: assets/patterns/oval_gloomy.jpg + AssetGenImage get ovalGloomy => + const AssetGenImage('assets/patterns/oval_gloomy.jpg'); + + /// File path: assets/patterns/oval_sunny.jpg + AssetGenImage get ovalSunny => + const AssetGenImage('assets/patterns/oval_sunny.jpg'); + + /// File path: assets/patterns/red_nimbuses.jpg + AssetGenImage get redNimbuses => + const AssetGenImage('assets/patterns/red_nimbuses.jpg'); + + /// File path: assets/patterns/tree_bark.jpg + AssetGenImage get treeBark => + const AssetGenImage('assets/patterns/tree_bark.jpg'); + + /// File path: assets/patterns/vibrant_pentagons.jpg + AssetGenImage get vibrantPentagons => + const AssetGenImage('assets/patterns/vibrant_pentagons.jpg'); + + /// File path: assets/patterns/wiring_pattern.jpg + AssetGenImage get wiringPattern => + const AssetGenImage('assets/patterns/wiring_pattern.jpg'); + + /// File path: assets/patterns/zigzags_gloomy.jpg + AssetGenImage get zigzagsGloomy => + const AssetGenImage('assets/patterns/zigzags_gloomy.jpg'); + + /// File path: assets/patterns/zigzags_sunny.jpg + AssetGenImage get zigzagsSunny => + const AssetGenImage('assets/patterns/zigzags_sunny.jpg'); + + /// List of all assets + List get values => [ + blackWhiteVisualized, + brazilCarnival, + cottonBalls, + cuteWorms, + flashCrossAxis, + memphisShapes, + ovalGloomy, + ovalSunny, + redNimbuses, + treeBark, + vibrantPentagons, + wiringPattern, + zigzagsGloomy, + zigzagsSunny + ]; +} + class $AssetsTutorialGen { const $AssetsTutorialGen(); @@ -46,6 +135,7 @@ class Assets { static const String license = 'LICENSE'; static const AssetGenImage albumPlaceholder = AssetGenImage('assets/album-placeholder.png'); + static const $AssetsBackgroundsGen backgrounds = $AssetsBackgroundsGen(); static const AssetGenImage bengaliPatternsBg = AssetGenImage('assets/bengali-patterns-bg.jpg'); static const AssetGenImage branding = AssetGenImage('assets/branding.png'); @@ -55,12 +145,15 @@ class Assets { static const AssetGenImage likedTracks = AssetGenImage('assets/liked-tracks.jpg'); static const $AssetsLogosGen logos = $AssetsLogosGen(); + static const $AssetsPatternsGen patterns = $AssetsPatternsGen(); static const AssetGenImage placeholder = AssetGenImage('assets/placeholder.png'); static const AssetGenImage spotubeHeroBanner = AssetGenImage('assets/spotube-hero-banner.png'); static const AssetGenImage spotubeLogoForeground = AssetGenImage('assets/spotube-logo-foreground.jpg'); + static const AssetGenImage spotubeLogoMacos = + AssetGenImage('assets/spotube-logo-macos.png'); static const AssetGenImage spotubeLogoBmp = AssetGenImage('assets/spotube-logo.bmp'); static const String spotubeLogoIco = 'assets/spotube-logo.ico'; @@ -104,6 +197,7 @@ class Assets { placeholder, spotubeHeroBanner, spotubeLogoForeground, + spotubeLogoMacos, spotubeLogoBmp, spotubeLogoIco, spotubeLogoPng, diff --git a/lib/collections/env.dart b/lib/collections/env.dart index eb60851f..feb2a2db 100644 --- a/lib/collections/env.dart +++ b/lib/collections/env.dart @@ -38,6 +38,11 @@ abstract class Env { @EnviedField(varName: "RELEASE_CHANNEL", defaultValue: "nightly") static final String _releaseChannel = _Env._releaseChannel; + @EnviedField(varName: "DISABLE_SPOTIFY_IMAGES", defaultValue: "0") + static final String _disableSpotifyImages = _Env._disableSpotifyImages; + + static bool get disableSpotifyImages => _disableSpotifyImages == "1"; + static ReleaseChannel get releaseChannel => _releaseChannel == "stable" ? ReleaseChannel.stable : ReleaseChannel.nightly; diff --git a/lib/collections/fonts.gen.dart b/lib/collections/fonts.gen.dart new file mode 100644 index 00000000..811e1d36 --- /dev/null +++ b/lib/collections/fonts.gen.dart @@ -0,0 +1,24 @@ +/// GENERATED CODE - DO NOT MODIFY BY HAND +/// ***************************************************** +/// FlutterGen +/// ***************************************************** + +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: directives_ordering,unnecessary_import,implicit_dynamic_list_literal,deprecated_member_use + +class FontFamily { + FontFamily._(); + + /// Font family: BootstrapIcons + static const String bootstrapIcons = 'BootstrapIcons'; + + /// Font family: GeistMono + static const String geistMono = 'GeistMono'; + + /// Font family: GeistSans + static const String geistSans = 'GeistSans'; + + /// Font family: RadixIcons + static const String radixIcons = 'RadixIcons'; +} diff --git a/lib/collections/gradients.dart b/lib/collections/gradients.dart index e861dde7..a7936ee2 100644 --- a/lib/collections/gradients.dart +++ b/lib/collections/gradients.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; const gradients = [ LinearGradient(colors: [ diff --git a/lib/collections/intents.dart b/lib/collections/intents.dart index 4f446831..e4e3fa07 100644 --- a/lib/collections/intents.dart +++ b/lib/collections/intents.dart @@ -3,13 +3,9 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; import 'package:spotube/collections/routes.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/modules/player/player_controls.dart'; -import 'package:spotube/pages/home/home.dart'; -import 'package:spotube/pages/library/library.dart'; -import 'package:spotube/pages/lyrics/lyrics.dart'; -import 'package:spotube/pages/search/search.dart'; import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/utils/platform.dart'; @@ -36,7 +32,7 @@ class PlayPauseAction extends Action { } class NavigationIntent extends Intent { - final GoRouter router; + final AppRouter router; final String path; const NavigationIntent(this.router, this.path); } @@ -44,7 +40,7 @@ class NavigationIntent extends Intent { class NavigationAction extends Action { @override invoke(intent) { - intent.router.go(intent.path); + intent.router.navigateNamed(intent.path); return null; } } @@ -52,32 +48,49 @@ class NavigationAction extends Action { enum HomeTabs { browse, search, - library, + lyrics, + userPlaylists, + userArtists, + userAlbums, + userLocalLibrary, + userDownloads, } class HomeTabIntent extends Intent { - final WidgetRef ref; + final AppRouter router; final HomeTabs tab; - const HomeTabIntent(this.ref, {required this.tab}); + const HomeTabIntent(this.router, {required this.tab}); } class HomeTabAction extends Action { @override invoke(intent) { - final router = intent.ref.read(routerProvider); + final router = intent.router; switch (intent.tab) { case HomeTabs.browse: - router.goNamed(HomePage.name); + router.navigate(const HomeRoute()); break; case HomeTabs.search: - router.goNamed(SearchPage.name); - break; - case HomeTabs.library: - router.goNamed(LibraryPage.name); + router.navigate(const SearchRoute()); break; case HomeTabs.lyrics: - router.goNamed(LyricsPage.name); + router.navigate(LyricsRoute()); + break; + case HomeTabs.userPlaylists: + router.navigate(const UserPlaylistsRoute()); + break; + case HomeTabs.userArtists: + router.navigate(const UserArtistsRoute()); + break; + case HomeTabs.userAlbums: + router.navigate(const UserAlbumsRoute()); + break; + case HomeTabs.userLocalLibrary: + router.navigate(const UserLocalLibraryRoute()); + break; + case HomeTabs.userDownloads: + router.navigate(const UserDownloadsRoute()); break; } return null; diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index a0380e29..87f2720e 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -1,329 +1,235 @@ -import 'package:flutter/foundation.dart' hide Category; -import 'package:flutter/widgets.dart'; -import 'package:go_router/go_router.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart' hide Search; -import 'package:spotube/models/spotify/recommendation_seeds.dart'; -import 'package:spotube/pages/album/album.dart'; -import 'package:spotube/pages/connect/connect.dart'; -import 'package:spotube/pages/connect/control/control.dart'; -import 'package:spotube/pages/getting_started/getting_started.dart'; -import 'package:spotube/pages/home/feed/feed_section.dart'; -import 'package:spotube/pages/home/genres/genre_playlists.dart'; -import 'package:spotube/pages/home/genres/genres.dart'; -import 'package:spotube/pages/home/home.dart'; -import 'package:spotube/pages/lastfm_login/lastfm_login.dart'; -import 'package:spotube/pages/library/local_folder.dart'; -import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; -import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart'; -import 'package:spotube/pages/lyrics/mini_lyrics.dart'; -import 'package:spotube/pages/playlist/liked_playlist.dart'; -import 'package:spotube/pages/playlist/playlist.dart'; -import 'package:spotube/pages/profile/profile.dart'; -import 'package:spotube/pages/search/search.dart'; -import 'package:spotube/pages/settings/blacklist.dart'; -import 'package:spotube/pages/settings/about.dart'; -import 'package:spotube/pages/settings/logs.dart'; -import 'package:spotube/pages/stats/albums/albums.dart'; -import 'package:spotube/pages/stats/artists/artists.dart'; -import 'package:spotube/pages/stats/fees/fees.dart'; -import 'package:spotube/pages/stats/minutes/minutes.dart'; -import 'package:spotube/pages/stats/playlists/playlists.dart'; -import 'package:spotube/pages/stats/stats.dart'; -import 'package:spotube/pages/stats/streams/streams.dart'; -import 'package:spotube/pages/track/track.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; -import 'package:spotube/components/spotube_page_route.dart'; -import 'package:spotube/pages/artist/artist.dart'; -import 'package:spotube/pages/library/library.dart'; -import 'package:spotube/pages/lyrics/lyrics.dart'; -import 'package:spotube/pages/root/root_app.dart'; -import 'package:spotube/pages/settings/settings.dart'; -import 'package:spotube/pages/mobile_login/mobile_login.dart'; final rootNavigatorKey = GlobalKey(); -final shellRouteNavigatorKey = GlobalKey(); -final routerProvider = Provider((ref) { - return GoRouter( - navigatorKey: rootNavigatorKey, - routes: [ - ShellRoute( - navigatorKey: shellRouteNavigatorKey, - builder: (context, state, child) => RootApp(child: child), - routes: [ - GoRoute( - path: "/", - name: HomePage.name, - redirect: (context, state) async { - final auth = await ref.read(authenticationProvider.future); - if (auth == null && !KVStoreService.doneGettingStarted) { - return "/getting-started"; - } +@AutoRouterConfig(replaceInRouteName: 'Screen|Page,Route') +class AppRouter extends RootStackRouter { + final WidgetRef ref; - return null; - }, - pageBuilder: (context, state) => - const SpotubePage(child: HomePage()), - routes: [ - GoRoute( - path: "genres", - name: GenrePage.name, - pageBuilder: (context, state) => - const SpotubePage(child: GenrePage()), - ), - GoRoute( - path: "genre/:categoryId", - name: GenrePlaylistsPage.name, - pageBuilder: (context, state) => SpotubePage( - child: GenrePlaylistsPage( - category: state.extra as Category, - ), - ), - ), - GoRoute( - path: "feeds/:feedId", - name: HomeFeedSectionPage.name, - pageBuilder: (context, state) => SpotubePage( - child: HomeFeedSectionPage( - sectionUri: state.pathParameters["feedId"] as String, - ), - ), - ) - ], - ), - GoRoute( - path: "/search", - name: SearchPage.name, - pageBuilder: (context, state) => - const SpotubePage(child: SearchPage()), - ), - GoRoute( - path: "/library", - name: LibraryPage.name, - pageBuilder: (context, state) => - const SpotubePage(child: LibraryPage()), - routes: [ - GoRoute( - path: "generate", - name: PlaylistGeneratorPage.name, - pageBuilder: (context, state) => - const SpotubePage(child: PlaylistGeneratorPage()), - routes: [ - GoRoute( - path: "result", - name: PlaylistGenerateResultPage.name, - pageBuilder: (context, state) => SpotubePage( - child: PlaylistGenerateResultPage( - state: state.extra as GeneratePlaylistProviderInput, - ), - ), - ) - ], - ), - GoRoute( - path: "local", - name: LocalLibraryPage.name, - pageBuilder: (context, state) { - assert(state.extra is String); - return SpotubePage( - child: LocalLibraryPage( - state.extra as String, - isDownloads: - state.uri.queryParameters["downloads"] != null, - isCache: state.uri.queryParameters["cache"] != null, - ), - ); + AppRouter(this.ref) : super(navigatorKey: rootNavigatorKey); + + @override + List get routes => [ + AutoRoute( + page: RootAppRoute.page, + path: "/", + initial: true, + children: [ + AutoRoute( + path: "home", + page: HomeRoute.page, + initial: true, + guards: [ + AutoRouteGuardCallback( + (resolver, router) async { + final auth = await ref.read(authenticationProvider.future); + + if (auth == null && !KVStoreService.doneGettingStarted) { + resolver.redirect(const GettingStartedRoute()); + } else { + resolver.next(true); + } }, ), - ]), - GoRoute( - path: "/lyrics", - name: LyricsPage.name, - pageBuilder: (context, state) => - const SpotubePage(child: LyricsPage()), - ), - GoRoute( - path: "/settings", - name: SettingsPage.name, - pageBuilder: (context, state) => const SpotubePage( - child: SettingsPage(), + ], ), - routes: [ - GoRoute( - path: "blacklist", - name: BlackListPage.name, - pageBuilder: (context, state) => SpotubeSlidePage( - child: const BlackListPage(), - ), - ), - if (!kIsWeb) - GoRoute( - path: "logs", - name: LogsPage.name, - pageBuilder: (context, state) => SpotubeSlidePage( - child: const LogsPage(), - ), - ), - GoRoute( - path: "about", - name: AboutSpotube.name, - pageBuilder: (context, state) => SpotubeSlidePage( - child: const AboutSpotube(), - ), - ), - ], - ), - GoRoute( - path: "/album/:id", - name: AlbumPage.name, - pageBuilder: (context, state) { - assert(state.extra is AlbumSimple); - return SpotubePage( - child: AlbumPage(album: state.extra as AlbumSimple), - ); - }, - ), - GoRoute( - path: "/artist/:id", - name: ArtistPage.name, - pageBuilder: (context, state) { - assert(state.pathParameters["id"] != null); - return SpotubePage( - child: ArtistPage(state.pathParameters["id"]!)); - }, - ), - GoRoute( - path: "/playlist/:id", - name: PlaylistPage.name, - pageBuilder: (context, state) { - assert(state.extra is PlaylistSimple); - return SpotubePage( - child: state.pathParameters["id"] == "user-liked-tracks" - ? LikedPlaylistPage(playlist: state.extra as PlaylistSimple) - : PlaylistPage(playlist: state.extra as PlaylistSimple), - ); - }, - ), - GoRoute( - path: "/track/:id", - name: TrackPage.name, - pageBuilder: (context, state) { - final id = state.pathParameters["id"]!; - return SpotubePage( - child: TrackPage(trackId: id), - ); - }, - ), - GoRoute( - path: "/connect", - name: ConnectPage.name, - pageBuilder: (context, state) => const SpotubePage( - child: ConnectPage(), + AutoRoute( + path: "home/genres", + page: GenreRoute.page, ), - routes: [ - GoRoute( - path: "control", - name: ConnectControlPage.name, - pageBuilder: (context, state) { - return const SpotubePage( - child: ConnectControlPage(), - ); - }, - ) - ], - ), - GoRoute( - path: "/profile", - name: ProfilePage.name, - pageBuilder: (context, state) => - const SpotubePage(child: ProfilePage()), - ), - GoRoute( - path: "/stats", - name: StatsPage.name, - pageBuilder: (context, state) => const SpotubePage( - child: StatsPage(), + AutoRoute( + path: "home/genre/:categoryId", + page: GenrePlaylistsRoute.page, ), - routes: [ - GoRoute( - path: "minutes", - name: StatsMinutesPage.name, - pageBuilder: (context, state) => const SpotubePage( - child: StatsMinutesPage(), + AutoRoute( + path: "home/feeds/:feedId", + page: HomeFeedSectionRoute.page, + ), + AutoRoute( + path: "search", + page: SearchRoute.page, + ), + AutoRoute( + path: "library", + page: LibraryRoute.page, + children: [ + AutoRoute( + path: "playlists", + page: UserPlaylistsRoute.page, ), - ), - GoRoute( - path: "streams", - name: StatsStreamsPage.name, - pageBuilder: (context, state) => const SpotubePage( - child: StatsStreamsPage(), + AutoRoute( + path: "artists", + page: UserArtistsRoute.page, ), - ), - GoRoute( - path: "fees", - name: StatsStreamFeesPage.name, - pageBuilder: (context, state) => const SpotubePage( - child: StatsStreamFeesPage(), + AutoRoute( + path: "albums", + page: UserAlbumsRoute.page, ), - ), - GoRoute( - path: "artists", - name: StatsArtistsPage.name, - pageBuilder: (context, state) => const SpotubePage( - child: StatsArtistsPage(), + AutoRoute( + path: "local", + page: UserLocalLibraryRoute.page, ), - ), - GoRoute( - path: "albums", - name: StatsAlbumsPage.name, - pageBuilder: (context, state) => const SpotubePage( - child: StatsAlbumsPage(), + AutoRoute( + path: "local/folder", + page: LocalLibraryRoute.page, + // parentNavigatorKey: shellRouteNavigatorKey, ), - ), - GoRoute( - path: "playlists", - name: StatsPlaylistsPage.name, - pageBuilder: (context, state) => const SpotubePage( - child: StatsPlaylistsPage(), + AutoRoute( + path: "downloads", + page: UserDownloadsRoute.page, ), + ], + ), + AutoRoute( + path: "library/generate", + page: PlaylistGeneratorRoute.page, + ), + AutoRoute( + path: "library/generate/result", + page: PlaylistGenerateResultRoute.page, + ), + AutoRoute( + path: "lyrics", + page: LyricsRoute.page, + ), + AutoRoute( + path: "settings", + page: SettingsRoute.page, + ), + AutoRoute( + path: "settings/blacklist", + page: BlackListRoute.page, + ), + if (!kIsWeb) + AutoRoute( + path: "settings/logs", + page: LogsRoute.page, ), - ], - ) - ], - ), - GoRoute( - path: "/mini-player", - name: MiniLyricsPage.name, - parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) => SpotubePage( - child: MiniLyricsPage(prevSize: state.extra as Size), + AutoRoute( + path: "settings/about", + page: AboutSpotubeRoute.page, + ), + AutoRoute( + path: "album/:id", + page: AlbumRoute.page, + ), + AutoRoute( + path: "artist/:id", + page: ArtistRoute.page, + ), + AutoRoute( + path: "liked-tracks", + page: LikedPlaylistRoute.page, + ), + AutoRoute( + path: "playlist/:id", + page: PlaylistRoute.page, + guards: [ + AutoRouteGuard.redirect( + (resolver) { + final PlaylistRouteArgs(:id, :playlist) = + resolver.route.args as PlaylistRouteArgs; + if (id == "user-liked-tracks") { + return LikedPlaylistRoute(playlist: playlist); + } + + return null; + }, + ), + ], + ), + AutoRoute( + path: "track/:id", + page: TrackRoute.page, + ), + AutoRoute( + path: "connect", + page: ConnectRoute.page, + ), + AutoRoute( + path: "connect/control", + page: ConnectControlRoute.page, + ), + AutoRoute( + path: "profile", + page: ProfileRoute.page, + ), + AutoRoute( + path: "stats", + page: StatsRoute.page, + ), + AutoRoute( + path: "stats/minutes", + page: StatsMinutesRoute.page, + ), + AutoRoute( + path: "stats/streams", + page: StatsStreamsRoute.page, + ), + AutoRoute( + path: "stats/fees", + page: StatsStreamFeesRoute.page, + ), + AutoRoute( + path: "stats/artists", + page: StatsArtistsRoute.page, + ), + AutoRoute( + path: "stats/albums", + page: StatsAlbumsRoute.page, + ), + AutoRoute( + path: "stats/playlists", + page: StatsPlaylistsRoute.page, + ), + ], ), - ), - GoRoute( - path: "/getting-started", - name: GettingStarting.name, - parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) => const SpotubePage( - child: GettingStarting(), + CustomRoute( + transitionsBuilder: TransitionsBuilders.slideBottom, + durationInMilliseconds: 200, + reverseDurationInMilliseconds: 200, + path: "/player/queue", + page: PlayerQueueRoute.page, ), - ), - GoRoute( - path: "/login", - name: WebViewLogin.name, - parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) => const SpotubePage( - child: WebViewLogin(), + CustomRoute( + transitionsBuilder: TransitionsBuilders.slideBottom, + durationInMilliseconds: 200, + reverseDurationInMilliseconds: 200, + path: "/player/sources", + page: PlayerTrackSourcesRoute.page, ), - ), - GoRoute( - path: "/lastfm-login", - name: LastFMLoginPage.name, - parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) => - const SpotubePage(child: LastFMLoginPage()), - ), - ], - ); -}); + CustomRoute( + transitionsBuilder: TransitionsBuilders.slideBottom, + durationInMilliseconds: 200, + reverseDurationInMilliseconds: 200, + path: "/player/lyrics", + page: PlayerLyricsRoute.page, + ), + AutoRoute( + path: "/mini-player", + page: MiniLyricsRoute.page, + // parentNavigatorKey: rootNavigatorKey, + ), + AutoRoute( + path: "/getting-started", + page: GettingStartedRoute.page, + // parentNavigatorKey: rootNavigatorKey, + ), + AutoRoute( + path: "/login", + page: WebViewLoginRoute.page, + // parentNavigatorKey: rootNavigatorKey, + ), + AutoRoute( + path: "/lastfm-login", + page: LastFMLoginRoute.page, + // parentNavigatorKey: rootNavigatorKey, + ), + ]; +} diff --git a/lib/collections/routes.gr.dart b/lib/collections/routes.gr.dart new file mode 100644 index 00000000..1d608896 --- /dev/null +++ b/lib/collections/routes.gr.dart @@ -0,0 +1,1174 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// AutoRouterGenerator +// ************************************************************************** + +// ignore_for_file: type=lint +// coverage:ignore-file + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:auto_route/auto_route.dart' as _i43; +import 'package:flutter/material.dart' as _i44; +import 'package:shadcn_flutter/shadcn_flutter.dart' as _i46; +import 'package:spotify/spotify.dart' as _i45; +import 'package:spotube/models/spotify/recommendation_seeds.dart' as _i47; +import 'package:spotube/pages/album/album.dart' as _i2; +import 'package:spotube/pages/artist/artist.dart' as _i3; +import 'package:spotube/pages/connect/connect.dart' as _i6; +import 'package:spotube/pages/connect/control/control.dart' as _i5; +import 'package:spotube/pages/getting_started/getting_started.dart' as _i9; +import 'package:spotube/pages/home/feed/feed_section.dart' as _i10; +import 'package:spotube/pages/home/genres/genre_playlists.dart' as _i8; +import 'package:spotube/pages/home/genres/genres.dart' as _i7; +import 'package:spotube/pages/home/home.dart' as _i11; +import 'package:spotube/pages/lastfm_login/lastfm_login.dart' as _i12; +import 'package:spotube/pages/library/library.dart' as _i13; +import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart' + as _i23; +import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart' + as _i22; +import 'package:spotube/pages/library/user_albums.dart' as _i37; +import 'package:spotube/pages/library/user_artists.dart' as _i38; +import 'package:spotube/pages/library/user_downloads.dart' as _i39; +import 'package:spotube/pages/library/user_local_tracks/local_folder.dart' + as _i15; +import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart' + as _i40; +import 'package:spotube/pages/library/user_playlists.dart' as _i41; +import 'package:spotube/pages/lyrics/lyrics.dart' as _i17; +import 'package:spotube/pages/lyrics/mini_lyrics.dart' as _i18; +import 'package:spotube/pages/mobile_login/mobile_login.dart' as _i42; +import 'package:spotube/pages/player/lyrics.dart' as _i19; +import 'package:spotube/pages/player/queue.dart' as _i20; +import 'package:spotube/pages/player/sources.dart' as _i21; +import 'package:spotube/pages/playlist/liked_playlist.dart' as _i14; +import 'package:spotube/pages/playlist/playlist.dart' as _i24; +import 'package:spotube/pages/profile/profile.dart' as _i25; +import 'package:spotube/pages/root/root_app.dart' as _i26; +import 'package:spotube/pages/search/search.dart' as _i27; +import 'package:spotube/pages/settings/about.dart' as _i1; +import 'package:spotube/pages/settings/blacklist.dart' as _i4; +import 'package:spotube/pages/settings/logs.dart' as _i16; +import 'package:spotube/pages/settings/settings.dart' as _i28; +import 'package:spotube/pages/stats/albums/albums.dart' as _i29; +import 'package:spotube/pages/stats/artists/artists.dart' as _i30; +import 'package:spotube/pages/stats/fees/fees.dart' as _i34; +import 'package:spotube/pages/stats/minutes/minutes.dart' as _i31; +import 'package:spotube/pages/stats/playlists/playlists.dart' as _i33; +import 'package:spotube/pages/stats/stats.dart' as _i32; +import 'package:spotube/pages/stats/streams/streams.dart' as _i35; +import 'package:spotube/pages/track/track.dart' as _i36; + +/// generated route for +/// [_i1.AboutSpotubePage] +class AboutSpotubeRoute extends _i43.PageRouteInfo { + const AboutSpotubeRoute({List<_i43.PageRouteInfo>? children}) + : super( + AboutSpotubeRoute.name, + initialChildren: children, + ); + + static const String name = 'AboutSpotubeRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i1.AboutSpotubePage(); + }, + ); +} + +/// generated route for +/// [_i2.AlbumPage] +class AlbumRoute extends _i43.PageRouteInfo { + AlbumRoute({ + _i44.Key? key, + required String id, + required _i45.AlbumSimple album, + List<_i43.PageRouteInfo>? children, + }) : super( + AlbumRoute.name, + args: AlbumRouteArgs( + key: key, + id: id, + album: album, + ), + rawPathParams: {'id': id}, + initialChildren: children, + ); + + static const String name = 'AlbumRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return _i2.AlbumPage( + key: args.key, + id: args.id, + album: args.album, + ); + }, + ); +} + +class AlbumRouteArgs { + const AlbumRouteArgs({ + this.key, + required this.id, + required this.album, + }); + + final _i44.Key? key; + + final String id; + + final _i45.AlbumSimple album; + + @override + String toString() { + return 'AlbumRouteArgs{key: $key, id: $id, album: $album}'; + } +} + +/// generated route for +/// [_i3.ArtistPage] +class ArtistRoute extends _i43.PageRouteInfo { + ArtistRoute({ + required String artistId, + _i44.Key? key, + List<_i43.PageRouteInfo>? children, + }) : super( + ArtistRoute.name, + args: ArtistRouteArgs( + artistId: artistId, + key: key, + ), + rawPathParams: {'id': artistId}, + initialChildren: children, + ); + + static const String name = 'ArtistRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + final pathParams = data.inheritedPathParams; + final args = data.argsAs( + orElse: () => ArtistRouteArgs(artistId: pathParams.getString('id'))); + return _i3.ArtistPage( + args.artistId, + key: args.key, + ); + }, + ); +} + +class ArtistRouteArgs { + const ArtistRouteArgs({ + required this.artistId, + this.key, + }); + + final String artistId; + + final _i44.Key? key; + + @override + String toString() { + return 'ArtistRouteArgs{artistId: $artistId, key: $key}'; + } +} + +/// generated route for +/// [_i4.BlackListPage] +class BlackListRoute extends _i43.PageRouteInfo { + const BlackListRoute({List<_i43.PageRouteInfo>? children}) + : super( + BlackListRoute.name, + initialChildren: children, + ); + + static const String name = 'BlackListRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i4.BlackListPage(); + }, + ); +} + +/// generated route for +/// [_i5.ConnectControlPage] +class ConnectControlRoute extends _i43.PageRouteInfo { + const ConnectControlRoute({List<_i43.PageRouteInfo>? children}) + : super( + ConnectControlRoute.name, + initialChildren: children, + ); + + static const String name = 'ConnectControlRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i5.ConnectControlPage(); + }, + ); +} + +/// generated route for +/// [_i6.ConnectPage] +class ConnectRoute extends _i43.PageRouteInfo { + const ConnectRoute({List<_i43.PageRouteInfo>? children}) + : super( + ConnectRoute.name, + initialChildren: children, + ); + + static const String name = 'ConnectRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i6.ConnectPage(); + }, + ); +} + +/// generated route for +/// [_i7.GenrePage] +class GenreRoute extends _i43.PageRouteInfo { + const GenreRoute({List<_i43.PageRouteInfo>? children}) + : super( + GenreRoute.name, + initialChildren: children, + ); + + static const String name = 'GenreRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i7.GenrePage(); + }, + ); +} + +/// generated route for +/// [_i8.GenrePlaylistsPage] +class GenrePlaylistsRoute extends _i43.PageRouteInfo { + GenrePlaylistsRoute({ + _i44.Key? key, + required String id, + required _i45.Category category, + List<_i43.PageRouteInfo>? children, + }) : super( + GenrePlaylistsRoute.name, + args: GenrePlaylistsRouteArgs( + key: key, + id: id, + category: category, + ), + rawPathParams: {'categoryId': id}, + initialChildren: children, + ); + + static const String name = 'GenrePlaylistsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return _i8.GenrePlaylistsPage( + key: args.key, + id: args.id, + category: args.category, + ); + }, + ); +} + +class GenrePlaylistsRouteArgs { + const GenrePlaylistsRouteArgs({ + this.key, + required this.id, + required this.category, + }); + + final _i44.Key? key; + + final String id; + + final _i45.Category category; + + @override + String toString() { + return 'GenrePlaylistsRouteArgs{key: $key, id: $id, category: $category}'; + } +} + +/// generated route for +/// [_i9.GettingStartedPage] +class GettingStartedRoute extends _i43.PageRouteInfo { + const GettingStartedRoute({List<_i43.PageRouteInfo>? children}) + : super( + GettingStartedRoute.name, + initialChildren: children, + ); + + static const String name = 'GettingStartedRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i9.GettingStartedPage(); + }, + ); +} + +/// generated route for +/// [_i10.HomeFeedSectionPage] +class HomeFeedSectionRoute + extends _i43.PageRouteInfo { + HomeFeedSectionRoute({ + _i46.Key? key, + required String sectionUri, + List<_i43.PageRouteInfo>? children, + }) : super( + HomeFeedSectionRoute.name, + args: HomeFeedSectionRouteArgs( + key: key, + sectionUri: sectionUri, + ), + rawPathParams: {'feedId': sectionUri}, + initialChildren: children, + ); + + static const String name = 'HomeFeedSectionRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + final pathParams = data.inheritedPathParams; + final args = data.argsAs( + orElse: () => HomeFeedSectionRouteArgs( + sectionUri: pathParams.getString('feedId'))); + return _i10.HomeFeedSectionPage( + key: args.key, + sectionUri: args.sectionUri, + ); + }, + ); +} + +class HomeFeedSectionRouteArgs { + const HomeFeedSectionRouteArgs({ + this.key, + required this.sectionUri, + }); + + final _i46.Key? key; + + final String sectionUri; + + @override + String toString() { + return 'HomeFeedSectionRouteArgs{key: $key, sectionUri: $sectionUri}'; + } +} + +/// generated route for +/// [_i11.HomePage] +class HomeRoute extends _i43.PageRouteInfo { + const HomeRoute({List<_i43.PageRouteInfo>? children}) + : super( + HomeRoute.name, + initialChildren: children, + ); + + static const String name = 'HomeRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i11.HomePage(); + }, + ); +} + +/// generated route for +/// [_i12.LastFMLoginPage] +class LastFMLoginRoute extends _i43.PageRouteInfo { + const LastFMLoginRoute({List<_i43.PageRouteInfo>? children}) + : super( + LastFMLoginRoute.name, + initialChildren: children, + ); + + static const String name = 'LastFMLoginRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i12.LastFMLoginPage(); + }, + ); +} + +/// generated route for +/// [_i13.LibraryPage] +class LibraryRoute extends _i43.PageRouteInfo { + const LibraryRoute({List<_i43.PageRouteInfo>? children}) + : super( + LibraryRoute.name, + initialChildren: children, + ); + + static const String name = 'LibraryRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i13.LibraryPage(); + }, + ); +} + +/// generated route for +/// [_i14.LikedPlaylistPage] +class LikedPlaylistRoute extends _i43.PageRouteInfo { + LikedPlaylistRoute({ + _i44.Key? key, + required _i45.PlaylistSimple playlist, + List<_i43.PageRouteInfo>? children, + }) : super( + LikedPlaylistRoute.name, + args: LikedPlaylistRouteArgs( + key: key, + playlist: playlist, + ), + initialChildren: children, + ); + + static const String name = 'LikedPlaylistRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return _i14.LikedPlaylistPage( + key: args.key, + playlist: args.playlist, + ); + }, + ); +} + +class LikedPlaylistRouteArgs { + const LikedPlaylistRouteArgs({ + this.key, + required this.playlist, + }); + + final _i44.Key? key; + + final _i45.PlaylistSimple playlist; + + @override + String toString() { + return 'LikedPlaylistRouteArgs{key: $key, playlist: $playlist}'; + } +} + +/// generated route for +/// [_i15.LocalLibraryPage] +class LocalLibraryRoute extends _i43.PageRouteInfo { + LocalLibraryRoute({ + required String location, + _i44.Key? key, + bool isDownloads = false, + bool isCache = false, + List<_i43.PageRouteInfo>? children, + }) : super( + LocalLibraryRoute.name, + args: LocalLibraryRouteArgs( + location: location, + key: key, + isDownloads: isDownloads, + isCache: isCache, + ), + initialChildren: children, + ); + + static const String name = 'LocalLibraryRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return _i15.LocalLibraryPage( + args.location, + key: args.key, + isDownloads: args.isDownloads, + isCache: args.isCache, + ); + }, + ); +} + +class LocalLibraryRouteArgs { + const LocalLibraryRouteArgs({ + required this.location, + this.key, + this.isDownloads = false, + this.isCache = false, + }); + + final String location; + + final _i44.Key? key; + + final bool isDownloads; + + final bool isCache; + + @override + String toString() { + return 'LocalLibraryRouteArgs{location: $location, key: $key, isDownloads: $isDownloads, isCache: $isCache}'; + } +} + +/// generated route for +/// [_i16.LogsPage] +class LogsRoute extends _i43.PageRouteInfo { + const LogsRoute({List<_i43.PageRouteInfo>? children}) + : super( + LogsRoute.name, + initialChildren: children, + ); + + static const String name = 'LogsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i16.LogsPage(); + }, + ); +} + +/// generated route for +/// [_i17.LyricsPage] +class LyricsRoute extends _i43.PageRouteInfo { + const LyricsRoute({List<_i43.PageRouteInfo>? children}) + : super( + LyricsRoute.name, + initialChildren: children, + ); + + static const String name = 'LyricsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i17.LyricsPage(); + }, + ); +} + +/// generated route for +/// [_i18.MiniLyricsPage] +class MiniLyricsRoute extends _i43.PageRouteInfo { + MiniLyricsRoute({ + _i46.Key? key, + required _i46.Size prevSize, + List<_i43.PageRouteInfo>? children, + }) : super( + MiniLyricsRoute.name, + args: MiniLyricsRouteArgs( + key: key, + prevSize: prevSize, + ), + initialChildren: children, + ); + + static const String name = 'MiniLyricsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return _i18.MiniLyricsPage( + key: args.key, + prevSize: args.prevSize, + ); + }, + ); +} + +class MiniLyricsRouteArgs { + const MiniLyricsRouteArgs({ + this.key, + required this.prevSize, + }); + + final _i46.Key? key; + + final _i46.Size prevSize; + + @override + String toString() { + return 'MiniLyricsRouteArgs{key: $key, prevSize: $prevSize}'; + } +} + +/// generated route for +/// [_i19.PlayerLyricsPage] +class PlayerLyricsRoute extends _i43.PageRouteInfo { + const PlayerLyricsRoute({List<_i43.PageRouteInfo>? children}) + : super( + PlayerLyricsRoute.name, + initialChildren: children, + ); + + static const String name = 'PlayerLyricsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i19.PlayerLyricsPage(); + }, + ); +} + +/// generated route for +/// [_i20.PlayerQueuePage] +class PlayerQueueRoute extends _i43.PageRouteInfo { + const PlayerQueueRoute({List<_i43.PageRouteInfo>? children}) + : super( + PlayerQueueRoute.name, + initialChildren: children, + ); + + static const String name = 'PlayerQueueRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i20.PlayerQueuePage(); + }, + ); +} + +/// generated route for +/// [_i21.PlayerTrackSourcesPage] +class PlayerTrackSourcesRoute extends _i43.PageRouteInfo { + const PlayerTrackSourcesRoute({List<_i43.PageRouteInfo>? children}) + : super( + PlayerTrackSourcesRoute.name, + initialChildren: children, + ); + + static const String name = 'PlayerTrackSourcesRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i21.PlayerTrackSourcesPage(); + }, + ); +} + +/// generated route for +/// [_i22.PlaylistGenerateResultPage] +class PlaylistGenerateResultRoute + extends _i43.PageRouteInfo { + PlaylistGenerateResultRoute({ + _i46.Key? key, + required _i47.GeneratePlaylistProviderInput state, + List<_i43.PageRouteInfo>? children, + }) : super( + PlaylistGenerateResultRoute.name, + args: PlaylistGenerateResultRouteArgs( + key: key, + state: state, + ), + initialChildren: children, + ); + + static const String name = 'PlaylistGenerateResultRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return _i22.PlaylistGenerateResultPage( + key: args.key, + state: args.state, + ); + }, + ); +} + +class PlaylistGenerateResultRouteArgs { + const PlaylistGenerateResultRouteArgs({ + this.key, + required this.state, + }); + + final _i46.Key? key; + + final _i47.GeneratePlaylistProviderInput state; + + @override + String toString() { + return 'PlaylistGenerateResultRouteArgs{key: $key, state: $state}'; + } +} + +/// generated route for +/// [_i23.PlaylistGeneratorPage] +class PlaylistGeneratorRoute extends _i43.PageRouteInfo { + const PlaylistGeneratorRoute({List<_i43.PageRouteInfo>? children}) + : super( + PlaylistGeneratorRoute.name, + initialChildren: children, + ); + + static const String name = 'PlaylistGeneratorRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i23.PlaylistGeneratorPage(); + }, + ); +} + +/// generated route for +/// [_i24.PlaylistPage] +class PlaylistRoute extends _i43.PageRouteInfo { + PlaylistRoute({ + _i44.Key? key, + required String id, + required _i45.PlaylistSimple playlist, + List<_i43.PageRouteInfo>? children, + }) : super( + PlaylistRoute.name, + args: PlaylistRouteArgs( + key: key, + id: id, + playlist: playlist, + ), + rawPathParams: {'id': id}, + initialChildren: children, + ); + + static const String name = 'PlaylistRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return _i24.PlaylistPage( + key: args.key, + id: args.id, + playlist: args.playlist, + ); + }, + ); +} + +class PlaylistRouteArgs { + const PlaylistRouteArgs({ + this.key, + required this.id, + required this.playlist, + }); + + final _i44.Key? key; + + final String id; + + final _i45.PlaylistSimple playlist; + + @override + String toString() { + return 'PlaylistRouteArgs{key: $key, id: $id, playlist: $playlist}'; + } +} + +/// generated route for +/// [_i25.ProfilePage] +class ProfileRoute extends _i43.PageRouteInfo { + const ProfileRoute({List<_i43.PageRouteInfo>? children}) + : super( + ProfileRoute.name, + initialChildren: children, + ); + + static const String name = 'ProfileRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i25.ProfilePage(); + }, + ); +} + +/// generated route for +/// [_i26.RootAppPage] +class RootAppRoute extends _i43.PageRouteInfo { + const RootAppRoute({List<_i43.PageRouteInfo>? children}) + : super( + RootAppRoute.name, + initialChildren: children, + ); + + static const String name = 'RootAppRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i26.RootAppPage(); + }, + ); +} + +/// generated route for +/// [_i27.SearchPage] +class SearchRoute extends _i43.PageRouteInfo { + const SearchRoute({List<_i43.PageRouteInfo>? children}) + : super( + SearchRoute.name, + initialChildren: children, + ); + + static const String name = 'SearchRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i27.SearchPage(); + }, + ); +} + +/// generated route for +/// [_i28.SettingsPage] +class SettingsRoute extends _i43.PageRouteInfo { + const SettingsRoute({List<_i43.PageRouteInfo>? children}) + : super( + SettingsRoute.name, + initialChildren: children, + ); + + static const String name = 'SettingsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i28.SettingsPage(); + }, + ); +} + +/// generated route for +/// [_i29.StatsAlbumsPage] +class StatsAlbumsRoute extends _i43.PageRouteInfo { + const StatsAlbumsRoute({List<_i43.PageRouteInfo>? children}) + : super( + StatsAlbumsRoute.name, + initialChildren: children, + ); + + static const String name = 'StatsAlbumsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i29.StatsAlbumsPage(); + }, + ); +} + +/// generated route for +/// [_i30.StatsArtistsPage] +class StatsArtistsRoute extends _i43.PageRouteInfo { + const StatsArtistsRoute({List<_i43.PageRouteInfo>? children}) + : super( + StatsArtistsRoute.name, + initialChildren: children, + ); + + static const String name = 'StatsArtistsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i30.StatsArtistsPage(); + }, + ); +} + +/// generated route for +/// [_i31.StatsMinutesPage] +class StatsMinutesRoute extends _i43.PageRouteInfo { + const StatsMinutesRoute({List<_i43.PageRouteInfo>? children}) + : super( + StatsMinutesRoute.name, + initialChildren: children, + ); + + static const String name = 'StatsMinutesRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i31.StatsMinutesPage(); + }, + ); +} + +/// generated route for +/// [_i32.StatsPage] +class StatsRoute extends _i43.PageRouteInfo { + const StatsRoute({List<_i43.PageRouteInfo>? children}) + : super( + StatsRoute.name, + initialChildren: children, + ); + + static const String name = 'StatsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i32.StatsPage(); + }, + ); +} + +/// generated route for +/// [_i33.StatsPlaylistsPage] +class StatsPlaylistsRoute extends _i43.PageRouteInfo { + const StatsPlaylistsRoute({List<_i43.PageRouteInfo>? children}) + : super( + StatsPlaylistsRoute.name, + initialChildren: children, + ); + + static const String name = 'StatsPlaylistsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i33.StatsPlaylistsPage(); + }, + ); +} + +/// generated route for +/// [_i34.StatsStreamFeesPage] +class StatsStreamFeesRoute extends _i43.PageRouteInfo { + const StatsStreamFeesRoute({List<_i43.PageRouteInfo>? children}) + : super( + StatsStreamFeesRoute.name, + initialChildren: children, + ); + + static const String name = 'StatsStreamFeesRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i34.StatsStreamFeesPage(); + }, + ); +} + +/// generated route for +/// [_i35.StatsStreamsPage] +class StatsStreamsRoute extends _i43.PageRouteInfo { + const StatsStreamsRoute({List<_i43.PageRouteInfo>? children}) + : super( + StatsStreamsRoute.name, + initialChildren: children, + ); + + static const String name = 'StatsStreamsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i35.StatsStreamsPage(); + }, + ); +} + +/// generated route for +/// [_i36.TrackPage] +class TrackRoute extends _i43.PageRouteInfo { + TrackRoute({ + _i46.Key? key, + required String trackId, + List<_i43.PageRouteInfo>? children, + }) : super( + TrackRoute.name, + args: TrackRouteArgs( + key: key, + trackId: trackId, + ), + rawPathParams: {'id': trackId}, + initialChildren: children, + ); + + static const String name = 'TrackRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + final pathParams = data.inheritedPathParams; + final args = data.argsAs( + orElse: () => TrackRouteArgs(trackId: pathParams.getString('id'))); + return _i36.TrackPage( + key: args.key, + trackId: args.trackId, + ); + }, + ); +} + +class TrackRouteArgs { + const TrackRouteArgs({ + this.key, + required this.trackId, + }); + + final _i46.Key? key; + + final String trackId; + + @override + String toString() { + return 'TrackRouteArgs{key: $key, trackId: $trackId}'; + } +} + +/// generated route for +/// [_i37.UserAlbumsPage] +class UserAlbumsRoute extends _i43.PageRouteInfo { + const UserAlbumsRoute({List<_i43.PageRouteInfo>? children}) + : super( + UserAlbumsRoute.name, + initialChildren: children, + ); + + static const String name = 'UserAlbumsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i37.UserAlbumsPage(); + }, + ); +} + +/// generated route for +/// [_i38.UserArtistsPage] +class UserArtistsRoute extends _i43.PageRouteInfo { + const UserArtistsRoute({List<_i43.PageRouteInfo>? children}) + : super( + UserArtistsRoute.name, + initialChildren: children, + ); + + static const String name = 'UserArtistsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i38.UserArtistsPage(); + }, + ); +} + +/// generated route for +/// [_i39.UserDownloadsPage] +class UserDownloadsRoute extends _i43.PageRouteInfo { + const UserDownloadsRoute({List<_i43.PageRouteInfo>? children}) + : super( + UserDownloadsRoute.name, + initialChildren: children, + ); + + static const String name = 'UserDownloadsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i39.UserDownloadsPage(); + }, + ); +} + +/// generated route for +/// [_i40.UserLocalLibraryPage] +class UserLocalLibraryRoute extends _i43.PageRouteInfo { + const UserLocalLibraryRoute({List<_i43.PageRouteInfo>? children}) + : super( + UserLocalLibraryRoute.name, + initialChildren: children, + ); + + static const String name = 'UserLocalLibraryRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i40.UserLocalLibraryPage(); + }, + ); +} + +/// generated route for +/// [_i41.UserPlaylistsPage] +class UserPlaylistsRoute extends _i43.PageRouteInfo { + const UserPlaylistsRoute({List<_i43.PageRouteInfo>? children}) + : super( + UserPlaylistsRoute.name, + initialChildren: children, + ); + + static const String name = 'UserPlaylistsRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i41.UserPlaylistsPage(); + }, + ); +} + +/// generated route for +/// [_i42.WebViewLoginPage] +class WebViewLoginRoute extends _i43.PageRouteInfo { + const WebViewLoginRoute({List<_i43.PageRouteInfo>? children}) + : super( + WebViewLoginRoute.name, + initialChildren: children, + ); + + static const String name = 'WebViewLoginRoute'; + + static _i43.PageInfo page = _i43.PageInfo( + name, + builder: (data) { + return const _i42.WebViewLoginPage(); + }, + ); +} diff --git a/lib/collections/side_bar_tiles.dart b/lib/collections/side_bar_tiles.dart index 4f23c049..44c8b308 100644 --- a/lib/collections/side_bar_tiles.dart +++ b/lib/collections/side_bar_tiles.dart @@ -1,81 +1,113 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:spotube/pages/home/home.dart'; -import 'package:spotube/pages/library/library.dart'; -import 'package:spotube/pages/lyrics/lyrics.dart'; -import 'package:spotube/pages/search/search.dart'; -import 'package:spotube/pages/stats/stats.dart'; class SideBarTiles { final IconData icon; final String title; final String id; - final String name; + final String pathPrefix; + final PageRouteInfo route; SideBarTiles({ required this.icon, required this.title, required this.id, - required this.name, + required this.route, + required this.pathPrefix, }); } List getSidebarTileList(AppLocalizations l10n) => [ SideBarTiles( - id: "browse", - name: HomePage.name, + id: "home", + pathPrefix: "/home", + route: const HomeRoute(), icon: SpotubeIcons.home, title: l10n.browse, ), SideBarTiles( id: "search", - name: SearchPage.name, + pathPrefix: "/search", + route: const SearchRoute(), icon: SpotubeIcons.search, title: l10n.search, ), - SideBarTiles( - id: "library", - name: LibraryPage.name, - icon: SpotubeIcons.library, - title: l10n.library, - ), SideBarTiles( id: "lyrics", - name: LyricsPage.name, + pathPrefix: "/lyrics", + route: LyricsRoute(), icon: SpotubeIcons.music, title: l10n.lyrics, ), SideBarTiles( id: "stats", - name: StatsPage.name, + pathPrefix: "/stats", + route: const StatsRoute(), icon: SpotubeIcons.chart, title: l10n.stats, ), ]; +List getSidebarLibraryTileList(AppLocalizations l10n) => [ + SideBarTiles( + id: "playlists", + pathPrefix: "/library/playlists", + title: l10n.playlists, + route: const UserPlaylistsRoute(), + icon: SpotubeIcons.playlist, + ), + SideBarTiles( + id: "artists", + pathPrefix: "/library/artists", + title: l10n.artists, + route: const UserArtistsRoute(), + icon: SpotubeIcons.artist, + ), + SideBarTiles( + id: "albums", + pathPrefix: "/library/albums", + title: l10n.albums, + route: const UserAlbumsRoute(), + icon: SpotubeIcons.album, + ), + SideBarTiles( + id: "local_library", + pathPrefix: "/library/local", + title: l10n.local_library, + route: const UserLocalLibraryRoute(), + icon: SpotubeIcons.device, + ), + ]; + List getNavbarTileList(AppLocalizations l10n) => [ SideBarTiles( - id: "browse", - name: HomePage.name, + id: "home", + pathPrefix: "/home", + route: const HomeRoute(), icon: SpotubeIcons.home, title: l10n.browse, ), SideBarTiles( id: "search", - name: SearchPage.name, + pathPrefix: "/search", + route: const SearchRoute(), icon: SpotubeIcons.search, title: l10n.search, ), SideBarTiles( id: "library", - name: LibraryPage.name, + pathPrefix: "/library", + route: const UserPlaylistsRoute(), icon: SpotubeIcons.library, title: l10n.library, ), SideBarTiles( id: "stats", - name: StatsPage.name, + pathPrefix: "/stats", + route: const StatsRoute(), icon: SpotubeIcons.chart, title: l10n.stats, ), diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 5c4df85f..bd9d037c 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -1,5 +1,5 @@ import 'package:fluentui_system_icons/fluentui_system_icons.dart'; -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_feather_icons/flutter_feather_icons.dart'; import 'package:simple_icons/simple_icons.dart'; @@ -37,6 +37,7 @@ abstract class SpotubeIcons { static const share = FeatherIcons.share2; static const playlistAdd = Icons.playlist_add_rounded; static const playlistRemove = Icons.playlist_remove_rounded; + static const playlist = Icons.playlist_play_rounded; static const trash = FeatherIcons.trash2; static const clock = FeatherIcons.clock; static const lyrics = Icons.lyrics_rounded; @@ -127,4 +128,11 @@ abstract class SpotubeIcons { static const cache = FeatherIcons.hardDrive; static const export = Icons.file_open_outlined; static const delete = FeatherIcons.trash2; + static const open = FeatherIcons.externalLink; + static const radioChecked = Icons.radio_button_on_rounded; + static const radioUnchecked = Icons.radio_button_off_rounded; + static const grid = FeatherIcons.grid; + static const list = FeatherIcons.list; + static const device = FeatherIcons.smartphone; + static const engine = FeatherIcons.server; } diff --git a/lib/components/adaptive/adaptive_list_tile.dart b/lib/components/adaptive/adaptive_list_tile.dart index 33df44c1..c6d00bd4 100644 --- a/lib/components/adaptive/adaptive_list_tile.dart +++ b/lib/components/adaptive/adaptive_list_tile.dart @@ -1,5 +1,6 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/extensions/constrains.dart'; class AdaptiveListTile extends HookWidget { @@ -24,41 +25,39 @@ class AdaptiveListTile extends HookWidget { Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); - return ListTile( + return ButtonTile( title: title, subtitle: subtitle, trailing: breakOn ?? mediaQuery.smAndDown ? null : trailing?.call(context, null), leading: leading, - onTap: breakOn ?? mediaQuery.smAndDown - ? () { - onTap?.call(); - showDialog( - context: context, - barrierDismissible: true, - builder: (context) { - return StatefulBuilder(builder: (context, update) { - return AlertDialog( - title: title != null - ? Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (leading != null) ...[ - leading!, - const SizedBox(width: 5) - ], - Flexible(child: title!), - ], - ) - : Container(), - content: trailing?.call(context, update), - ); - }); - }, + enabled: breakOn ?? mediaQuery.smAndDown, + onPressed: () { + onTap?.call(); + showDialog( + context: context, + barrierDismissible: true, + builder: (context) { + return StatefulBuilder(builder: (context, update) { + return AlertDialog( + title: title != null + ? Row( + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 5, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (leading != null) leading!, + Flexible(child: title!), + ], + ) + : const SizedBox.shrink(), + content: Center(child: trailing?.call(context, update)), ); - } - : null, + }); + }, + ); + }, ); } } diff --git a/lib/components/adaptive/adaptive_pop_sheet_list.dart b/lib/components/adaptive/adaptive_pop_sheet_list.dart index 97dc6132..95d3fae7 100644 --- a/lib/components/adaptive/adaptive_pop_sheet_list.dart +++ b/lib/components/adaptive/adaptive_pop_sheet_list.dart @@ -1,67 +1,46 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show showModalBottomSheet; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/extensions/constrains.dart'; -_emptyCB() {} - -class PopSheetEntry extends ListTile { +class AdaptiveMenuButton extends MenuButton { final T? value; - const PopSheetEntry({ - this.value, + const AdaptiveMenuButton({ super.key, - super.leading, - super.title, - super.subtitle, + this.value, + required super.child, + super.subMenu, + super.onPressed, super.trailing, - super.isThreeLine = false, - super.dense, - super.visualDensity, - super.shape, - super.style, - super.selectedColor, - super.iconColor, - super.textColor, - super.titleTextStyle, - super.subtitleTextStyle, - super.leadingAndTrailingTextStyle, - super.contentPadding, + super.leading, super.enabled = true, - super.onTap = _emptyCB, - super.onLongPress, - super.onFocusChange, - super.mouseCursor, - super.selected = false, - super.focusColor, - super.hoverColor, - super.splashColor, super.focusNode, - super.autofocus = false, - super.tileColor, - super.selectedTileColor, - super.enableFeedback, - super.horizontalTitleGap, - super.minVerticalPadding, - super.minLeadingWidth, - super.titleAlignment, - }); + super.autoClose = true, + super.popoverController, + }) : assert( + value != null || onPressed != null, + 'Either value or onPressed must be provided', + ); } /// An adaptive widget that shows a [PopupMenuButton] when screen size is above /// or equal to 640px /// In smaller screen, a [IconButton] with a [showModalBottomSheet] is shown class AdaptivePopSheetList extends StatelessWidget { - final List> children; + final List> children; final Widget? icon; final Widget? child; final bool useRootNavigator; final List? headings; - final String? tooltip; + final String tooltip; final ValueChanged? onSelected; - final BorderRadius borderRadius; final Offset offset; + final ButtonVariance variance; + const AdaptivePopSheetList({ super.key, required this.children, @@ -70,166 +49,141 @@ class AdaptivePopSheetList extends StatelessWidget { this.useRootNavigator = true, this.headings, this.onSelected, - this.borderRadius = const BorderRadius.all(Radius.circular(999)), - this.tooltip, + required this.tooltip, this.offset = Offset.zero, + this.variance = ButtonVariance.ghost, }) : assert( !(icon != null && child != null), 'Either icon or child must be provided', ); - Future showPopupMenu(BuildContext context, RelativeRect position) { + Future showDropdownMenu(BuildContext context, Offset position) async { final mediaQuery = MediaQuery.of(context); + final childrenModified = children.map((s) { + if (s.onPressed == null) { + return MenuButton( + key: s.key, + autoClose: s.autoClose, + enabled: s.enabled, + leading: s.leading, + focusNode: s.focusNode, + onPressed: (context) { + if (s.value != null) { + onSelected?.call(s.value as T); + } + }, + popoverController: s.popoverController, + subMenu: s.subMenu, + trailing: s.trailing, + child: s.child, + ); + } + return s; + }).toList(); - return showMenu( + if (mediaQuery.mdAndUp) { + await showDropdown( + context: context, + rootOverlay: useRootNavigator, + // heightConstraint: PopoverConstraint.anchorFixedSize, + // constraints: BoxConstraints( + // maxHeight: mediaQuery.size.height * 0.6, + // ), + position: position, + builder: (context) { + return DropdownMenu( + children: childrenModified, + ); + }, + ).future; + return; + } + + showModalBottomSheet( context: context, - useRootNavigator: useRootNavigator, - constraints: BoxConstraints( - maxHeight: mediaQuery.size.height * 0.6, + enableDrag: true, + showDragHandle: true, + useRootNavigator: true, + shape: RoundedRectangleBorder( + borderRadius: context.theme.borderRadiusMd, ), - position: position, - items: children - .map( - (item) => PopupMenuItem( - padding: EdgeInsets.zero, - enabled: false, - child: _AdaptivePopSheetListItem( - item: item, - onSelected: onSelected, + backgroundColor: context.theme.colorScheme.card, + builder: (context) { + return ListView.builder( + itemCount: childrenModified.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final data = childrenModified[index]; + + return Button( + enabled: data.enabled, + style: ButtonVariance.ghost.copyWith( + padding: (context, state, value) => const EdgeInsets.all(16), ), - ), - ) - .toList(), + onPressed: () { + data.onPressed?.call(context); + if (data.autoClose) { + Navigator.of(context).pop(); + } + }, + leading: data.leading, + trailing: data.trailing, + alignment: Alignment.centerLeft, + child: data.child, + ); + }, + ); + }, ); } @override Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); - final theme = Theme.of(context); if (mediaQuery.mdAndUp) { - return PopupMenuButton( - icon: icon, - tooltip: tooltip, - offset: offset, - child: child == null ? null : IgnorePointer(child: child), - itemBuilder: (context) => children - .map( - (item) => PopupMenuItem( - padding: EdgeInsets.zero, - enabled: false, - child: _AdaptivePopSheetListItem( - item: item, - onSelected: onSelected, - ), - ), - ) - .toList(), - ); - } - - void showSheet() { - showModalBottomSheet( - context: context, - useRootNavigator: useRootNavigator, - isScrollControlled: true, - showDragHandle: true, - constraints: BoxConstraints( - maxHeight: mediaQuery.size.height * 0.6, + return Tooltip( + tooltip: TooltipContainer( + child: Text(tooltip), ), - builder: (context) { - return Padding( - padding: const EdgeInsets.all(8.0).copyWith(top: 0), - child: DefaultTextStyle( - style: theme.textTheme.titleMedium!, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (headings != null) ...[ - ...headings!, - const SizedBox(height: 8), - Divider( - color: theme.colorScheme.primary, - thickness: 0.3, - endIndent: 16, - indent: 16, - ), - ], - ...children.map( - (item) => _AdaptivePopSheetListItem( - item: item, - onSelected: onSelected, - ), - ) - ], - ), + child: IconButton( + variance: variance, + icon: icon ?? const Icon(SpotubeIcons.moreVertical), + onPressed: () { + final renderBox = context.findRenderObject() as RenderBox; + final position = RelativeRect.fromRect( + Rect.fromPoints( + renderBox.localToGlobal(Offset.zero, + ancestor: context.findRenderObject()), + renderBox.localToGlobal(renderBox.size.bottomRight(Offset.zero), + ancestor: context.findRenderObject()), ), - ), - ); - }, + Offset.zero & mediaQuery.size, + ); + final offset = Offset(position.left, position.top); + showDropdownMenu(context, offset); + }, + ), ); } if (child != null) { return Tooltip( - message: tooltip ?? '', - child: InkWell( - onTap: showSheet, - borderRadius: borderRadius, + tooltip: TooltipContainer(child: Text(tooltip)), + child: Button( + onPressed: () => showDropdownMenu(context, Offset.zero), + style: variance, child: IgnorePointer(child: child), ), ); } - return IconButton( - icon: icon ?? const Icon(SpotubeIcons.moreVertical), - tooltip: tooltip, - style: theme.iconButtonTheme.style?.copyWith( - shape: WidgetStatePropertyAll( - RoundedRectangleBorder( - borderRadius: borderRadius, - ), - ), - ), - onPressed: showSheet, - ); - } -} - -class _AdaptivePopSheetListItem extends StatelessWidget { - final PopSheetEntry item; - final ValueChanged? onSelected; - const _AdaptivePopSheetListItem({ - super.key, - required this.item, - this.onSelected, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return InkWell( - borderRadius: (theme.listTileTheme.shape as RoundedRectangleBorder?) - ?.borderRadius as BorderRadius? ?? - const BorderRadius.all(Radius.circular(10)), - onTap: !item.enabled - ? null - : () { - item.onTap?.call(); - if (item.value != null) { - Navigator.pop(context); - onSelected?.call(item.value as T); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: IconTheme.merge( - data: const IconThemeData(opacity: 1), - child: IgnorePointer(child: item), - ), + return Tooltip( + tooltip: TooltipContainer(child: Text(tooltip)), + child: IconButton( + variance: variance, + icon: icon ?? const Icon(SpotubeIcons.moreVertical), + onPressed: () => showDropdownMenu(context, Offset.zero), ), ); } diff --git a/lib/components/adaptive/adaptive_popup_menu_button.dart b/lib/components/adaptive/adaptive_popup_menu_button.dart deleted file mode 100644 index 02fced52..00000000 --- a/lib/components/adaptive/adaptive_popup_menu_button.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -import 'package:popover/popover.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/extensions/constrains.dart'; - -class Action extends StatelessWidget { - final Widget text; - final Widget icon; - final void Function() onPressed; - final bool isExpanded; - final Color? backgroundColor; - const Action({ - super.key, - required this.icon, - required this.text, - required this.onPressed, - this.isExpanded = true, - this.backgroundColor, - }); - - @override - Widget build(BuildContext context) { - if (isExpanded != true) { - return IconButton( - icon: icon, - onPressed: onPressed, - style: IconButton.styleFrom( - backgroundColor: backgroundColor, - ), - tooltip: text is Text - ? (text as Text).data - : text.toStringShallow().split(",").last.replaceAll( - "\"", - "", - ), - ); - } - - return ListTile( - tileColor: backgroundColor, - onTap: onPressed, - leading: icon, - title: text, - ); - } -} - -class AdaptiveActions extends HookWidget { - final List actions; - final bool? breakOn; - const AdaptiveActions({ - required this.actions, - this.breakOn, - super.key, - }); - - @override - Widget build(BuildContext context) { - final mediaQuery = MediaQuery.of(context); - - if (breakOn ?? mediaQuery.lgAndUp) { - return IconButton( - icon: const Icon(SpotubeIcons.moreHorizontal), - onPressed: () { - showPopover( - context: context, - direction: PopoverDirection.left, - bodyBuilder: (context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: actions - .map( - (action) => SizedBox( - width: 200, - child: Row( - children: [ - Expanded(child: action), - ], - ), - ), - ) - .toList(), - ); - }, - backgroundColor: Theme.of(context).cardColor, - ); - }, - ); - } - - return Row( - children: actions.map((action) { - return Action( - icon: action.icon, - onPressed: action.onPressed, - text: action.text, - backgroundColor: action.backgroundColor, - isExpanded: false, - ); - }).toList(), - ); - } -} diff --git a/lib/components/adaptive/adaptive_select_tile.dart b/lib/components/adaptive/adaptive_select_tile.dart index 3f6d2700..afa982af 100644 --- a/lib/components/adaptive/adaptive_select_tile.dart +++ b/lib/components/adaptive/adaptive_select_tile.dart @@ -1,5 +1,6 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show ListTile, ListTileControlAffinity; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/extensions/constrains.dart'; @@ -7,11 +8,12 @@ class AdaptiveSelectTile extends HookWidget { final Widget title; final Widget? subtitle; final Widget? secondary; + final List? trailing; final ListTileControlAffinity? controlAffinity; final T value; final ValueChanged? onChanged; - final List> options; + final List> options; /// Show the smaller value when the breakpoint is reached /// @@ -22,6 +24,9 @@ class AdaptiveSelectTile extends HookWidget { final bool? breakLayout; + final BoxConstraints? popupConstraints; + final PopoverConstraint? popupWidthConstraint; + const AdaptiveSelectTile({ required this.title, required this.value, @@ -30,64 +35,50 @@ class AdaptiveSelectTile extends HookWidget { this.controlAffinity = ListTileControlAffinity.trailing, this.subtitle, this.secondary, + this.trailing, this.breakLayout, this.showValueWhenUnfolded = true, super.key, + this.popupConstraints, + this.popupWidthConstraint, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); - final mediaQuery = MediaQuery.of(context); - final rawControl = DecoratedBox( - decoration: BoxDecoration( - color: theme.colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(10), - ), - child: DropdownButton( - items: options, - value: value, - onChanged: onChanged, - menuMaxHeight: mediaQuery.size.height * 0.6, - underline: const SizedBox.shrink(), - padding: const EdgeInsets.symmetric(horizontal: 10), - borderRadius: BorderRadius.circular(10), - icon: const Icon(SpotubeIcons.angleDown), - dropdownColor: theme.colorScheme.secondaryContainer, - ), - ); - final controlPlaceholder = useMemoized( - () => options - .firstWhere( - (element) => element.value == value, - orElse: () => DropdownMenuItem( - value: null, - child: Container(), - ), - ) - .child, - [value, options]); + final mediaQuery = MediaQuery.sizeOf(context); - final control = breakLayout ?? mediaQuery.mdAndUp - ? rawControl - : showValueWhenUnfolded - ? Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - border: Border.all( - color: theme.colorScheme.primary, - width: 2, - ), - borderRadius: BorderRadius.circular(10), - ), - child: DefaultTextStyle( - style: TextStyle( - color: theme.colorScheme.primary, - ), - child: controlPlaceholder, - ), - ) - : const SizedBox.shrink(); + Widget? control = Select( + itemBuilder: (context, item) { + return options.firstWhere((element) => element.value == item).child; + }, + value: value, + onChanged: onChanged, + popupConstraints: popupConstraints ?? const BoxConstraints(maxWidth: 200), + popupWidthConstraint: popupWidthConstraint ?? PopoverConstraint.flexible, + autoClosePopover: true, + popup: (context) { + return SelectPopup( + autoClose: true, + items: SelectItemBuilder( + childCount: options.length, + builder: (context, index) { + return options[index]; + }, + ), + ); + }, + ); + + if (mediaQuery.smAndDown) { + if (showValueWhenUnfolded) { + control = OutlineBadge( + child: options.firstWhere((element) => element.value == value).child, + ); + } else { + control = null; + } + } return ListTile( title: title, @@ -95,29 +86,48 @@ class AdaptiveSelectTile extends HookWidget { leading: controlAffinity != ListTileControlAffinity.leading ? secondary : control, - trailing: controlAffinity == ListTileControlAffinity.leading - ? secondary - : control, + trailing: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + spacing: 5, + children: [ + ...?trailing, + if (controlAffinity == ListTileControlAffinity.leading && + secondary != null) + secondary! + else if (controlAffinity == ListTileControlAffinity.trailing && + control != null) + control, + ], + ), onTap: breakLayout ?? mediaQuery.mdAndUp ? null : () { showDialog( context: context, builder: (context) { - return SimpleDialog( - title: title, - children: [ - for (final option in options) - RadioListTile( - title: option.child, - value: option.value as T, - groupValue: value, - onChanged: (v) { - Navigator.pop(context); - onChanged?.call(v); - }, - ), - ], + return AlertDialog( + content: Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (context, index) { + final item = options[index]; + + return ListTile( + iconColor: theme.colorScheme.primary, + leading: item.value == value + ? const Icon(SpotubeIcons.radioChecked) + : const Icon(SpotubeIcons.radioUnchecked), + title: item.child, + onTap: () { + onChanged?.call(item.value); + Navigator.of(context).pop(); + }, + ); + }, + ), + ), ); }, ); diff --git a/lib/components/animated_gradient.dart b/lib/components/animated_gradient.dart index aaba2ff9..a9d4ef2b 100644 --- a/lib/components/animated_gradient.dart +++ b/lib/components/animated_gradient.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; class AnimateGradient extends HookWidget { diff --git a/lib/components/bordered_text.dart b/lib/components/bordered_text.dart deleted file mode 100644 index f25f2208..00000000 --- a/lib/components/bordered_text.dart +++ /dev/null @@ -1,88 +0,0 @@ -library bordered_text; - -import 'package:flutter/widgets.dart'; - -/// Adds stroke to text widget -/// We can apply a very thin and subtle stroke to a [Text] -/// ```dart -/// BorderedText( -/// strokeWidth: 1.0, -/// text: Text( -/// 'Bordered Text', -/// style: TextStyle( -/// decoration: TextDecoration.none, -/// decorationStyle: TextDecorationStyle.wavy, -/// decorationColor: Colors.red, -/// ), -/// ), -/// ) -/// ``` -class BorderedText extends StatelessWidget { - const BorderedText({ - super.key, - required this.child, - this.strokeCap = StrokeCap.round, - this.strokeJoin = StrokeJoin.round, - this.strokeWidth = 6.0, - this.strokeColor = const Color.fromRGBO(53, 0, 71, 1), - }); - - /// the stroke cap style - final StrokeCap strokeCap; - - /// the stroke joint style - final StrokeJoin strokeJoin; - - /// the stroke width - final double strokeWidth; - - /// the stroke color - final Color strokeColor; - - /// the [Text] widget to apply stroke on - final Text child; - - @override - Widget build(BuildContext context) { - TextStyle style; - if (child.style != null) { - style = child.style!.copyWith( - foreground: Paint() - ..style = PaintingStyle.stroke - ..strokeCap = strokeCap - ..strokeJoin = strokeJoin - ..strokeWidth = strokeWidth - ..color = strokeColor, - color: null, - ); - } else { - style = TextStyle( - foreground: Paint() - ..style = PaintingStyle.stroke - ..strokeCap = strokeCap - ..strokeJoin = strokeJoin - ..strokeWidth = strokeWidth - ..color = strokeColor, - ); - } - return Stack( - alignment: Alignment.center, - textDirection: child.textDirection, - children: [ - Text( - child.data!, - style: style, - maxLines: child.maxLines, - overflow: child.overflow, - semanticsLabel: child.semanticsLabel, - softWrap: child.softWrap, - strutStyle: child.strutStyle, - textAlign: child.textAlign, - textDirection: child.textDirection, - textScaler: child.textScaler, - ), - child, - ], - ); - } -} diff --git a/lib/components/button/back_button.dart b/lib/components/button/back_button.dart new file mode 100644 index 00000000..42c952ab --- /dev/null +++ b/lib/components/button/back_button.dart @@ -0,0 +1,21 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/collections/spotube_icons.dart'; + +class BackButton extends StatelessWidget { + final Color? color; + final IconData icon; + const BackButton({ + super.key, + this.color, + this.icon = SpotubeIcons.angleLeft, + }); + + @override + Widget build(BuildContext context) { + return IconButton.ghost( + size: const ButtonSize(.9), + icon: Icon(icon, color: color), + onPressed: () => Navigator.of(context).pop(), + ); + } +} diff --git a/lib/components/compact_search.dart b/lib/components/compact_search.dart deleted file mode 100644 index d37cb673..00000000 --- a/lib/components/compact_search.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -import 'package:popover/popover.dart'; -import 'package:spotube/collections/spotube_icons.dart'; - -class CompactSearch extends HookWidget { - final ValueChanged? onChanged; - final String placeholder; - final IconData icon; - final Color? iconColor; - - const CompactSearch({ - super.key, - this.onChanged, - this.placeholder = "Search...", - this.icon = SpotubeIcons.search, - this.iconColor, - }); - - @override - Widget build(BuildContext context) { - return IconButton( - onPressed: () { - showPopover( - context: context, - backgroundColor: Theme.of(context).cardColor, - transitionDuration: const Duration(milliseconds: 100), - barrierColor: Colors.transparent, - arrowDxOffset: -6, - bodyBuilder: (context) { - return Container( - padding: const EdgeInsets.all(8.0), - width: 300, - child: TextField( - autofocus: true, - onChanged: onChanged, - decoration: InputDecoration( - hintText: placeholder, - prefixIcon: Icon(icon), - ), - ), - ); - }, - height: 60, - ); - }, - tooltip: placeholder, - icon: Icon(icon, color: iconColor), - ); - } -} diff --git a/lib/components/dialogs/confirm_download_dialog.dart b/lib/components/dialogs/confirm_download_dialog.dart index 897c64cb..a2df0e9c 100644 --- a/lib/components/dialogs/confirm_download_dialog.dart +++ b/lib/components/dialogs/confirm_download_dialog.dart @@ -1,5 +1,4 @@ -import 'package:flutter/material.dart'; - +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; @@ -9,13 +8,15 @@ class ConfirmDownloadDialog extends StatelessWidget { @override Widget build(BuildContext context) { - return AlertDialog( - title: Padding( - padding: const EdgeInsets.all(15), - child: Row( + final screenSize = MediaQuery.sizeOf(context); + + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: Breakpoints.sm), + child: AlertDialog( + title: Row( + spacing: 10, children: [ Text(context.l10n.are_you_sure), - const SizedBox(width: 10), const UniversalImage( path: "https://c.tenor.com/kHcmsxlKHEAAAAAM/rock-one-eyebrow-raised-rock-staring.gif", @@ -24,58 +25,53 @@ class ConfirmDownloadDialog extends StatelessWidget { ) ], ), - ), - content: Container( - padding: const EdgeInsets.all(15), - constraints: BoxConstraints(maxWidth: Breakpoints.sm), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.download_warning, - textAlign: TextAlign.justify, - ), - const SizedBox(height: 10), - Text( - context.l10n.download_ip_ban_warning, - style: const TextStyle( - color: Colors.red, - fontWeight: FontWeight.bold, + content: Expanded( + flex: screenSize.smAndUp ? 0 : 1, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.download_warning, + textAlign: TextAlign.justify, ), - textAlign: TextAlign.justify, - ), - const SizedBox(height: 10), - Text( - context.l10n.by_clicking_accept_terms, - ), - const SizedBox(height: 10), - BulletPoint(context.l10n.download_agreement_1), - const SizedBox(height: 10), - BulletPoint(context.l10n.download_agreement_2), - const SizedBox(height: 10), - BulletPoint(context.l10n.download_agreement_3), - ], + const SizedBox(height: 10), + Text( + context.l10n.download_ip_ban_warning, + style: const TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.justify, + ), + const SizedBox(height: 10), + Text( + context.l10n.by_clicking_accept_terms, + ), + const SizedBox(height: 10), + BulletPoint(context.l10n.download_agreement_1), + const SizedBox(height: 10), + BulletPoint(context.l10n.download_agreement_2), + const SizedBox(height: 10), + BulletPoint(context.l10n.download_agreement_3), + ], + ), ), ), + actions: [ + Button.outline( + child: Text(context.l10n.decline), + onPressed: () { + Navigator.pop(context, false); + }, + ), + Button.destructive( + onPressed: () => Navigator.of(context).pop(true), + child: Text(context.l10n.accept), + ), + ], ), - actions: [ - OutlinedButton( - child: Text(context.l10n.decline), - onPressed: () { - Navigator.pop(context, false); - }, - ), - FilledButton( - style: FilledButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: Colors.red, - ), - onPressed: () => Navigator.of(context).pop(true), - child: Text(context.l10n.accept), - ), - ], ); } } diff --git a/lib/components/dialogs/piped_down_dialog.dart b/lib/components/dialogs/piped_down_dialog.dart deleted file mode 100644 index b1717a2a..00000000 --- a/lib/components/dialogs/piped_down_dialog.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class PipedDownDialog extends HookConsumerWidget { - const PipedDownDialog({super.key}); - - @override - Widget build(BuildContext context, ref) { - final pipedInstance = - ref.watch(userPreferencesProvider.select((s) => s.pipedInstance)); - final ThemeData(:colorScheme) = Theme.of(context); - - return AlertDialog( - insetPadding: const EdgeInsets.all(6), - contentPadding: const EdgeInsets.all(6), - icon: Icon( - SpotubeIcons.error, - color: colorScheme.error, - ), - title: Text( - context.l10n.piped_api_down, - style: TextStyle(color: colorScheme.error), - ), - content: Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: - Text(context.l10n.piped_down_error_instructions(pipedInstance)), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.ok), - ), - FilledButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.settings), - ), - ], - ); - } -} diff --git a/lib/components/dialogs/playlist_add_track_dialog.dart b/lib/components/dialogs/playlist_add_track_dialog.dart index 5af9c9e4..5098bf9d 100644 --- a/lib/components/dialogs/playlist_add_track_dialog.dart +++ b/lib/components/dialogs/playlist_add_track_dialog.dart @@ -1,7 +1,6 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; @@ -22,7 +21,7 @@ class PlaylistAddTrackDialog extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme) = Theme.of(context); + final typography = Theme.of(context).typography; final userPlaylists = ref.watch(favoritePlaylistsProvider); final favoritePlaylistsNotifier = ref.watch(favoritePlaylistsProvider.notifier); @@ -64,67 +63,86 @@ class PlaylistAddTrackDialog extends HookConsumerWidget { tracks.map((e) => e.id!).toList(), ), ), - ).then((_) => Navigator.pop(context, true)); + ).then((_) => context.mounted ? Navigator.pop(context, true) : null); } - return AlertDialog( - insetPadding: EdgeInsets.zero, - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - context.l10n.add_to_playlist, - style: textTheme.titleMedium, + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: AlertDialog( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.l10n.add_to_playlist, + style: typography.large, + ), + const Spacer(), + const PlaylistCreateDialogButton(), + ], + ), + actions: [ + OutlineButton( + child: Text(context.l10n.cancel), + onPressed: () { + Navigator.pop(context, false); + }, + ), + PrimaryButton( + onPressed: onAdd, + child: Text(context.l10n.add), ), - const Gap(20), - const PlaylistCreateDialogButton(), ], - ), - actions: [ - OutlinedButton( - child: Text(context.l10n.cancel), - onPressed: () { - Navigator.pop(context, false); - }, - ), - FilledButton( - onPressed: onAdd, - child: Text(context.l10n.add), - ), - ], - content: SizedBox( - height: 300, - width: 300, - child: userPlaylists.isLoading - ? const Center(child: CircularProgressIndicator()) - : ListView.builder( - shrinkWrap: true, - itemCount: filteredPlaylists.length, - itemBuilder: (context, index) { - final playlist = filteredPlaylists.elementAt(index); - return CheckboxListTile( - secondary: CircleAvatar( - backgroundImage: UniversalImage.imageProvider( - playlist.images.asUrlString( - placeholder: ImagePlaceholder.collection, + content: SizedBox( + height: 300, + child: userPlaylists.isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView.builder( + shrinkWrap: true, + itemCount: filteredPlaylists.length, + itemBuilder: (context, index) { + final playlist = filteredPlaylists.elementAt(index); + return Button.ghost( + style: ButtonVariance.ghost.copyWith( + padding: (context, _, __) { + return const EdgeInsets.symmetric(vertical: 8); + }, + ), + leading: Avatar( + initials: + Avatar.getInitials(playlist.name ?? "Playlist"), + provider: UniversalImage.imageProvider( + playlist.images.asUrlString( + placeholder: ImagePlaceholder.collection, + ), ), ), - ), - contentPadding: EdgeInsets.zero, - title: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text(playlist.name!), - ), - value: playlistsCheck.value[playlist.id] ?? false, - onChanged: (val) { - playlistsCheck.value = { - ...playlistsCheck.value, - playlist.id!: val == true - }; - }, - ); - }, - ), + trailing: Checkbox( + state: (playlistsCheck.value[playlist.id] ?? false) + ? CheckboxState.checked + : CheckboxState.unchecked, + onChanged: (val) { + playlistsCheck.value = { + ...playlistsCheck.value, + playlist.id!: val == CheckboxState.checked, + }; + }, + ), + onPressed: () { + playlistsCheck.value = { + ...playlistsCheck.value, + playlist.id!: + !(playlistsCheck.value[playlist.id] ?? false), + }; + }, + child: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text(playlist.name!), + ), + ); + }, + ), + ), ), ); } diff --git a/lib/components/dialogs/prompt_dialog.dart b/lib/components/dialogs/prompt_dialog.dart index 30a63bcf..3498bf02 100644 --- a/lib/components/dialogs/prompt_dialog.dart +++ b/lib/components/dialogs/prompt_dialog.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/extensions/context.dart'; Future showPromptDialog({ @@ -16,13 +16,13 @@ Future showPromptDialog({ content: Text(message), actions: [ if (cancelText != null) - OutlinedButton( + Button.outline( onPressed: () => Navigator.of(context).pop(false), child: Text( cancelText == "Cancel" ? context.l10n.cancel : cancelText, ), ), - FilledButton( + Button.primary( child: Text(okText == "Ok" ? context.l10n.ok : okText), onPressed: () => Navigator.of(context).pop(true), ), diff --git a/lib/components/dialogs/replace_downloaded_dialog.dart b/lib/components/dialogs/replace_downloaded_dialog.dart index 00461d34..3a0f3a1d 100644 --- a/lib/components/dialogs/replace_downloaded_dialog.dart +++ b/lib/components/dialogs/replace_downloaded_dialog.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/context.dart'; @@ -13,45 +13,35 @@ class ReplaceDownloadedDialog extends ConsumerWidget { @override Widget build(BuildContext context, ref) { final groupValue = ref.watch(replaceDownloadedFileState); - final theme = Theme.of(context); final replaceAll = ref.watch(replaceDownloadedFileState); return AlertDialog( title: Text(context.l10n.track_exists(track.name ?? "")), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(context.l10n.do_you_want_to_replace), - RadioListTile( - dense: true, - contentPadding: EdgeInsets.zero, - activeColor: theme.colorScheme.primary, - value: true, - groupValue: groupValue, - onChanged: (value) { - if (value != null) { - ref.read(replaceDownloadedFileState.notifier).state = true; - } - }, - title: Text(context.l10n.replace_downloaded_tracks), - ), - RadioListTile( - dense: true, - contentPadding: EdgeInsets.zero, - activeColor: theme.colorScheme.primary, - value: false, - groupValue: groupValue, - onChanged: (value) { - if (value != null) { - ref.read(replaceDownloadedFileState.notifier).state = false; - } - }, - title: Text(context.l10n.skip_download_tracks), - ), - ], + content: RadioGroup( + value: groupValue, + onChanged: (value) { + ref.read(replaceDownloadedFileState.notifier).state = value; + }, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.do_you_want_to_replace), + const Gap(16), + RadioItem( + value: true, + trailing: Text(context.l10n.replace_downloaded_tracks), + ), + const Gap(8), + RadioItem( + value: false, + trailing: Text(context.l10n.skip_download_tracks), + ), + ], + ), ), actions: [ - OutlinedButton( + Button.outline( onPressed: replaceAll == true ? null : () { @@ -59,7 +49,7 @@ class ReplaceDownloadedDialog extends ConsumerWidget { }, child: Text(context.l10n.skip), ), - FilledButton( + Button.primary( onPressed: replaceAll == false ? null : () { diff --git a/lib/components/dialogs/select_device_dialog.dart b/lib/components/dialogs/select_device_dialog.dart index 3a3bde60..5392a403 100644 --- a/lib/components/dialogs/select_device_dialog.dart +++ b/lib/components/dialogs/select_device_dialog.dart @@ -1,6 +1,6 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/connect/clients.dart'; @@ -16,31 +16,31 @@ class SelectDeviceDialog extends HookConsumerWidget { return AlertDialog( title: Text(context.l10n.choose_the_device), - insetPadding: const EdgeInsets.all(16), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(context.l10n.multiple_device_connected), - RadioListTile.adaptive( - title: Text(remoteService.name), - value: true, - groupValue: isRemoteService.value, - onChanged: (value) { - isRemoteService.value = value!; - }, - ), - RadioListTile.adaptive( - title: Text(context.l10n.this_device), - value: false, - groupValue: isRemoteService.value, - onChanged: (value) { - isRemoteService.value = !value!; - }, - ), - ], + content: RadioGroup( + value: isRemoteService.value, + onChanged: (value) { + isRemoteService.value = value; + }, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.multiple_device_connected), + const Gap(16), + RadioItem( + trailing: Text(remoteService.name), + value: true, + ), + const Gap(8), + RadioItem( + trailing: Text(context.l10n.this_device), + value: false, + ), + ], + ), ), actions: [ - TextButton( + Button.primary( onPressed: () { Navigator.of(context).pop(isRemoteService.value); }, @@ -51,7 +51,8 @@ class SelectDeviceDialog extends HookConsumerWidget { } } -Future showSelectDeviceDialog(BuildContext context, WidgetRef ref) async { +Future showSelectDeviceDialog( + BuildContext context, WidgetRef ref) async { final connectClients = ref.read(connectClientsProvider); if (connectClients.asData?.value.resolvedService == null) { @@ -63,5 +64,5 @@ Future showSelectDeviceDialog(BuildContext context, WidgetRef ref) async { builder: (context) => const SelectDeviceDialog(), ); - return isRemote ?? false; + return isRemote; } diff --git a/lib/components/dialogs/track_details_dialog.dart b/lib/components/dialogs/track_details_dialog.dart index 61bca7b1..bfb4a318 100644 --- a/lib/components/dialogs/track_details_dialog.dart +++ b/lib/components/dialogs/track_details_dialog.dart @@ -1,6 +1,7 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/links/artist_link.dart'; import 'package:spotube/components/links/hyper_link.dart'; @@ -32,8 +33,7 @@ class TrackDetailsDialog extends HookWidget { ), context.l10n.album: LinkText( track.album!.name!, - "/album/${track.album?.id}", - extra: track.album, + AlbumRoute(album: track.album!, id: track.album!.id!), overflow: TextOverflow.ellipsis, style: const TextStyle(color: Colors.blue), ), @@ -73,17 +73,15 @@ class TrackDetailsDialog extends HookWidget { }; return AlertDialog( - contentPadding: const EdgeInsets.all(16), - insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 100), - scrollable: true, + surfaceBlur: 0, + surfaceOpacity: 1, title: Row( - mainAxisAlignment: MainAxisAlignment.center, + spacing: 8, children: [ const Icon(SpotubeIcons.info), - const SizedBox(width: 8), Text( context.l10n.details, - style: theme.textTheme.titleMedium, + style: theme.typography.h4, ), ], ), @@ -91,65 +89,64 @@ class TrackDetailsDialog extends HookWidget { width: mediaQuery.mdAndUp ? double.infinity : 700, child: Table( columnWidths: const { - 0: FixedColumnWidth(95), - 1: FixedColumnWidth(10), - 2: FlexColumnWidth(1), + 0: FixedTableSize(95), + 1: FixedTableSize(10), + 2: FlexTableSize(), }, - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - children: [ + theme: const TableTheme( + backgroundColor: Colors.transparent, + cellTheme: TableCellTheme( + backgroundColor: WidgetStatePropertyAll(Colors.transparent), + ), + ), + rowHeights: const {0: FixedTableSize(40)}, + rows: [ for (final entry in detailsMap.entries) TableRow( - children: [ + cells: [ TableCell( - verticalAlignment: TableCellVerticalAlignment.top, child: Text( entry.key, - style: theme.textTheme.titleMedium, + style: theme.typography.bold, ), ), const TableCell( - verticalAlignment: TableCellVerticalAlignment.top, child: Text(":"), ), - if (entry.value is Widget) - entry.value as Widget - else if (entry.value is String) - Text( - entry.value as String, - style: theme.textTheme.bodyMedium, - ), + TableCell( + child: entry.value is Widget + ? entry.value as Widget + : (entry.value is String) + ? Text( + entry.value as String, + style: theme.typography.normal, + ) + : const Text(""), + ), ], ), - const TableRow( - children: [ - SizedBox(height: 16), - SizedBox(height: 16), - SizedBox(height: 16), - ], - ), for (final entry in ytTracksDetailsMap.entries) TableRow( - children: [ + cells: [ TableCell( - verticalAlignment: TableCellVerticalAlignment.top, child: Text( entry.key, - style: theme.textTheme.titleMedium, + style: theme.typography.bold, ), ), const TableCell( - verticalAlignment: TableCellVerticalAlignment.top, child: Text(":"), ), - if (entry.value is Widget) - entry.value as Widget - else - Text( - entry.value, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium, - ), + TableCell( + child: entry.value is Widget + ? entry.value as Widget + : Text( + entry.value, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.typography.normal, + ), + ), ], ), ], diff --git a/lib/components/expandable_search/expandable_search.dart b/lib/components/expandable_search/expandable_search.dart index 157e180f..0c40b843 100644 --- a/lib/components/expandable_search/expandable_search.dart +++ b/lib/components/expandable_search/expandable_search.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/extensions/context.dart'; @@ -39,11 +39,8 @@ class ExpandableSearchField extends StatelessWidget { child: TextField( focusNode: searchFocus, controller: searchController, - decoration: InputDecoration( - hintText: context.l10n.search_tracks, - isDense: true, - prefixIcon: const Icon(SpotubeIcons.search), - ), + placeholder: Text(context.l10n.search_tracks), + leading: const Icon(SpotubeIcons.search), ), ), ), @@ -69,16 +66,9 @@ class ExpandableSearchButton extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return IconButton( icon: icon, - style: IconButton.styleFrom( - backgroundColor: - isFiltering ? theme.colorScheme.secondaryContainer : null, - foregroundColor: isFiltering ? theme.colorScheme.secondary : null, - minimumSize: const Size(25, 25), - ), + variance: isFiltering ? ButtonVariance.secondary : ButtonVariance.outline, onPressed: () { if (isFiltering) { searchFocus.requestFocus(); diff --git a/lib/components/fallbacks/anonymous_fallback.dart b/lib/components/fallbacks/anonymous_fallback.dart index 62ed8ddd..293df932 100644 --- a/lib/components/fallbacks/anonymous_fallback.dart +++ b/lib/components/fallbacks/anonymous_fallback.dart @@ -1,10 +1,13 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_undraw/flutter_undraw.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/provider/authentication/authentication.dart'; -import 'package:spotube/utils/service_utils.dart'; +import 'package:spotube/utils/platform.dart'; class AnonymousFallback extends ConsumerWidget { final Widget? child; @@ -25,12 +28,19 @@ class AnonymousFallback extends ConsumerWidget { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, + spacing: 10, children: [ + Undraw( + illustration: kIsMobile + ? UndrawIllustration.accessDenied + : UndrawIllustration.secureLogin, + height: 200 * context.theme.scaling, + color: context.theme.colorScheme.primary, + ), Text(context.l10n.not_logged_in), - const SizedBox(height: 10), - FilledButton( + Button.primary( child: Text(context.l10n.login_with_spotify), - onPressed: () => ServiceUtils.pushNamed(context, SettingsPage.name), + onPressed: () => context.navigateTo(const SettingsRoute()), ) ], ), diff --git a/lib/components/fallbacks/not_found.dart b/lib/components/fallbacks/not_found.dart index ce168f17..9a994446 100644 --- a/lib/components/fallbacks/not_found.dart +++ b/lib/components/fallbacks/not_found.dart @@ -1,32 +1,27 @@ -import 'package:flutter/material.dart'; -import 'package:spotube/collections/assets.gen.dart'; +import 'package:flutter_undraw/flutter_undraw.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/extensions/context.dart'; class NotFound extends StatelessWidget { - final bool vertical; - const NotFound({super.key, this.vertical = false}); + const NotFound({super.key}); @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final widgets = [ - SizedBox( - height: 150, - width: 150, - child: Assets.emptyBox.image(), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(context.l10n.nothing_found, style: theme.textTheme.titleLarge), - Text( - context.l10n.the_box_is_empty, - style: theme.textTheme.titleMedium, - ), - ], - ), - ]; - return vertical ? Column(children: widgets) : Row(children: widgets); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Undraw( + illustration: UndrawIllustration.empty, + height: 200 * context.theme.scaling, + color: context.theme.colorScheme.primary, + ), + const Gap(10), + Text( + context.l10n.nothing_found, + textAlign: TextAlign.center, + ).muted().small() + ], + ); } } diff --git a/lib/components/form/checkbox_form_field.dart b/lib/components/form/checkbox_form_field.dart new file mode 100644 index 00000000..0e794833 --- /dev/null +++ b/lib/components/form/checkbox_form_field.dart @@ -0,0 +1,45 @@ +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class CheckboxFormBuilderField extends StatelessWidget { + final String name; + final FormFieldValidator? validator; + + final ValueChanged? onChanged; + final Widget? leading; + final Widget? trailing; + final bool tristate; + const CheckboxFormBuilderField({ + super.key, + required this.name, + this.validator, + this.onChanged, + this.leading, + this.trailing, + this.tristate = false, + }); + + @override + Widget build(BuildContext context) { + return FormBuilderField( + name: name, + validator: validator, + builder: (field) { + return Checkbox( + state: tristate && field.value == null + ? CheckboxState.indeterminate + : field.value == true + ? CheckboxState.checked + : CheckboxState.unchecked, + onChanged: (state) { + field.didChange(state == CheckboxState.checked); + onChanged?.call(state); + }, + leading: leading, + trailing: trailing, + tristate: tristate, + ); + }, + ); + } +} diff --git a/lib/components/form/text_form_field.dart b/lib/components/form/text_form_field.dart new file mode 100644 index 00000000..56ef34a5 --- /dev/null +++ b/lib/components/form/text_form_field.dart @@ -0,0 +1,187 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; + +class TextFormBuilderField extends StatelessWidget { + final String name; + final FormFieldValidator? validator; + final Widget? label; + + final TextEditingController? controller; + final bool filled; + final Widget? placeholder; + // final AlignmentGeometry? placeholderAlignment; + // final AlignmentGeometry? leadingAlignment; + // final AlignmentGeometry? trailingAlignment; + final bool border; + final Widget? leading; + final Widget? trailing; + final EdgeInsetsGeometry? padding; + final ValueChanged? onSubmitted; + final VoidCallback? onEditingComplete; + final FocusNode? focusNode; + final VoidCallback? onTap; + final bool enabled; + final bool readOnly; + final bool obscureText; + final String obscuringCharacter; + final String? initialValue; + final int? maxLength; + final MaxLengthEnforcement? maxLengthEnforcement; + final int? maxLines; + final int? minLines; + final BorderRadiusGeometry? borderRadius; + final TextAlign textAlign; + final bool expands; + final TextAlignVertical? textAlignVertical; + final UndoHistoryController? undoController; + final ValueChanged? onChanged; + final Iterable? autofillHints; + final void Function(PointerDownEvent event)? onTapOutside; + final List? inputFormatters; + final TextStyle? style; + // final EditableTextContextMenuBuilder? contextMenuBuilder; + // final bool useNativeContextMenu; + // final bool? isCollapsed; + final TextInputType? keyboardType; + final TextInputAction? textInputAction; + final Clip clipBehavior; + final bool autofocus; + final WidgetStatesController? statesController; + + const TextFormBuilderField({ + super.key, + required this.name, + this.label, + this.validator, + this.controller, + this.maxLength, + this.maxLengthEnforcement, + this.maxLines = 1, + this.minLines, + this.filled = false, + this.placeholder, + this.border = true, + this.leading, + this.trailing, + this.padding, + this.onSubmitted, + this.onEditingComplete, + this.focusNode, + this.onTap, + this.enabled = true, + this.readOnly = false, + this.obscureText = false, + this.obscuringCharacter = '•', + this.initialValue, + this.borderRadius, + this.keyboardType, + this.textAlign = TextAlign.start, + this.expands = false, + this.textAlignVertical = TextAlignVertical.center, + this.autofillHints, + this.undoController, + this.onChanged, + this.onTapOutside, + this.inputFormatters, + this.style, + // this.contextMenuBuilder = TextField.defaultContextMenuBuilder, + // this.useNativeContextMenu = false, + // this.isCollapsed, + this.textInputAction, + this.clipBehavior = Clip.hardEdge, + this.autofocus = false, + // this.placeholderAlignment, + // this.leadingAlignment, + // this.trailingAlignment, + this.statesController, + }); + + @override + Widget build(BuildContext context) { + return FormBuilderField( + name: name, + validator: validator, + onChanged: (value) { + if (value == null) return; + onChanged?.call(value); + }, + builder: (field) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: 5, + children: [ + if (label != null) + DefaultTextStyle( + style: context.theme.typography.semiBold.copyWith( + color: field.hasError + ? context.theme.colorScheme.destructive + : context.theme.colorScheme.foreground, + ), + child: label!, + ), + TextField( + controller: controller, + maxLength: maxLength, + maxLengthEnforcement: maxLengthEnforcement, + maxLines: maxLines, + minLines: minLines, + filled: filled, + placeholder: placeholder, + border: border, + leading: leading, + trailing: trailing, + padding: padding, + onSubmitted: (value) { + field.validate(); + field.save(); + onSubmitted?.call(value); + }, + onEditingComplete: () { + field.save(); + onEditingComplete?.call(); + }, + focusNode: focusNode, + onTap: onTap, + enabled: enabled, + readOnly: readOnly, + obscureText: obscureText, + obscuringCharacter: obscuringCharacter, + initialValue: field.value, + borderRadius: borderRadius, + textAlign: textAlign, + expands: expands, + textAlignVertical: textAlignVertical, + autofillHints: autofillHints, + undoController: undoController, + onChanged: (value) { + field.didChange(value); + }, + onTapOutside: onTapOutside, + inputFormatters: inputFormatters, + style: style, + // contextMenuBuilder: contextMenuBuilder, + // useNativeContextMenu: useNativeContextMenu, + // isCollapsed: isCollapsed, + keyboardType: keyboardType, + textInputAction: textInputAction, + clipBehavior: clipBehavior, + autofocus: autofocus, + // placeholderAlignment: placeholderAlignment, + // leadingAlignment: leadingAlignment, + // trailingAlignment: trailingAlignment, + statesController: statesController, + ), + if (field.hasError) + Text( + field.errorText ?? "", + style: TextStyle( + color: context.theme.colorScheme.destructive, + ), + ), + ], + ), + ); + } +} diff --git a/lib/components/framework/app_pop_scope.dart b/lib/components/framework/app_pop_scope.dart index b8e35767..fe923958 100644 --- a/lib/components/framework/app_pop_scope.dart +++ b/lib/components/framework/app_pop_scope.dart @@ -1,6 +1,6 @@ import 'dart:io'; -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; /// A temporary workaround for [WillPopScope] and [PopScope] not working in GoRouter /// https://github.com/flutter/flutter/issues/140869#issuecomment-2247181468 diff --git a/lib/components/heart_button/heart_button.dart b/lib/components/heart_button/heart_button.dart index fa4318cc..56cb22ab 100644 --- a/lib/components/heart_button/heart_button.dart +++ b/lib/components/heart_button/heart_button.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/heart_button/use_track_toggle_like.dart'; @@ -13,12 +13,16 @@ class HeartButton extends HookConsumerWidget { final IconData? icon; final Color? color; final String? tooltip; + final ButtonVariance variance; + final ButtonSize size; const HeartButton({ required this.isLiked, required this.onPressed, this.color, this.tooltip, this.icon, + this.variance = ButtonVariance.ghost, + this.size = ButtonSize.normal, super.key, }); @@ -28,28 +32,32 @@ class HeartButton extends HookConsumerWidget { if (auth.asData?.value == null) return const SizedBox.shrink(); - return IconButton( - tooltip: tooltip, - icon: AnimatedSwitcher( - switchInCurve: Curves.fastOutSlowIn, - switchOutCurve: Curves.fastOutSlowIn, - duration: const Duration(milliseconds: 300), - transitionBuilder: (child, animation) { - return ScaleTransition( - scale: animation, - child: child, - ); - }, - child: Icon( - icon ?? - (isLiked - ? Icons.favorite_rounded - : Icons.favorite_outline_rounded), - key: ValueKey(isLiked), - color: color ?? (isLiked ? color ?? Colors.red : null), + return Tooltip( + tooltip: TooltipContainer(child: Text(tooltip ?? "")), + child: IconButton( + variance: variance, + size: size, + icon: AnimatedSwitcher( + switchInCurve: Curves.fastOutSlowIn, + switchOutCurve: Curves.fastOutSlowIn, + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) { + return ScaleTransition( + scale: animation, + child: child, + ); + }, + child: Icon( + icon ?? + (isLiked + ? Icons.favorite_rounded + : Icons.favorite_outline_rounded), + key: ValueKey(isLiked), + color: color ?? (isLiked ? color ?? Colors.red : null), + ), ), + onPressed: onPressed, ), - onPressed: onPressed, ); } } diff --git a/lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart b/lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart index 16204952..47fb0f33 100644 --- a/lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart +++ b/lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart @@ -1,14 +1,14 @@ import 'dart:ui'; -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/modules/album/album_card.dart'; import 'package:spotube/modules/artist/artist_card.dart'; import 'package:spotube/modules/playlist/playlist_card.dart'; -import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; class HorizontalPlaybuttonCardView extends HookWidget { @@ -36,14 +36,9 @@ class HorizontalPlaybuttonCardView extends HookWidget { @override Widget build(BuildContext context) { - final ThemeData(:textTheme) = Theme.of(context); final scrollController = useScrollController(); - final height = useBreakpointValue( - xs: 226, - sm: 226, - md: 236, - others: 266, - ); + final isArtist = items.every((s) => s is Artist); + final scale = context.theme.scaling; return Padding( padding: const EdgeInsets.all(8.0), @@ -54,15 +49,21 @@ class HorizontalPlaybuttonCardView extends HookWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - DefaultTextStyle( - style: textTheme.titleMedium!, - child: title, + Flexible( + child: DefaultTextStyle( + style: context.theme.typography.h4.copyWith( + color: context.theme.colorScheme.foreground, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: title, + ), ), if (titleTrailing != null) titleTrailing!, ], ), SizedBox( - height: height, + height: isArtist ? 250 : 225, child: NotificationListener( // disable multiple scrollbar to use this onNotification: (notification) => true, @@ -86,10 +87,13 @@ class HorizontalPlaybuttonCardView extends HookWidget { onFetchData: onFetchMore, loadingBuilder: (context) => Skeletonizer( enabled: true, - child: AlbumCard(FakeData.albumSimple), + child: isArtist + ? ArtistCard(FakeData.artist) + : AlbumCard(FakeData.albumSimple), ), isLoading: isLoadingNextPage, hasReachedMax: !hasNextPage, + separatorBuilder: (context, index) => Gap(12 * scale), itemBuilder: (context, index) { final item = items[index]; @@ -97,11 +101,7 @@ class HorizontalPlaybuttonCardView extends HookWidget { PlaylistSimple() => PlaylistCard(item as PlaylistSimple), AlbumSimple() => AlbumCard(item as AlbumSimple), - Artist() => Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12.0), - child: ArtistCard(item as Artist), - ), + Artist() => ArtistCard(item as Artist), _ => const SizedBox.shrink(), }; }), diff --git a/lib/components/inter_scrollbar/inter_scrollbar.dart b/lib/components/inter_scrollbar/inter_scrollbar.dart index 8a86b643..415ba6da 100644 --- a/lib/components/inter_scrollbar/inter_scrollbar.dart +++ b/lib/components/inter_scrollbar/inter_scrollbar.dart @@ -1,5 +1,5 @@ import 'package:draggable_scrollbar/draggable_scrollbar.dart'; -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:spotube/utils/platform.dart'; diff --git a/lib/components/links/anchor_button.dart b/lib/components/links/anchor_button.dart index c6f0b889..a0b3fa73 100644 --- a/lib/components/links/anchor_button.dart +++ b/lib/components/links/anchor_button.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; class AnchorButton extends HookWidget { final String text; diff --git a/lib/components/links/artist_link.dart b/lib/components/links/artist_link.dart index 9f06f1b3..9467cb38 100644 --- a/lib/components/links/artist_link.dart +++ b/lib/components/links/artist_link.dart @@ -1,9 +1,9 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/components/links/anchor_button.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/artist/artist.dart'; -import 'package:spotube/utils/service_utils.dart'; class ArtistLink extends StatelessWidget { final List artists; @@ -49,13 +49,8 @@ class ArtistLink extends StatelessWidget { if (onRouteChange != null) { onRouteChange?.call("/artist/${artist.value.id}"); } else { - ServiceUtils.pushNamed( - context, - ArtistPage.name, - pathParameters: { - "id": artist.value.id!, - }, - ); + context + .navigateTo(ArtistRoute(artistId: artist.value.id!)); } }, overflow: TextOverflow.ellipsis, diff --git a/lib/components/links/hyper_link.dart b/lib/components/links/hyper_link.dart index 32d715e0..647edaca 100644 --- a/lib/components/links/hyper_link.dart +++ b/lib/components/links/hyper_link.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/components/links/anchor_button.dart'; import 'package:url_launcher/url_launcher_string.dart'; diff --git a/lib/components/links/link_text.dart b/lib/components/links/link_text.dart index 0cab71d0..c64ae93d 100644 --- a/lib/components/links/link_text.dart +++ b/lib/components/links/link_text.dart @@ -1,15 +1,14 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/components/links/anchor_button.dart'; -import 'package:spotube/utils/service_utils.dart'; class LinkText extends StatelessWidget { final String text; final TextStyle style; final TextAlign? textAlign; final TextOverflow? overflow; - final String route; + final PageRouteInfo route; final int? maxLines; - final T? extra; final bool push; const LinkText( @@ -17,7 +16,6 @@ class LinkText extends StatelessWidget { this.route, { super.key, this.textAlign, - this.extra, this.overflow, this.style = const TextStyle(), this.maxLines, @@ -30,9 +28,9 @@ class LinkText extends StatelessWidget { text, onTap: () { if (push) { - ServiceUtils.push(context, route, extra: extra); + context.navigateTo(route); } else { - ServiceUtils.navigate(context, route, extra: extra); + context.navigateTo(route); } }, key: key, diff --git a/lib/components/panels/controller.dart b/lib/components/panels/controller.dart deleted file mode 100644 index 4e367701..00000000 --- a/lib/components/panels/controller.dart +++ /dev/null @@ -1,146 +0,0 @@ -part of 'sliding_up_panel.dart'; - -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() async { - assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - await _panelState!._close(); - notifyListeners(); - } - - /// Opens the sliding panel fully - /// (i.e. to the maxHeight) - Future open() async { - assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - await _panelState!._open(); - notifyListeners(); - } - - /// Hides the sliding panel (i.e. is invisible) - Future hide() async { - assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - await _panelState!._hide(); - notifyListeners(); - } - - /// Shows the sliding panel in its collapsed state - /// (i.e. "un-hide" the sliding panel) - Future show() async { - assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - await _panelState!._show(); - notifyListeners(); - } - - /// 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/panels/helpers.dart b/lib/components/panels/helpers.dart deleted file mode 100644 index d79fa97c..00000000 --- a/lib/components/panels/helpers.dart +++ /dev/null @@ -1,95 +0,0 @@ -part of "sliding_up_panel.dart"; - -/// 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, super.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/panels/sliding_up_panel.dart b/lib/components/panels/sliding_up_panel.dart deleted file mode 100644 index e99fe261..00000000 --- a/lib/components/panels/sliding_up_panel.dart +++ /dev/null @@ -1,685 +0,0 @@ -/* -Name: Zotov Vladimir -Date: 18/06/22 -Purpose: Defines the package: sliding_up_panel2 -Copyright: © 2022, Zotov Vladimir. All rights reserved. -Licensing: More information can be found here: https://github.com/Zotov-VD/sliding_up_panel/blob/master/LICENSE - -This product includes software developed by Akshath Jain (https://akshathjain.com) -*/ - -library panels; - -import 'dart:math'; - -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/physics.dart'; -import 'package:flutter/rendering.dart'; - -part 'controller.dart'; -part 'helpers.dart'; - -enum SlideDirection { up, down } - -enum PanelState { open, closed } - -class SlidingUpPanel extends StatefulWidget { - /// Returns the Widget that slides into view. When the - /// panel is collapsed and if [collapsed] is null, - /// then top portion of this Widget will be displayed; - /// otherwise, [collapsed] will be displayed overtop - /// of this Widget. - final Widget? Function(double position)? panelBuilder; - - /// The Widget displayed overtop the [panel] when collapsed. - /// This fades out as the panel is opened. - final Widget? collapsed; - - /// The Widget that lies underneath the sliding panel. - /// This Widget automatically sizes itself - /// to fill the screen. - final Widget? body; - - /// Optional persistent widget that floats above the [panel] and attaches - /// to the top of the [panel]. Content at the top of the panel will be covered - /// by this widget. Add padding to the bottom of the `panel` to - /// avoid coverage. - final Widget? header; - - /// Optional persistent widget that floats above the [panel] and - /// attaches to the bottom of the [panel]. Content at the bottom of the panel - /// will be covered by this widget. Add padding to the bottom of the `panel` - /// to avoid coverage. - final Widget? footer; - - /// The height of the sliding panel when fully collapsed. - final double minHeight; - - /// The height of the sliding panel when fully open. - final double maxHeight; - - /// A point between [minHeight] and [maxHeight] that the panel snaps to - /// while animating. A fast swipe on the panel will disregard this point - /// and go directly to the open/close position. This value is represented as a - /// percentage of the total animation distance ([maxHeight] - [minHeight]), - /// so it must be between 0.0 and 1.0, exclusive. - final double? snapPoint; - - /// The amount to inset the children of the sliding panel sheet. - final EdgeInsetsGeometry? padding; - - /// Empty space surrounding the sliding panel sheet. - final EdgeInsetsGeometry? margin; - - /// Set to false to disable the panel from snapping open or closed. - final bool panelSnapping; - - /// Disable panel draggable on scrolling. Defaults to false. - final bool disableDraggableOnScrolling; - - /// If non-null, this can be used to control the state of the panel. - final PanelController? controller; - - /// If non-null, shows a darkening shadow over the [body] as the panel slides open. - final bool backdropEnabled; - - /// Shows a darkening shadow of this [Color] over the [body] as the panel slides open. - final Color backdropColor; - - /// The opacity of the backdrop when the panel is fully open. - /// This value can range from 0.0 to 1.0 where 0.0 is completely transparent - /// and 1.0 is completely opaque. - final double backdropOpacity; - - /// Flag that indicates whether or not tapping the - /// backdrop closes the panel. Defaults to true. - final bool backdropTapClosesPanel; - - /// If non-null, this callback - /// is called as the panel slides around with the - /// current position of the panel. The position is a double - /// between 0.0 and 1.0 where 0.0 is fully collapsed and 1.0 is fully open. - final void Function(double position)? onPanelSlide; - - /// If non-null, this callback is called when the - /// panel is fully opened - final VoidCallback? onPanelOpened; - - /// If non-null, this callback is called when the panel - /// is fully collapsed. - final VoidCallback? onPanelClosed; - - /// If non-null and true, the SlidingUpPanel exhibits a - /// parallax effect as the panel slides up. Essentially, - /// the body slides up as the panel slides up. - final bool parallaxEnabled; - - /// Allows for specifying the extent of the parallax effect in terms - /// of the percentage the panel has slid up/down. Recommended values are - /// within 0.0 and 1.0 where 0.0 is no parallax and 1.0 mimics a - /// one-to-one scrolling effect. Defaults to a 10% parallax. - final double parallaxOffset; - - /// Allows toggling of the draggability of the SlidingUpPanel. - /// Set this to false to prevent the user from being able to drag - /// the panel up and down. Defaults to true. - final bool isDraggable; - - /// Either SlideDirection.UP or SlideDirection.DOWN. Indicates which way - /// the panel should slide. Defaults to UP. If set to DOWN, the panel attaches - /// itself to the top of the screen and is fully opened when the user swipes - /// down on the panel. - final SlideDirection slideDirection; - - /// The default state of the panel; either PanelState.OPEN or PanelState.CLOSED. - /// This value defaults to PanelState.CLOSED which indicates that the panel is - /// in the closed position and must be opened. PanelState.OPEN indicates that - /// by default the Panel is open and must be swiped closed by the user. - final PanelState defaultPanelState; - - /// To attach to a [Scrollable] on a panel that - /// links the panel's position to the scroll position. Useful for implementing - /// infinite scroll behavior - final ScrollController? scrollController; - - final BoxDecoration? panelDecoration; - - const SlidingUpPanel( - {super.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); - - @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/playbutton_card.dart b/lib/components/playbutton_card.dart deleted file mode 100644 index ae9050d8..00000000 --- a/lib/components/playbutton_card.dart +++ /dev/null @@ -1,220 +0,0 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/hover_builder.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/string.dart'; -import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; -import 'package:spotube/hooks/utils/use_brightness_value.dart'; - -class PlaybuttonCard extends HookWidget { - final void Function()? onTap; - final void Function()? onPlaybuttonPressed; - final void Function()? onAddToQueuePressed; - final String? description; - final EdgeInsetsGeometry? margin; - final String imageUrl; - final bool isPlaying; - final bool isLoading; - final String title; - final bool isOwner; - - const PlaybuttonCard({ - required this.imageUrl, - required this.isPlaying, - required this.isLoading, - required this.title, - this.margin, - this.description, - this.onPlaybuttonPressed, - this.onAddToQueuePressed, - this.onTap, - this.isOwner = false, - super.key, - }); - - @override - Widget build(BuildContext context) { - final textsKey = useMemoized(() => GlobalKey(), []); - final theme = Theme.of(context); - final mediaQuery = MediaQuery.of(context); - final radius = BorderRadius.circular(15); - - final double size = useBreakpointValue( - xs: 130, - sm: 130, - md: 150, - others: 170, - ); - - final end = useBreakpointValue( - xs: 7, - sm: 7, - others: 15, - ); - - final unescapeHtml = description?.unescapeHtml().cleanHtml(); - return Container( - constraints: BoxConstraints(maxWidth: size), - margin: margin, - child: Material( - color: Color.lerp( - theme.colorScheme.surfaceContainerHighest, - theme.colorScheme.surface, - useBrightnessValue(.9, .7), - ), - borderRadius: radius, - shadowColor: theme.colorScheme.surface, - elevation: 3, - child: InkWell( - mouseCursor: SystemMouseCursors.click, - onTap: onTap, - borderRadius: radius, - splashFactory: theme.splashFactory, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Stack( - clipBehavior: Clip.none, - children: [ - Container( - margin: const EdgeInsets.fromLTRB(8, 8, 8, 0), - padding: const EdgeInsets.only( - left: 8, - right: 8, - top: 8, - ), - height: mediaQuery.smAndDown - ? 120 - : mediaQuery.mdAndDown - ? 130 - : 150, - decoration: BoxDecoration( - borderRadius: radius, - image: DecorationImage( - image: UniversalImage.imageProvider(imageUrl), - fit: BoxFit.cover, - ), - ), - ), - if (isOwner) - Positioned( - top: 15, - left: 15, - child: AnimatedSize( - duration: const Duration(milliseconds: 150), - alignment: Alignment.centerLeft, - curve: Curves.easeInExpo, - child: HoverBuilder(builder: (context, isHovered) { - return Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.blueAccent, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - SpotubeIcons.user, - color: Colors.white, - size: 16, - ), - if (isHovered) - Text( - context.l10n.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.surface, - foregroundColor: theme.colorScheme.primary, - minimumSize: const Size.square(10), - ), - icon: const Icon(SpotubeIcons.queueAdd), - onPressed: isLoading ? null : onAddToQueuePressed, - ), - ), - const Gap(5), - IconButton( - style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.primaryContainer, - foregroundColor: theme.colorScheme.primary, - minimumSize: const Size.square(10), - ), - icon: Skeleton.keep( - child: isLoading - ? SizedBox.fromSize( - size: const Size.square(15), - child: const CircularProgressIndicator( - strokeWidth: 2), - ) - : isPlaying - ? const Icon(SpotubeIcons.pause) - : const Icon(SpotubeIcons.play), - ), - onPressed: isLoading ? null : onPlaybuttonPressed, - ), - ], - ), - ), - ], - ), - Column( - key: textsKey, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 15), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: AutoSizeText( - title, - maxLines: 1, - minFontSize: theme.textTheme.bodyMedium!.fontSize!, - overflow: TextOverflow.ellipsis, - ), - ), - if (description != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: AutoSizeText( - unescapeHtml!, - 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/playbutton_view/playbutton_card.dart b/lib/components/playbutton_view/playbutton_card.dart new file mode 100644 index 00000000..0b47ae28 --- /dev/null +++ b/lib/components/playbutton_view/playbutton_card.dart @@ -0,0 +1,170 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/extensions/string.dart'; +import 'package:spotube/utils/platform.dart'; + +class PlaybuttonCard extends StatelessWidget { + final void Function()? onTap; + final void Function()? onPlaybuttonPressed; + final void Function()? onAddToQueuePressed; + final String? description; + + final String? imageUrl; + final Widget? image; + final bool isPlaying; + final bool isLoading; + final String title; + final bool isOwner; + + const PlaybuttonCard({ + required this.isPlaying, + required this.isLoading, + required this.title, + this.description, + this.onPlaybuttonPressed, + this.onAddToQueuePressed, + this.onTap, + this.isOwner = false, + this.imageUrl, + this.image, + super.key, + }) : assert( + imageUrl != null || image != null, + "imageUrl and image can't be null at the same time", + ); + + @override + Widget build(BuildContext context) { + final unescapeHtml = description?.unescapeHtml().cleanHtml() ?? ""; + final scale = context.theme.scaling; + + return SizedBox( + width: 150 * scale, + child: CardImage( + image: Stack( + children: [ + if (imageUrl != null) + Container( + width: 150 * scale, + height: 150 * scale, + decoration: BoxDecoration( + borderRadius: context.theme.borderRadiusMd, + image: DecorationImage( + image: UniversalImage.imageProvider( + imageUrl!, + height: 200 * scale, + width: 200 * scale, + ), + fit: BoxFit.cover, + ), + ), + ) + else + SizedBox( + width: 150 * scale, + height: 150 * scale, + child: ClipRRect( + borderRadius: context.theme.borderRadiusMd, + child: image!, + ), + ), + StatedWidget.builder( + builder: (context, states) { + return Positioned( + right: 8, + bottom: 8, + child: Column( + children: [ + AnimatedScale( + curve: Curves.easeOutBack, + duration: const Duration(milliseconds: 300), + scale: (states.contains(WidgetState.hovered) || + kIsMobile) && + !isLoading + ? 1 + : 0.7, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: (states.contains(WidgetState.hovered) || + kIsMobile) && + !isLoading + ? 1 + : 0, + child: IconButton.secondary( + icon: const Icon(SpotubeIcons.queueAdd), + onPressed: onAddToQueuePressed, + size: ButtonSize.small, + ), + ), + ), + const Gap(5), + AnimatedScale( + curve: Curves.easeOutBack, + duration: const Duration(milliseconds: 150), + scale: states.contains(WidgetState.hovered) || + kIsMobile || + isPlaying || + isLoading + ? 1 + : 0.7, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: states.contains(WidgetState.hovered) || + kIsMobile || + isPlaying || + isLoading + ? 1 + : 0, + child: IconButton.secondary( + icon: switch ((isLoading, isPlaying)) { + (true, _) => const CircularProgressIndicator( + size: 15, + ), + (false, false) => const Icon(SpotubeIcons.play), + (false, true) => const Icon(SpotubeIcons.pause) + }, + enabled: !isLoading, + onPressed: onPlaybuttonPressed, + size: ButtonSize.small, + ), + ), + ), + ], + ), + ); + }, + ), + if (isOwner) + const Positioned( + right: 5, + top: 5, + child: SecondaryBadge( + style: ButtonStyle.secondaryIcon( + shape: ButtonShape.circle, + size: ButtonSize.small, + ), + child: Icon(SpotubeIcons.user), + ), + ), + ], + ), + title: Tooltip( + tooltip: TooltipContainer(child: Text(title)), + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + subtitle: Text( + unescapeHtml.isEmpty ? "\n" : unescapeHtml, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + onPressed: onTap, + ), + ); + } +} diff --git a/lib/components/playbutton_view/playbutton_tile.dart b/lib/components/playbutton_view/playbutton_tile.dart new file mode 100644 index 00000000..ec1ca95f --- /dev/null +++ b/lib/components/playbutton_view/playbutton_tile.dart @@ -0,0 +1,115 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/string.dart'; + +class PlaybuttonTile extends StatelessWidget { + final void Function()? onTap; + final void Function()? onPlaybuttonPressed; + final void Function()? onAddToQueuePressed; + final String? description; + + final String? imageUrl; + final Widget? image; + final bool isPlaying; + final bool isLoading; + final String title; + final bool isOwner; + + const PlaybuttonTile({ + required this.isPlaying, + required this.isLoading, + required this.title, + this.description, + this.onPlaybuttonPressed, + this.onAddToQueuePressed, + this.onTap, + this.isOwner = false, + this.imageUrl, + this.image, + super.key, + }) : assert( + imageUrl != null || image != null, + "imageUrl and image can't be null at the same time", + ); + + @override + Widget build(BuildContext context) { + final cleanDescription = description?.unescapeHtml().cleanHtml() ?? ""; + final scale = context.theme.scaling; + + return Button( + leading: imageUrl != null + ? Container( + width: 50 * scale, + height: 50 * scale, + decoration: BoxDecoration( + borderRadius: context.theme.borderRadiusMd, + image: DecorationImage( + image: UniversalImage.imageProvider(imageUrl!), + fit: BoxFit.cover, + ), + ), + ) + : SizedBox( + width: 50 * scale, + height: 50 * scale, + child: ClipRRect( + borderRadius: context.theme.borderRadiusMd, + child: image, + ), + ), + style: ButtonVariance.ghost.copyWith( + padding: (context, states, value) { + return (ButtonVariance.ghost.padding(context, states) as EdgeInsets) + .copyWith(right: 0, left: 0); + }, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Tooltip( + tooltip: TooltipContainer(child: Text(context.l10n.add_to_queue)), + child: IconButton.outline( + icon: const Icon(SpotubeIcons.queueAdd), + onPressed: onAddToQueuePressed, + enabled: !isLoading, + ), + ), + const Gap(8), + Tooltip( + tooltip: TooltipContainer(child: Text(context.l10n.play)), + child: IconButton.secondary( + icon: switch ((isLoading, isPlaying)) { + (true, _) => const CircularProgressIndicator( + size: 22, + ), + (false, false) => const Icon(SpotubeIcons.play), + (false, true) => const Icon(SpotubeIcons.pause) + }, + onPressed: onPlaybuttonPressed, + enabled: !isLoading, + ), + ), + ], + ), + enabled: !isLoading, + onPressed: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title), + if (cleanDescription.isNotEmpty) + Text( + description!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ).xSmall().muted(), + ], + ), + ); + } +} diff --git a/lib/components/playbutton_view/playbutton_view.dart b/lib/components/playbutton_view/playbutton_view.dart new file mode 100644 index 00000000..7880bb8c --- /dev/null +++ b/lib/components/playbutton_view/playbutton_view.dart @@ -0,0 +1,204 @@ +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_undraw/flutter_undraw.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/playbutton_view/playbutton_card.dart'; +import 'package:spotube/components/playbutton_view/playbutton_tile.dart'; +import 'package:spotube/components/waypoint.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; + +const _dummyPlaybuttonCard = PlaybuttonCard( + imageUrl: 'https://placehold.co/150x150.png', + isLoading: false, + isPlaying: false, + title: "Playbutton", + description: "A really cool playbutton", + isOwner: false, +); + +const _dummyPlaybuttonTile = PlaybuttonTile( + imageUrl: 'https://placehold.co/150x150.png', + isLoading: false, + isPlaying: false, + title: "Playbutton", + description: "A really cool playbutton", + isOwner: false, +); + +/// A [PlaybuttonCard] grid/list view (selectable) sliver widget +/// with support for infinite scrolling +class PlaybuttonView extends StatelessWidget { + final int itemCount; + final Widget Function(BuildContext context, int index) gridItemBuilder; + final Widget Function(BuildContext context, int index) listItemBuilder; + final bool hasMore; + final bool isLoading; + final VoidCallback onRequestMore; + final ScrollController controller; + + final Widget? leading; + + const PlaybuttonView({ + super.key, + required this.itemCount, + required this.gridItemBuilder, + required this.listItemBuilder, + required this.hasMore, + required this.isLoading, + required this.onRequestMore, + required this.controller, + this.leading, + }); + + @override + Widget build(BuildContext context) { + final scale = context.theme.scaling; + + return SliverLayoutBuilder( + builder: (context, constrains) => HookBuilder(builder: (context) { + final isGrid = useState(constrains.mdAndUp); + final hasUserInteracted = useRef(false); + + useEffect(() { + if (hasUserInteracted.value) return null; + if (isGrid.value != constrains.mdAndUp) { + isGrid.value = constrains.mdAndUp; + } + return null; + }, [constrains]); + + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (leading != null) leading!, + Toggle( + value: isGrid.value, + style: + const ButtonStyle.outline(density: ButtonDensity.icon), + onChanged: (value) { + isGrid.value = value; + hasUserInteracted.value = true; + }, + child: const Icon(SpotubeIcons.grid), + ), + const SizedBox(width: 8), + Toggle( + value: !isGrid.value, + style: + const ButtonStyle.outline(density: ButtonDensity.icon), + onChanged: (value) { + isGrid.value = !value; + hasUserInteracted.value = true; + }, + child: const Icon(SpotubeIcons.list), + ), + ], + ), + ), + const SliverGap(10), + // Toggle between grid and list view + switch ((isGrid.value, isLoading)) { + (true, _) => !isLoading && itemCount == 0 + ? SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8), + sliver: SliverToBoxAdapter( + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 10, + children: [ + Undraw( + height: 200 * context.theme.scaling, + illustration: UndrawIllustration.taken, + color: Theme.of(context).colorScheme.primary, + ), + Text( + context.l10n.nothing_found, + textAlign: TextAlign.center, + ).muted().small() + ], + ), + ), + ) + : SliverGrid.builder( + itemCount: isLoading ? 6 : itemCount + 1, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 150 * scale, + mainAxisExtent: 225 * scale, + crossAxisSpacing: 12 * scale, + mainAxisSpacing: 12 * scale, + ), + itemBuilder: (context, index) { + if (isLoading) { + return const Skeletonizer( + enabled: true, + child: _dummyPlaybuttonCard, + ); + } + + if (index == itemCount) { + if (!hasMore) return const SizedBox.shrink(); + return Waypoint( + controller: controller, + isGrid: true, + onTouchEdge: onRequestMore, + child: const Skeletonizer( + enabled: true, + child: _dummyPlaybuttonCard, + ), + ); + } + + return gridItemBuilder(context, index); + }, + ), + (false, true) => Skeletonizer.sliver( + enabled: true, + child: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _dummyPlaybuttonTile, + childCount: 6, + ), + ), + ), + (false, false) => SliverInfiniteList( + itemCount: itemCount, + loadingBuilder: (context) => const Skeletonizer( + enabled: true, + child: _dummyPlaybuttonTile, + ), + itemBuilder: listItemBuilder, + onFetchData: onRequestMore, + hasReachedMax: !hasMore, + isLoading: isLoading, + emptyBuilder: (context) { + return Column( + mainAxisSize: MainAxisSize.min, + spacing: 10, + children: [ + Undraw( + height: 200 * context.theme.scaling, + illustration: UndrawIllustration.taken, + color: Theme.of(context).colorScheme.primary, + ), + Text( + context.l10n.nothing_found, + textAlign: TextAlign.center, + ).muted().small() + ], + ); + }, + ), + } + ], + ); + }), + ); + } +} diff --git a/lib/components/shimmers/shimmer_lyrics.dart b/lib/components/shimmers/shimmer_lyrics.dart index 03816202..f8d29722 100644 --- a/lib/components/shimmers/shimmer_lyrics.dart +++ b/lib/components/shimmers/shimmer_lyrics.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; diff --git a/lib/components/sort_tracks_dropdown.dart b/lib/components/sort_tracks_dropdown.dart deleted file mode 100644 index 16727013..00000000 --- a/lib/components/sort_tracks_dropdown.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/modules/library/user_local_tracks.dart'; -import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; -import 'package:spotube/extensions/context.dart'; - -class SortTracksDropdown extends StatelessWidget { - final SortBy? value; - final void Function(SortBy)? onChanged; - const SortTracksDropdown({ - this.onChanged, - this.value, - super.key, - }); - - @override - Widget build(BuildContext context) { - var theme = Theme.of(context); - return ListTileTheme( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - child: AdaptivePopSheetList( - children: [ - PopSheetEntry( - value: SortBy.none, - enabled: value != SortBy.none, - title: Text(context.l10n.none), - ), - PopSheetEntry( - value: SortBy.ascending, - enabled: value != SortBy.ascending, - title: Text(context.l10n.sort_a_z), - ), - PopSheetEntry( - value: SortBy.descending, - enabled: value != SortBy.descending, - title: Text(context.l10n.sort_z_a), - ), - PopSheetEntry( - value: SortBy.newest, - enabled: value != SortBy.newest, - title: Text(context.l10n.sort_newest), - ), - PopSheetEntry( - value: SortBy.oldest, - enabled: value != SortBy.oldest, - title: Text(context.l10n.sort_oldest), - ), - PopSheetEntry( - value: SortBy.duration, - enabled: value != SortBy.duration, - title: Text(context.l10n.sort_duration), - ), - PopSheetEntry( - value: SortBy.artist, - enabled: value != SortBy.artist, - title: Text(context.l10n.sort_artist), - ), - PopSheetEntry( - value: SortBy.album, - enabled: value != SortBy.album, - title: Text(context.l10n.sort_album), - ), - ], - headings: [ - Text(context.l10n.sort_tracks), - ], - onSelected: onChanged, - tooltip: context.l10n.sort_tracks, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - child: DefaultTextStyle( - style: theme.textTheme.titleSmall!, - child: Row( - children: [ - const Icon(SpotubeIcons.sort), - const SizedBox(width: 8), - Text(context.l10n.sort_tracks), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/components/spotube_page_route.dart b/lib/components/spotube_page_route.dart index 22e4d2f1..cff32975 100644 --- a/lib/components/spotube_page_route.dart +++ b/lib/components/spotube_page_route.dart @@ -1,25 +1,24 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; class SpotubePage extends MaterialPage { const SpotubePage({required super.child}); } -class SpotubeSlidePage extends CustomTransitionPage { - SpotubeSlidePage({ - required super.child, - super.key, - }) : super( - reverseTransitionDuration: const Duration(milliseconds: 150), - transitionDuration: const Duration(milliseconds: 150), - transitionsBuilder: (context, animation, secondaryAnimation, child) { - return SlideTransition( - position: Tween( - begin: const Offset(1, 0), - end: Offset.zero, - ).animate(animation), - child: child, - ); - }, - ); -} +// class SpotubeSlidePage extends CustomTransitionPage { +// SpotubeSlidePage({ +// required super.child, +// super.key, +// }) : super( +// reverseTransitionDuration: const Duration(milliseconds: 150), +// transitionDuration: const Duration(milliseconds: 150), +// transitionsBuilder: (context, animation, secondaryAnimation, child) { +// return SlideTransition( +// position: Tween( +// begin: const Offset(1, 0), +// end: Offset.zero, +// ).animate(animation), +// child: child, +// ); +// }, +// ); +// } diff --git a/lib/components/themed_button_tab_bar.dart b/lib/components/themed_button_tab_bar.dart deleted file mode 100644 index c245e5f4..00000000 --- a/lib/components/themed_button_tab_bar.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:buttons_tabbar/buttons_tabbar.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:spotube/hooks/utils/use_brightness_value.dart'; - -class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { - final List tabs; - final TabController? controller; - const ThemedButtonsTabBar({super.key, required this.tabs, this.controller}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final bgColor = useBrightnessValue( - theme.colorScheme.primaryContainer, - Color.lerp(theme.colorScheme.primary, Colors.black, 0.7)!, - ); - - return Padding( - padding: const EdgeInsets.only( - top: 8, - bottom: 8, - ), - child: ButtonsTabBar( - controller: controller, - radius: 100, - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(15), - ), - labelStyle: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - ), - borderWidth: 0, - unselectedDecoration: BoxDecoration( - color: theme.colorScheme.surface, - borderRadius: BorderRadius.circular(15), - ), - unselectedLabelStyle: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.primary, - ), - tabs: tabs, - ), - ); - } - - @override - Size get preferredSize => const Size.fromHeight(50); -} diff --git a/lib/components/titlebar/mouse_state.dart b/lib/components/titlebar/mouse_state.dart deleted file mode 100644 index 9af2a8b0..00000000 --- a/lib/components/titlebar/mouse_state.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; - -typedef MouseStateBuilderCB = Widget Function( - BuildContext context, MouseState mouseState); - -class MouseState { - bool isMouseOver = false; - bool isMouseDown = false; - MouseState(); - @override - String toString() { - return "isMouseDown: $isMouseDown - isMouseOver: $isMouseOver"; - } -} - -T? _ambiguate(T? value) => value; - -class MouseStateBuilder extends StatefulWidget { - final MouseStateBuilderCB builder; - final VoidCallback? onPressed; - const MouseStateBuilder({super.key, required this.builder, this.onPressed}); - @override - // ignore: library_private_types_in_public_api - _MouseStateBuilderState createState() => _MouseStateBuilderState(); -} - -class _MouseStateBuilderState extends State { - late MouseState _mouseState; - _MouseStateBuilderState() { - _mouseState = MouseState(); - } - - @override - Widget build(BuildContext context) { - return MouseRegion( - onEnter: (event) { - setState(() { - _mouseState.isMouseOver = true; - }); - }, - onExit: (event) { - setState(() { - _mouseState.isMouseOver = false; - }); - }, - child: GestureDetector( - onTapDown: (_) { - setState(() { - _mouseState.isMouseDown = true; - }); - }, - onTapCancel: () { - setState(() { - _mouseState.isMouseDown = false; - }); - }, - onTap: () { - setState(() { - _mouseState.isMouseDown = false; - _mouseState.isMouseOver = false; - }); - _ambiguate(WidgetsBinding.instance)!.addPostFrameCallback((_) { - if (widget.onPressed != null) { - widget.onPressed!(); - } - }); - }, - onTapUp: (_) {}, - child: widget.builder(context, _mouseState), - ), - ); - } -} diff --git a/lib/components/titlebar/titlebar.dart b/lib/components/titlebar/titlebar.dart index 76a5ec8a..778f0b09 100644 --- a/lib/components/titlebar/titlebar.dart +++ b/lib/components/titlebar/titlebar.dart @@ -1,88 +1,60 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotube/components/button/back_button.dart'; import 'package:spotube/components/titlebar/titlebar_buttons.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/utils/platform.dart'; - import 'package:window_manager/window_manager.dart'; -class PageWindowTitleBar extends StatefulHookConsumerWidget - implements PreferredSizeWidget { - final Widget? leading; +final kTitlebarVisible = kIsWindows || kIsLinux; + +class TitleBar extends HookConsumerWidget implements PreferredSizeWidget { final bool automaticallyImplyLeading; - final List? actions; + final List trailing; + final List leading; + final Widget? child; + final Widget? title; + final Widget? header; // small widget placed on top of title + final Widget? subtitle; // small widget placed below title + final bool + trailingExpanded; // expand the trailing instead of the main content + final AlignmentGeometry alignment; final Color? backgroundColor; final Color? foregroundColor; - final IconThemeData? actionsIconTheme; - final bool? centerTitle; - final double? titleSpacing; - final double toolbarOpacity; - final double? leadingWidth; - final TextStyle? toolbarTextStyle; - final TextStyle? titleTextStyle; - final double? titleWidth; - final Widget? title; + final double? leadingGap; + final double? trailingGap; + final EdgeInsetsGeometry? padding; + final double? height; + final bool useSafeArea; + final double? surfaceBlur; + final double? surfaceOpacity; - final bool _sliver; - - const PageWindowTitleBar({ + const TitleBar({ super.key, - this.actions, + this.automaticallyImplyLeading = true, + this.trailing = const [], + this.leading = const [], this.title, - this.toolbarOpacity = 1, + this.header, + this.subtitle, + this.child, + this.trailingExpanded = false, + this.alignment = Alignment.center, + this.padding, this.backgroundColor, - this.actionsIconTheme, - this.automaticallyImplyLeading = false, - this.centerTitle, this.foregroundColor, - this.leading, - this.leadingWidth, - this.titleSpacing, - this.titleTextStyle, - this.titleWidth, - this.toolbarTextStyle, - }) : _sliver = false, - pinned = false, - floating = false, - snap = false, - stretch = false; + this.leadingGap, + this.trailingGap, + this.height, + this.surfaceBlur, + this.surfaceOpacity, + this.useSafeArea = false, + }); - final bool pinned; - final bool floating; - final bool snap; - final bool stretch; - - const PageWindowTitleBar.sliver({ - super.key, - this.actions, - this.title, - this.backgroundColor, - this.actionsIconTheme, - this.automaticallyImplyLeading = false, - this.centerTitle, - this.foregroundColor, - this.leading, - this.leadingWidth, - this.titleSpacing, - this.titleTextStyle, - this.titleWidth, - this.toolbarTextStyle, - this.pinned = false, - this.floating = false, - this.snap = false, - this.stretch = false, - }) : _sliver = true, - toolbarOpacity = 1; - - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); - - @override - ConsumerState createState() => _PageWindowTitleBarState(); -} - -class _PageWindowTitleBarState extends ConsumerState { - void onDrag(details) { + void onDrag(WidgetRef ref) { final systemTitleBar = ref.read(userPreferencesProvider.select((s) => s.systemTitleBar)); if (kIsDesktop && !systemTitleBar) { @@ -91,89 +63,73 @@ class _PageWindowTitleBarState extends ConsumerState { } @override - Widget build(BuildContext context) { - final mediaQuery = MediaQuery.of(context); + Widget build(BuildContext context, ref) { + final hasLeadingOrCanPop = leading.isNotEmpty || Navigator.canPop(context); + final lastClicked = useRef(DateTime.now().millisecondsSinceEpoch); - if (widget._sliver) { - return SliverLayoutBuilder( + return SizedBox( + height: height ?? (48 * context.theme.scaling), + child: LayoutBuilder( builder: (context, constraints) { final hasFullscreen = - mediaQuery.size.width == constraints.crossAxisExtent; - final hasLeadingOrCanPop = - widget.leading != null || Navigator.canPop(context); + MediaQuery.sizeOf(context).width == constraints.maxWidth; - return SliverPadding( - padding: EdgeInsets.only( - left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0, - ), - sliver: SliverAppBar( - leading: widget.leading, - automaticallyImplyLeading: widget.automaticallyImplyLeading, - actions: [ - ...?widget.actions, - WindowTitleBarButtons(foregroundColor: widget.foregroundColor), + final canPop = leading.isEmpty && + automaticallyImplyLeading && + (Navigator.canPop(context) || context.watchRouter.canPop()); + + return GestureDetector( + onHorizontalDragStart: (_) => onDrag(ref), + onVerticalDragStart: (_) => onDrag(ref), + onTapDown: (details) async { + final systemTitlebar = ref.read( + userPreferencesProvider.select((s) => s.systemTitleBar)); + if (!kIsDesktop || systemTitlebar) return; + + int currMills = DateTime.now().millisecondsSinceEpoch; + + if ((currMills - lastClicked.value) < 500) { + if (await windowManager.isMaximized()) { + await windowManager.unmaximize(); + } else { + await windowManager.maximize(); + } + } else { + lastClicked.value = currMills; + } + }, + child: AppBar( + leading: canPop ? [const BackButton()] : leading, + trailing: [ + ...trailing, + Align( + alignment: Alignment.topRight, + child: + WindowTitleBarButtons(foregroundColor: foregroundColor), + ), ], - backgroundColor: widget.backgroundColor, - foregroundColor: widget.foregroundColor, - actionsIconTheme: widget.actionsIconTheme, - centerTitle: widget.centerTitle, - titleSpacing: widget.titleSpacing, - leadingWidth: widget.leadingWidth, - toolbarTextStyle: widget.toolbarTextStyle, - titleTextStyle: widget.titleTextStyle, - title: SizedBox( - width: double.infinity, // workaround to force dragging - child: widget.title ?? const Text(""), - ), - pinned: widget.pinned, - floating: widget.floating, - snap: widget.snap, - stretch: widget.stretch, - ), + title: title, + header: header, + subtitle: subtitle, + trailingExpanded: trailingExpanded, + alignment: alignment, + padding: padding ?? EdgeInsets.zero, + backgroundColor: backgroundColor, + leadingGap: leadingGap, + trailingGap: trailingGap, + height: height ?? (48 * context.theme.scaling), + surfaceBlur: surfaceBlur, + surfaceOpacity: surfaceOpacity, + useSafeArea: useSafeArea, + child: child, + ).withPadding( + left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0), ); }, - ); - } - - return LayoutBuilder(builder: (context, constrains) { - final hasFullscreen = mediaQuery.size.width == constrains.maxWidth; - final hasLeadingOrCanPop = - widget.leading != null || Navigator.canPop(context); - - return GestureDetector( - onHorizontalDragStart: onDrag, - onVerticalDragStart: onDrag, - child: Padding( - padding: EdgeInsets.only( - left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0, - ), - child: AppBar( - leading: widget.leading, - automaticallyImplyLeading: widget.automaticallyImplyLeading, - actions: [ - ...?widget.actions, - WindowTitleBarButtons(foregroundColor: widget.foregroundColor), - ], - backgroundColor: widget.backgroundColor, - foregroundColor: widget.foregroundColor, - actionsIconTheme: widget.actionsIconTheme, - centerTitle: widget.centerTitle, - titleSpacing: widget.titleSpacing, - toolbarOpacity: widget.toolbarOpacity, - leadingWidth: widget.leadingWidth, - toolbarTextStyle: widget.toolbarTextStyle, - titleTextStyle: widget.titleTextStyle, - title: SizedBox( - width: double.infinity, // workaround to force dragging - child: widget.title ?? const Text(""), - ), - scrolledUnderElevation: 0, - shadowColor: Colors.transparent, - forceMaterialTransparency: true, - elevation: 0, - ), - ), - ); - }); + ), + ); } + + @override + Size get preferredSize => Size.fromHeight(height ?? 48); } diff --git a/lib/components/titlebar/titlebar_buttons.dart b/lib/components/titlebar/titlebar_buttons.dart index 35cdf08e..30d88508 100644 --- a/lib/components/titlebar/titlebar_buttons.dart +++ b/lib/components/titlebar/titlebar_buttons.dart @@ -1,8 +1,12 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotube/components/hover_builder.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/titlebar/titlebar_icon_buttons.dart'; -import 'package:spotube/components/titlebar/window_button.dart'; + +import 'package:spotube/hooks/configurators/use_window_listener.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/utils/platform.dart'; import 'package:titlebar_buttons/titlebar_buttons.dart'; @@ -25,6 +29,15 @@ class WindowTitleBarButtons extends HookConsumerWidget { await windowManager.close(); } + useWindowListener( + onWindowMaximize: () { + isMaximized.value = true; + }, + onWindowUnmaximize: () { + isMaximized.value = false; + }, + ); + useEffect(() { if (kIsDesktop) { windowManager.isMaximized().then((value) { @@ -34,91 +47,73 @@ class WindowTitleBarButtons extends HookConsumerWidget { return null; }, []); - if (!kIsDesktop || kIsMacOS || preferences.systemTitleBar) { + if (!kTitlebarVisible || preferences.systemTitleBar) { return const SizedBox.shrink(); } if (kIsWindows) { - final theme = Theme.of(context); - final colors = WindowButtonColors( - normal: Colors.transparent, - iconNormal: foregroundColor ?? theme.colorScheme.onSurface, - mouseOver: theme.colorScheme.onSurface.withOpacity(0.1), - mouseDown: theme.colorScheme.onSurface.withOpacity(0.2), - iconMouseOver: theme.colorScheme.onSurface, - iconMouseDown: theme.colorScheme.onSurface, - ); - - final closeColors = WindowButtonColors( - normal: Colors.transparent, - iconNormal: foregroundColor ?? theme.colorScheme.onSurface, - mouseOver: Colors.red, - mouseDown: Colors.red[800]!, - iconMouseOver: Colors.white, - iconMouseDown: Colors.black, - ); - - return Padding( - padding: const EdgeInsets.only(bottom: 25), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MinimizeWindowButton( - onPressed: windowManager.minimize, - colors: colors, + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadcnWindowButton( + icon: MinimizeIcon(color: context.theme.colorScheme.foreground), + onPressed: windowManager.minimize, + ), + if (isMaximized.value != true) + ShadcnWindowButton( + icon: MaximizeIcon(color: context.theme.colorScheme.foreground), + onPressed: () { + windowManager.maximize(); + isMaximized.value = true; + }, + ) + else + ShadcnWindowButton( + icon: RestoreIcon(color: context.theme.colorScheme.foreground), + onPressed: () { + windowManager.unmaximize(); + isMaximized.value = false; + }, ), - if (isMaximized.value != true) - MaximizeWindowButton( - colors: colors, - onPressed: () { - windowManager.maximize(); - isMaximized.value = true; - }, - ) - else - RestoreWindowButton( - colors: colors, - onPressed: () { - windowManager.unmaximize(); - isMaximized.value = false; - }, + HoverBuilder(builder: (context, isHovered) { + return ShadcnWindowButton( + icon: CloseIcon( + color: isHovered + ? Colors.white + : context.theme.colorScheme.foreground, ), - CloseWindowButton( - colors: closeColors, onPressed: onClose, - ), - ], - ), + hoverBackgroundColor: const Color(0xFFD32F2F), + ); + }), + ], ); } - return Padding( - padding: const EdgeInsets.only(bottom: 20, left: 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DecoratedMinimizeButton( - type: type, - onPressed: windowManager.minimize, - ), - DecoratedMaximizeButton( - type: type, - onPressed: () async { - if (await windowManager.isMaximized()) { - await windowManager.unmaximize(); - isMaximized.value = false; - } else { - await windowManager.maximize(); - isMaximized.value = true; - } - }, - ), - DecoratedCloseButton( - type: type, - onPressed: onClose, - ), - ], - ), + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DecoratedMinimizeButton( + type: type, + onPressed: windowManager.minimize, + ), + DecoratedMaximizeButton( + type: type, + onPressed: () async { + if (await windowManager.isMaximized()) { + await windowManager.unmaximize(); + isMaximized.value = false; + } else { + await windowManager.maximize(); + isMaximized.value = true; + } + }, + ), + DecoratedCloseButton( + type: type, + onPressed: onClose, + ), + ], ); } } diff --git a/lib/components/titlebar/titlebar_icon_buttons.dart b/lib/components/titlebar/titlebar_icon_buttons.dart index 70170262..481a22ce 100644 --- a/lib/components/titlebar/titlebar_icon_buttons.dart +++ b/lib/components/titlebar/titlebar_icon_buttons.dart @@ -1,56 +1,50 @@ import 'dart:math'; -import 'package:flutter/material.dart'; -import 'package:spotube/components/titlebar/window_button.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/extensions/button_variance.dart'; -class MinimizeWindowButton extends WindowButton { - MinimizeWindowButton( - {super.key, super.colors, super.onPressed, bool? animate}) - : super( - animate: animate ?? false, - iconBuilder: (buttonContext) => - MinimizeIcon(color: buttonContext.iconColor), - ); +class ShadcnWindowButton extends StatelessWidget { + final Widget icon; + final VoidCallback onPressed; + final Color? hoverBackgroundColor; + + const ShadcnWindowButton({ + super.key, + required this.icon, + required this.onPressed, + this.hoverBackgroundColor, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 45, + height: 32, + child: IconButton( + variance: ButtonVariance.ghost.copyWith( + decoration: (context, states) { + final decoration = ButtonVariance.ghost.decoration(context, states) + as BoxDecoration; + if (hoverBackgroundColor != null && + states.contains(WidgetState.hovered)) { + return decoration.copyWith( + borderRadius: BorderRadius.zero, + color: hoverBackgroundColor, + ); + } + + return decoration.copyWith( + borderRadius: BorderRadius.zero, + ); + }, + ), + icon: icon, + onPressed: onPressed, + ), + ); + } } -class MaximizeWindowButton extends WindowButton { - MaximizeWindowButton( - {super.key, super.colors, super.onPressed, bool? animate}) - : super( - animate: animate ?? false, - iconBuilder: (buttonContext) => - MaximizeIcon(color: buttonContext.iconColor), - ); -} - -class RestoreWindowButton extends WindowButton { - RestoreWindowButton({super.key, super.colors, super.onPressed, bool? animate}) - : super( - animate: animate ?? false, - iconBuilder: (buttonContext) => - RestoreIcon(color: buttonContext.iconColor), - ); -} - -final _defaultCloseButtonColors = WindowButtonColors( - mouseOver: const Color(0xFFD32F2F), - mouseDown: const Color(0xFFB71C1C), - iconNormal: const Color(0xFF805306), - iconMouseOver: const Color(0xFFFFFFFF)); - -class CloseWindowButton extends WindowButton { - CloseWindowButton( - {super.key, WindowButtonColors? colors, super.onPressed, bool? animate}) - : super( - colors: colors ?? _defaultCloseButtonColors, - animate: animate ?? false, - iconBuilder: (buttonContext) => - CloseIcon(color: buttonContext.iconColor), - ); -} - -// Switched to CustomPaint icons by https://github.com/esDotDev - /// Close class CloseIcon extends StatelessWidget { final Color color; @@ -149,8 +143,9 @@ class _AlignedPaint extends StatelessWidget { @override Widget build(BuildContext context) { return Align( - alignment: Alignment.center, - child: CustomPaint(size: const Size(10, 10), painter: painter)); + alignment: Alignment.center, + child: CustomPaint(size: const Size(10, 10), painter: painter), + ); } } diff --git a/lib/components/titlebar/window_button.dart b/lib/components/titlebar/window_button.dart deleted file mode 100644 index 3201d191..00000000 --- a/lib/components/titlebar/window_button.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:spotube/components/titlebar/mouse_state.dart'; - -typedef WindowButtonIconBuilder = Widget Function( - WindowButtonContext buttonContext); -typedef WindowButtonBuilder = Widget Function( - WindowButtonContext buttonContext, Widget icon); - -class WindowButtonContext { - BuildContext context; - MouseState mouseState; - Color? backgroundColor; - Color iconColor; - WindowButtonContext( - {required this.context, - required this.mouseState, - this.backgroundColor, - required this.iconColor}); -} - -class WindowButtonColors { - late Color normal; - late Color mouseOver; - late Color mouseDown; - late Color iconNormal; - late Color iconMouseOver; - late Color iconMouseDown; - WindowButtonColors( - {Color? normal, - Color? mouseOver, - Color? mouseDown, - Color? iconNormal, - Color? iconMouseOver, - Color? iconMouseDown}) { - this.normal = normal ?? _defaultButtonColors.normal; - this.mouseOver = mouseOver ?? _defaultButtonColors.mouseOver; - this.mouseDown = mouseDown ?? _defaultButtonColors.mouseDown; - this.iconNormal = iconNormal ?? _defaultButtonColors.iconNormal; - this.iconMouseOver = iconMouseOver ?? _defaultButtonColors.iconMouseOver; - this.iconMouseDown = iconMouseDown ?? _defaultButtonColors.iconMouseDown; - } -} - -final _defaultButtonColors = WindowButtonColors( - normal: Colors.transparent, - iconNormal: const Color(0xFF805306), - mouseOver: const Color(0xFF404040), - mouseDown: const Color(0xFF202020), - iconMouseOver: const Color(0xFFFFFFFF), - iconMouseDown: const Color(0xFFF0F0F0), -); - -class WindowButton extends StatelessWidget { - final WindowButtonBuilder? builder; - final WindowButtonIconBuilder? iconBuilder; - late final WindowButtonColors colors; - final bool animate; - final EdgeInsets? padding; - final VoidCallback? onPressed; - - WindowButton( - {super.key, - WindowButtonColors? colors, - this.builder, - @required this.iconBuilder, - this.padding, - this.onPressed, - this.animate = false}) { - this.colors = colors ?? _defaultButtonColors; - } - - Color getBackgroundColor(MouseState mouseState) { - if (mouseState.isMouseDown) return colors.mouseDown; - if (mouseState.isMouseOver) return colors.mouseOver; - return colors.normal; - } - - Color getIconColor(MouseState mouseState) { - if (mouseState.isMouseDown) return colors.iconMouseDown; - if (mouseState.isMouseOver) return colors.iconMouseOver; - return colors.iconNormal; - } - - @override - Widget build(BuildContext context) { - if (kIsWeb) { - return Container(); - } else { - // Don't show button on macOS - if (Platform.isMacOS) { - return Container(); - } - } - - return MouseStateBuilder( - builder: (context, mouseState) { - WindowButtonContext buttonContext = WindowButtonContext( - mouseState: mouseState, - context: context, - backgroundColor: getBackgroundColor(mouseState), - iconColor: getIconColor(mouseState)); - - var icon = - (iconBuilder != null) ? iconBuilder!(buttonContext) : Container(); - - var fadeOutColor = - getBackgroundColor(MouseState()..isMouseOver = true).withOpacity(0); - var padding = this.padding ?? const EdgeInsets.all(10); - var animationMs = - mouseState.isMouseOver ? (animate ? 100 : 0) : (animate ? 200 : 0); - Widget iconWithPadding = Padding(padding: padding, child: icon); - iconWithPadding = AnimatedContainer( - curve: Curves.easeOut, - duration: Duration(milliseconds: animationMs), - color: buttonContext.backgroundColor ?? fadeOutColor, - child: iconWithPadding); - var button = - (builder != null) ? builder!(buttonContext, icon) : iconWithPadding; - return SizedBox( - width: 45, - height: 32, - child: button, - ); - }, - onPressed: () { - if (onPressed != null) onPressed!(); - }, - ); - } -} diff --git a/lib/components/track_presentation/presentation_actions.dart b/lib/components/track_presentation/presentation_actions.dart new file mode 100644 index 00000000..01228524 --- /dev/null +++ b/lib/components/track_presentation/presentation_actions.dart @@ -0,0 +1,221 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; +import 'package:spotube/components/dialogs/confirm_download_dialog.dart'; +import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; +import 'package:spotube/components/track_presentation/presentation_state.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/provider/history/history.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; + +class TrackPresentationActionsSection extends HookConsumerWidget { + const TrackPresentationActionsSection({super.key}); + + showToastForAction(BuildContext context, String action, int count) { + final message = switch (action) { + "download" => (context.l10n.download_count(count), SpotubeIcons.download), + "add-to-playlist" => ( + context.l10n.add_count_to_playlist(count), + SpotubeIcons.playlistAdd + ), + "add-to-queue" => ( + context.l10n.add_count_to_queue(count), + SpotubeIcons.queueAdd + ), + "play-next" => ( + context.l10n.play_count_next(count), + SpotubeIcons.lightning + ), + _ => ("", SpotubeIcons.error), + }; + + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + child: Basic( + leading: Icon(message.$2), + title: Text(message.$1), + leadingAlignment: Alignment.center, + trailing: IconButton.ghost( + size: ButtonSize.small, + icon: const Icon(SpotubeIcons.close), + onPressed: () { + overlay.close(); + }, + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context, ref) { + final options = TrackPresentationOptions.of(context); + + ref.watch(downloadManagerProvider); + final downloader = ref.watch(downloadManagerProvider.notifier); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryActionsProvider); + final audioSource = + ref.watch(userPreferencesProvider.select((s) => s.audioSource)); + + final state = ref.watch(presentationStateProvider(options.collection)); + final notifier = + ref.watch(presentationStateProvider(options.collection).notifier); + final selectedTracks = state.selectedTracks; + + return AdaptivePopSheetList( + tooltip: context.l10n.more_actions, + headings: [ + Text( + context.l10n.more_actions, + style: context.theme.typography.large, + ), + ], + onSelected: (action) async { + var tracks = selectedTracks; + + if (selectedTracks.isEmpty) { + tracks = await options.pagination.onFetchAll(); + + notifier.selectAllTracks(); + } + + if (!context.mounted) return; + + switch (action) { + case "download": + { + final confirmed = audioSource == AudioSource.piped || + (await showDialog( + context: context, + builder: (context) { + return const ConfirmDownloadDialog(); + }, + ) ?? + false); + if (confirmed != true) return; + downloader.batchAddToQueue(tracks); + notifier.deselectAllTracks(); + if (!context.mounted) return; + showToastForAction(context, action, tracks.length); + break; + } + case "add-to-playlist": + { + if (context.mounted) { + final worked = await showDialog( + context: context, + builder: (context) { + return PlaylistAddTrackDialog( + openFromPlaylist: options.collectionId, + tracks: tracks.toList(), + ); + }, + ); + + if (!context.mounted || worked != true) return; + showToastForAction(context, action, tracks.length); + } + break; + } + case "play-next": + { + playlistNotifier.addTracksAtFirst(tracks); + playlistNotifier.addCollection(options.collectionId); + if (options.collection is AlbumSimple) { + historyNotifier.addAlbums([options.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([options.collection as PlaylistSimple]); + } + notifier.deselectAllTracks(); + if (!context.mounted) return; + showToastForAction(context, action, tracks.length); + break; + } + case "add-to-queue": + { + playlistNotifier.addTracks(tracks); + playlistNotifier.addCollection(options.collectionId); + if (options.collection is AlbumSimple) { + historyNotifier.addAlbums([options.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([options.collection as PlaylistSimple]); + } + notifier.deselectAllTracks(); + if (!context.mounted) return; + showToastForAction(context, action, tracks.length); + break; + } + default: + } + + if (!context.mounted) return; + }, + icon: const Icon(SpotubeIcons.moreVertical), + variance: ButtonVariance.outline, + children: [ + AdaptiveMenuButton( + value: "download", + leading: const Icon(SpotubeIcons.download), + child: selectedTracks.isEmpty || + selectedTracks.length == options.tracks.length + ? Text( + context.l10n.download_all, + ) + : Text( + context.l10n.download_count(selectedTracks.length), + ), + ), + AdaptiveMenuButton( + value: "add-to-playlist", + leading: const Icon(SpotubeIcons.playlistAdd), + child: selectedTracks.isEmpty || + selectedTracks.length == options.tracks.length + ? Text( + context.l10n.add_all_to_playlist, + ) + : Text( + context.l10n.add_count_to_playlist(selectedTracks.length), + ), + ), + AdaptiveMenuButton( + value: "add-to-queue", + leading: const Icon(SpotubeIcons.queueAdd), + child: selectedTracks.isEmpty || + selectedTracks.length == options.tracks.length + ? Text( + context.l10n.add_all_to_queue, + ) + : Text( + context.l10n.add_count_to_queue(selectedTracks.length), + ), + ), + AdaptiveMenuButton( + value: "play-next", + leading: const Icon(SpotubeIcons.lightning), + child: selectedTracks.isEmpty || + selectedTracks.length == options.tracks.length + ? Text( + context.l10n.play_all_next, + ) + : Text( + context.l10n.play_count_next(selectedTracks.length), + ), + ), + ], + ); + } +} diff --git a/lib/components/track_presentation/presentation_list.dart b/lib/components/track_presentation/presentation_list.dart new file mode 100644 index 00000000..dda7dffa --- /dev/null +++ b/lib/components/track_presentation/presentation_list.dart @@ -0,0 +1,111 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_undraw/flutter_undraw.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotube/collections/fake.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; +import 'package:spotube/components/track_presentation/presentation_state.dart'; +import 'package:spotube/components/track_presentation/use_track_tile_play_callback.dart'; +import 'package:spotube/components/track_tile/track_tile.dart'; +import 'package:spotube/components/track_presentation/use_is_user_playlist.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; + +class PresentationListSection extends HookConsumerWidget { + const PresentationListSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final options = TrackPresentationOptions.of(context); + final playlist = ref.watch(audioPlayerProvider); + final state = ref.watch(presentationStateProvider(options.collection)); + final notifier = + ref.read(presentationStateProvider(options.collection).notifier); + final isUserPlaylist = useIsUserPlaylist(ref, options.collectionId); + + final onTileTap = useTrackTilePlayCallback(ref); + + if (state.presentationTracks.isEmpty && !options.pagination.isLoading) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Undraw( + illustration: UndrawIllustration.dreamer, + color: context.theme.colorScheme.primary, + height: 200 * context.theme.scaling, + ), + Text( + isUserPlaylist + ? context.l10n.no_tracks_added_yet + : context.l10n.no_tracks, + textAlign: TextAlign.center, + ).muted().small(), + ], + ), + ), + ); + } + + return SliverInfiniteList( + isLoading: options.pagination.isLoading, + onFetchData: options.pagination.onFetchMore, + itemCount: state.presentationTracks.length, + hasReachedMax: !options.pagination.hasNextPage, + loadingBuilder: (context) { + return Skeletonizer( + enabled: true, + child: TrackTile( + index: 0, + playlist: playlist, + track: FakeData.track, + ), + ); + }, + emptyBuilder: (context) => Skeletonizer( + enabled: true, + child: Column( + children: List.generate( + 10, + (index) => TrackTile( + track: FakeData.track, + index: index, + playlist: playlist, + ), + ), + ), + ), + itemBuilder: (context, index) { + final track = state.presentationTracks[index]; + final isSelected = state.selectedTracks.any((e) => e.id == track.id); + return TrackTile( + userPlaylist: isUserPlaylist, + playlistId: options.collectionId, + index: index, + playlist: playlist, + track: track, + selected: isSelected, + onTap: () => onTileTap(track, index), + onChanged: state.selectedTracks.isEmpty + ? null + : (isSelected) { + if (isSelected == true) { + notifier.selectTrack(track); + } else { + notifier.deselectTrack(track); + } + }, + onLongPress: () { + notifier.selectTrack(track); + HapticFeedback.selectionClick(); + }, + ); + }, + ); + } +} diff --git a/lib/components/track_presentation/presentation_modifiers.dart b/lib/components/track_presentation/presentation_modifiers.dart new file mode 100644 index 00000000..c46fef3f --- /dev/null +++ b/lib/components/track_presentation/presentation_modifiers.dart @@ -0,0 +1,124 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/track_presentation/sort_tracks_dropdown.dart'; +import 'package:spotube/components/track_presentation/presentation_actions.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; +import 'package:spotube/components/track_presentation/presentation_state.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; + +class TrackPresentationModifiersSection extends HookConsumerWidget { + final FocusNode? focusNode; + const TrackPresentationModifiersSection({ + super.key, + this.focusNode, + }); + + @override + Widget build(BuildContext context, ref) { + final options = TrackPresentationOptions.of(context); + final state = ref.watch(presentationStateProvider(options.collection)); + final notifier = ref.watch( + presentationStateProvider(options.collection).notifier, + ); + + final controller = useShadcnTextEditingController(); + final scale = context.theme.scaling; + + return LayoutBuilder(builder: (context, constrains) { + return Padding( + padding: EdgeInsets.symmetric( + horizontal: (constrains.mdAndUp ? 16 : 8) * scale, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox( + state: state.selectedTracks.length == options.tracks.length + ? CheckboxState.checked + : CheckboxState.unchecked, + onChanged: (value) { + if (value == CheckboxState.checked) { + notifier.selectAllTracks(); + } else { + notifier.deselectAllTracks(); + } + }, + ), + ], + ), + Flexible( + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + Flexible( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 320 * scale, + maxHeight: 38 * scale, + ), + child: TextField( + controller: controller, + focusNode: focusNode, + leading: Icon( + SpotubeIcons.search, + color: context.theme.colorScheme.mutedForeground, + ), + placeholder: Text(context.l10n.search_tracks), + onChanged: (value) { + if (value.isEmpty) { + notifier.clearFilter(); + } else { + notifier.filterTracks(value); + } + }, + trailing: ListenableBuilder( + listenable: controller, + builder: (context, _) { + return AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + crossFadeState: controller.text.isEmpty + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: + const SizedBox.square(dimension: 20), + secondChild: AnimatedScale( + duration: const Duration(milliseconds: 300), + scale: controller.text.isEmpty ? 0 : 1, + child: IconButton.ghost( + size: const ButtonSize(.6), + icon: const Icon(SpotubeIcons.close), + onPressed: () { + controller.clear(); + notifier.clearFilter(); + }, + ), + ), + ); + }), + ), + ), + ), + SortTracksDropdown( + value: state.sortBy, + onChanged: (value) { + notifier.sortTracks(value); + }, + ), + const TrackPresentationActionsSection(), + ], + ), + ), + ], + ), + ); + }); + } +} diff --git a/lib/components/tracks_view/track_view_props.dart b/lib/components/track_presentation/presentation_props.dart similarity index 60% rename from lib/components/tracks_view/track_view_props.dart rename to lib/components/track_presentation/presentation_props.dart index b0a00ae2..144cf0e8 100644 --- a/lib/components/tracks_view/track_view_props.dart +++ b/lib/components/track_presentation/presentation_props.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:flutter/material.dart' hide Page; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; class PaginationProps { @@ -38,31 +38,33 @@ class PaginationProps { onRefresh.hashCode; } -class InheritedTrackView extends InheritedWidget { +class TrackPresentationOptions { final Object collection; final String title; final String? description; + final String? owner; + final String? ownerImage; final String image; final String routePath; final List tracks; final PaginationProps pagination; final bool isLiked; - final String shareUrl; + final String? shareUrl; // events final FutureOr Function()? onHeart; // if null heart button will hidden - const InheritedTrackView({ - super.key, - required super.child, + const TrackPresentationOptions({ required this.collection, required this.title, this.description, + this.owner, + this.ownerImage, required this.image, required this.tracks, required this.pagination, required this.routePath, - required this.shareUrl, + this.shareUrl, this.isLiked = false, this.onHeart, }) : assert(collection is AlbumSimple || collection is PlaylistSimple); @@ -71,29 +73,36 @@ class InheritedTrackView extends InheritedWidget { ? (collection as AlbumSimple).id! : (collection as PlaylistSimple).id!; - @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.collection != collection || - oldWidget.child != child; + static TrackPresentationOptions of(BuildContext context) { + return Data.of(context); } - 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; + @override + operator ==(Object other) { + return other is TrackPresentationOptions && + other.collection == collection && + other.title == title && + other.description == description && + other.image == image && + other.routePath == routePath && + other.tracks == tracks && + other.pagination == pagination && + other.isLiked == isLiked && + other.shareUrl == shareUrl && + other.onHeart == onHeart; } + + @override + int get hashCode => + super.hashCode ^ + collection.hashCode ^ + title.hashCode ^ + description.hashCode ^ + image.hashCode ^ + routePath.hashCode ^ + tracks.hashCode ^ + pagination.hashCode ^ + isLiked.hashCode ^ + shareUrl.hashCode ^ + onHeart.hashCode; } diff --git a/lib/components/track_presentation/presentation_state.dart b/lib/components/track_presentation/presentation_state.dart new file mode 100644 index 00000000..d3428861 --- /dev/null +++ b/lib/components/track_presentation/presentation_state.dart @@ -0,0 +1,173 @@ +import 'package:collection/collection.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class PresentationState { + final List selectedTracks; + final List presentationTracks; + final SortBy sortBy; + + const PresentationState({ + required this.selectedTracks, + required this.presentationTracks, + required this.sortBy, + }); + + PresentationState copyWith({ + List? selectedTracks, + List? presentationTracks, + SortBy? sortBy, + }) { + return PresentationState( + selectedTracks: selectedTracks ?? this.selectedTracks, + presentationTracks: presentationTracks ?? this.presentationTracks, + sortBy: sortBy ?? this.sortBy, + ); + } +} + +class PresentationStateNotifier + extends AutoDisposeFamilyNotifier { + @override + PresentationState build(collection) { + if (arg case PlaylistSimple() || AlbumSimple()) { + if (isSavedTrackPlaylist) { + ref.listen( + likedTracksProvider, + (previous, next) { + next.whenData((value) { + state = state.copyWith( + presentationTracks: ServiceUtils.sortTracks( + value, + state.sortBy, + ), + ); + }); + }, + ); + } else { + ref.listen( + arg is PlaylistSimple + ? playlistTracksProvider((arg as PlaylistSimple).id!) + : albumTracksProvider((arg as AlbumSimple)), + (previous, next) { + next.whenData((value) { + state = state.copyWith( + presentationTracks: ServiceUtils.sortTracks( + value.items, + state.sortBy, + ), + ); + }); + }, + ); + } + } + + return PresentationState( + selectedTracks: [], + presentationTracks: tracks, + sortBy: SortBy.none, + ); + } + + bool get isSavedTrackPlaylist => + arg is PlaylistSimple && + (arg as PlaylistSimple).id == "user-liked-tracks"; + + List get tracks { + assert( + arg is PlaylistSimple || arg is AlbumSimple, + "arg must be PlaylistSimple or AlbumSimple", + ); + + final isPlaylist = arg is PlaylistSimple; + + final tracks = switch ((isPlaylist, isSavedTrackPlaylist)) { + (true, true) => ref.read(likedTracksProvider).asData?.value, + (true, false) => ref + .read(playlistTracksProvider((arg as PlaylistSimple).id!)) + .asData + ?.value + .items, + _ => ref + .read(albumTracksProvider((arg as AlbumSimple))) + .asData + ?.value + .items, + } ?? + []; + + return tracks; + } + + void selectTrack(Track track) { + if (state.selectedTracks.any((e) => e.id == track.id)) { + return; + } + + state = state.copyWith( + selectedTracks: [...state.selectedTracks, track], + ); + } + + void selectAllTracks() { + state = state.copyWith( + selectedTracks: tracks, + ); + } + + void deselectTrack(Track track) { + state = state.copyWith( + selectedTracks: state.selectedTracks.where((e) => e != track).toList(), + ); + } + + void deselectAllTracks() { + state = state.copyWith( + selectedTracks: [], + ); + } + + void filterTracks(String query) { + if (query.isEmpty) { + return; + } + + state = state.copyWith( + presentationTracks: ServiceUtils.sortTracks( + tracks + .map((e) => (weightedRatio(e.name!, query), e)) + .sorted((a, b) => b.$1.compareTo(a.$1)) + .where((e) => e.$1 > 50) + .map((e) => e.$2) + .toList(), + state.sortBy, + ), + ); + } + + void clearFilter() { + state = state.copyWith( + presentationTracks: ServiceUtils.sortTracks(tracks, state.sortBy), + ); + } + + void sortTracks(SortBy sortBy) { + state = state.copyWith( + presentationTracks: sortBy == SortBy.none + ? tracks + : ServiceUtils.sortTracks(state.presentationTracks, sortBy), + sortBy: sortBy, + ); + } +} + +final presentationStateProvider = AutoDisposeNotifierProviderFamily< + PresentationStateNotifier, PresentationState, Object>( + () => PresentationStateNotifier(), +); diff --git a/lib/components/track_presentation/presentation_top.dart b/lib/components/track_presentation/presentation_top.dart new file mode 100644 index 00000000..8da2f51c --- /dev/null +++ b/lib/components/track_presentation/presentation_top.dart @@ -0,0 +1,278 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/env.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/heart_button/heart_button.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; +import 'package:spotube/components/track_presentation/use_action_callbacks.dart'; +import 'package:spotube/components/track_presentation/use_is_user_playlist.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +class TrackPresentationTopSection extends HookConsumerWidget { + const TrackPresentationTopSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final mediaQuery = MediaQuery.sizeOf(context); + final options = TrackPresentationOptions.of(context); + final scale = context.theme.scaling; + final isUserPlaylist = useIsUserPlaylist(ref, options.collectionId); + + final playlistImage = (options.collection is PlaylistSimple && + (options.collection as PlaylistSimple).owner?.displayName == + "Spotify" && + Env.disableSpotifyImages) + ? ref.watch(playlistImageProvider(options.collectionId)) + : null; + final decorationImage = playlistImage != null + ? DecorationImage( + image: AssetImage(playlistImage.src), + fit: BoxFit.cover, + colorFilter: ColorFilter.mode( + playlistImage.color, + playlistImage.colorBlendMode, + ), + ) + : DecorationImage( + image: UniversalImage.imageProvider(options.image), + fit: BoxFit.cover, + ); + + final imageDimension = mediaQuery.mdAndUp ? 200 : 120; + + final (:isLoading, :isActive, :onPlay, :onShuffle) = + useActionCallbacks(ref); + + final playbackActions = Row( + spacing: 8 * scale, + children: [ + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.shuffle_playlist), + ), + child: IconButton.secondary( + icon: isLoading + ? const Center( + child: + CircularProgressIndicator(onSurface: false, size: 20), + ) + : const Icon(SpotubeIcons.shuffle), + enabled: !isLoading && !isActive, + onPressed: onShuffle, + ), + ), + if (mediaQuery.width <= 320) + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.add_to_queue), + ), + child: IconButton.secondary( + icon: const Icon(SpotubeIcons.queueAdd), + enabled: !isLoading && !isActive, + onPressed: () {}, + ), + ) + else + Button.secondary( + leading: const Icon(SpotubeIcons.add), + enabled: !isLoading && !isActive, + child: Text(context.l10n.queue), + onPressed: () {}, + ), + Button.primary( + alignment: Alignment.center, + leading: switch ((isActive, isLoading)) { + (true, false) => const Icon(SpotubeIcons.pause), + (false, true) => const Center( + child: CircularProgressIndicator(onSurface: true, size: 18), + ), + _ => const Icon(SpotubeIcons.play), + }, + onPressed: onPlay, + enabled: !isLoading && !isActive, + child: isActive ? Text(context.l10n.pause) : Text(context.l10n.play), + ), + ], + ); + + final additionalActions = Row( + spacing: 8 * scale, + children: [ + if (isUserPlaylist) + IconButton.outline( + size: ButtonSize.small, + icon: const Icon(SpotubeIcons.edit), + onPressed: () { + showDialog( + context: context, + builder: (context) { + return PlaylistCreateDialog( + playlistId: options.collectionId, + trackIds: options.tracks.map((e) => e.id!).toList(), + ); + }, + ); + }, + ), + if (options.shareUrl != null) + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.share), + ), + child: IconButton.outline( + icon: const Icon(SpotubeIcons.share), + size: ButtonSize.small, + onPressed: () async { + await Clipboard.setData( + ClipboardData(text: options.shareUrl!), + ); + + if (!context.mounted) return; + + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + child: Text( + context.l10n + .copied_shareurl_to_clipboard(options.shareUrl!), + ).small(), + ); + }, + ); + }, + ), + ), + if (options.onHeart != null) + HeartButton( + isLiked: options.isLiked, + tooltip: options.isLiked + ? context.l10n.remove_from_favorites + : context.l10n.save_as_favorite, + variance: ButtonVariance.outline, + size: ButtonSize.small, + onPressed: options.onHeart, + ), + ], + ); + + return SliverMainAxisGroup( + slivers: [ + if (mediaQuery.mdAndUp) SliverGap(16 * scale), + SliverPadding( + padding: EdgeInsets.symmetric( + horizontal: (mediaQuery.mdAndUp ? 16 : 8.0) * scale, + ), + sliver: SliverList.list( + children: [ + DecoratedBox( + decoration: BoxDecoration( + image: decorationImage, + borderRadius: BorderRadius.circular(45), + ), + child: OutlinedContainer( + surfaceOpacity: context.theme.surfaceOpacity, + surfaceBlur: context.theme.surfaceBlur, + padding: EdgeInsets.all(24 * scale), + borderRadius: BorderRadius.circular(22 * scale), + borderWidth: 2, + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 16 * scale, + children: [ + Row( + spacing: 16 * scale, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: imageDimension * scale, + width: imageDimension * scale, + decoration: BoxDecoration( + borderRadius: context.theme.borderRadiusXl, + image: decorationImage, + ), + ), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AutoSizeText( + options.title, + maxLines: 2, + minFontSize: 16, + style: context.theme.typography.h3, + ), + if (options.description != null) + AutoSizeText( + options.description!, + maxLines: 2, + minFontSize: 14, + maxFontSize: 18, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: context + .theme.colorScheme.mutedForeground, + fontSize: 18, + ), + ), + const Gap(16), + Flex( + crossAxisAlignment: CrossAxisAlignment.start, + direction: mediaQuery.smAndUp + ? Axis.horizontal + : Axis.vertical, + spacing: 8 * scale, + children: [ + if (options.owner != null) + OutlineBadge( + leading: options.ownerImage != null + ? Avatar( + initials: + options.owner?[0] ?? "U", + provider: UniversalImage + .imageProvider( + options.ownerImage!, + ), + ) + : null, + child: Text( + options.owner!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ).small(), + ), + additionalActions, + ], + ), + if (mediaQuery.mdAndUp) ...[ + const Gap(16), + playbackActions + ], + ], + ), + ), + ], + ), + if (mediaQuery.smAndDown) playbackActions, + ], + ), + ), + ), + ], + ), + ) + ], + ); + } +} diff --git a/lib/components/track_presentation/sort_tracks_dropdown.dart b/lib/components/track_presentation/sort_tracks_dropdown.dart new file mode 100644 index 00000000..54990503 --- /dev/null +++ b/lib/components/track_presentation/sort_tracks_dropdown.dart @@ -0,0 +1,70 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart'; +import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; +import 'package:spotube/extensions/context.dart'; + +class SortTracksDropdown extends StatelessWidget { + final SortBy? value; + final void Function(SortBy)? onChanged; + const SortTracksDropdown({ + this.onChanged, + this.value, + super.key, + }); + + @override + Widget build(BuildContext context) { + return AdaptivePopSheetList( + variance: ButtonVariance.outline, + headings: [ + Text(context.l10n.sort_tracks), + ], + onSelected: onChanged, + tooltip: context.l10n.sort_tracks, + icon: const Icon(SpotubeIcons.sort), + children: [ + AdaptiveMenuButton( + value: SortBy.none, + enabled: value != SortBy.none, + child: Text(context.l10n.none), + ), + AdaptiveMenuButton( + value: SortBy.ascending, + enabled: value != SortBy.ascending, + child: Text(context.l10n.sort_a_z), + ), + AdaptiveMenuButton( + value: SortBy.descending, + enabled: value != SortBy.descending, + child: Text(context.l10n.sort_z_a), + ), + AdaptiveMenuButton( + value: SortBy.newest, + enabled: value != SortBy.newest, + child: Text(context.l10n.sort_newest), + ), + AdaptiveMenuButton( + value: SortBy.oldest, + enabled: value != SortBy.oldest, + child: Text(context.l10n.sort_oldest), + ), + AdaptiveMenuButton( + value: SortBy.duration, + enabled: value != SortBy.duration, + child: Text(context.l10n.sort_duration), + ), + AdaptiveMenuButton( + value: SortBy.artist, + enabled: value != SortBy.artist, + child: Text(context.l10n.sort_artist), + ), + AdaptiveMenuButton( + value: SortBy.album, + enabled: value != SortBy.album, + child: Text(context.l10n.sort_album), + ), + ], + ); + } +} diff --git a/lib/components/track_presentation/track_presentation.dart b/lib/components/track_presentation/track_presentation.dart new file mode 100644 index 00000000..47089bd6 --- /dev/null +++ b/lib/components/track_presentation/track_presentation.dart @@ -0,0 +1,98 @@ +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; +import 'package:spotube/components/track_presentation/presentation_list.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; +import 'package:spotube/components/track_presentation/presentation_top.dart'; +import 'package:spotube/components/track_presentation/presentation_modifiers.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/utils/platform.dart'; + +class TrackPresentation extends HookConsumerWidget { + final TrackPresentationOptions options; + const TrackPresentation({ + super.key, + required this.options, + }); + + @override + Widget build(BuildContext context, ref) { + final scrollController = useScrollController(); + final focusNode = useFocusNode(); + final scale = context.theme.scaling; + + useEffect(() { + if (!kIsMobile) return null; + void listener() { + if (!scrollController.hasClients) return; + + if (focusNode.hasFocus) { + scrollController.animateTo( + 300 * scale, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + } + + focusNode.addListener(listener); + return () { + focusNode.removeListener(listener); + }; + }, [focusNode, scrollController, scale]); + + return Data.inherit( + data: options, + child: SafeArea( + bottom: false, + child: Scaffold( + headers: const [TitleBar()], + child: CustomScrollView( + controller: scrollController, + slivers: [ + const TrackPresentationTopSection(), + const SliverGap(16), + SliverLayoutBuilder( + builder: (context, constrains) { + return SliverList.list( + children: [ + TrackPresentationModifiersSection( + focusNode: focusNode, + ), + Basic( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + leading: constrains.mdAndUp ? const Text(" #") : null, + title: Row( + children: [ + Expanded( + flex: constrains.lgAndUp ? 5 : 6, + child: Text(context.l10n.title), + ), + if (constrains.mdAndUp) + Expanded( + flex: 3, + child: Text(context.l10n.album), + ), + Text(context.l10n.duration), + ], + ), + ).small().muted(), + ], + ); + }, + ), + const PresentationListSection(), + const SliverSafeArea(sliver: SliverGap(10)), + ], + ), + ), + ), + ); + } +} diff --git a/lib/components/track_presentation/use_action_callbacks.dart b/lib/components/track_presentation/use_action_callbacks.dart new file mode 100644 index 00000000..0012594a --- /dev/null +++ b/lib/components/track_presentation/use_action_callbacks.dart @@ -0,0 +1,137 @@ +import 'dart:math'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/dialogs/select_device_dialog.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; + +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/history/history.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; + +typedef UseActionCallbacks = ({ + bool isActive, + bool isLoading, + Future Function() onShuffle, + Future Function() onPlay, +}); + +UseActionCallbacks useActionCallbacks(WidgetRef ref) { + final isLoading = useState(false); + final context = useContext(); + final options = TrackPresentationOptions.of(context); + final playlist = ref.watch(audioPlayerProvider); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryActionsProvider); + + final isActive = useMemoized( + () => playlist.collections.contains(options.collectionId), + [playlist.collections, options.collectionId], + ); + + final onShuffle = useCallback(() async { + try { + isLoading.value = true; + + final initialTracks = options.tracks; + if (!context.mounted) return; + + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice == null) return; + if (isRemoteDevice) { + final allTracks = await options.pagination.onFetchAll(); + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + options.collection is AlbumSimple + ? WebSocketLoadEventData.album( + tracks: allTracks, + collection: options.collection as AlbumSimple, + initialIndex: Random().nextInt(allTracks.length)) + : WebSocketLoadEventData.playlist( + tracks: allTracks, + collection: options.collection as PlaylistSimple, + initialIndex: Random().nextInt(allTracks.length), + ), + ); + await remotePlayback.setShuffle(true); + } else { + await playlistNotifier.load( + initialTracks, + autoPlay: true, + initialIndex: Random().nextInt(initialTracks.length), + ); + await audioPlayer.setShuffle(true); + playlistNotifier.addCollection(options.collectionId); + if (options.collection is AlbumSimple) { + historyNotifier.addAlbums([options.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists([options.collection as PlaylistSimple]); + } + + final allTracks = await options.pagination.onFetchAll(); + + await playlistNotifier.addTracks( + allTracks.sublist(initialTracks.length), + ); + } + } finally { + isLoading.value = false; + } + }, [options, playlistNotifier, historyNotifier]); + + final onPlay = useCallback(() async { + try { + isLoading.value = true; + + final initialTracks = options.tracks; + + if (!context.mounted) return; + + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice == null) return; + if (isRemoteDevice) { + final allTracks = await options.pagination.onFetchAll(); + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + options.collection is AlbumSimple + ? WebSocketLoadEventData.album( + tracks: allTracks, + collection: options.collection as AlbumSimple, + ) + : WebSocketLoadEventData.playlist( + tracks: allTracks, + collection: options.collection as PlaylistSimple, + ), + ); + } else { + await playlistNotifier.load(initialTracks, autoPlay: true); + playlistNotifier.addCollection(options.collectionId); + if (options.collection is AlbumSimple) { + historyNotifier.addAlbums([options.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists([options.collection as PlaylistSimple]); + } + + final allTracks = await options.pagination.onFetchAll(); + + await playlistNotifier.addTracks( + allTracks.sublist(initialTracks.length), + ); + } + } finally { + if (context.mounted) { + isLoading.value = false; + } + } + }, [options, playlistNotifier, historyNotifier]); + + return ( + isActive: isActive, + isLoading: isLoading.value, + onShuffle: onShuffle, + onPlay: onPlay, + ); +} diff --git a/lib/components/tracks_view/sections/body/use_is_user_playlist.dart b/lib/components/track_presentation/use_is_user_playlist.dart similarity index 100% rename from lib/components/tracks_view/sections/body/use_is_user_playlist.dart rename to lib/components/track_presentation/use_is_user_playlist.dart diff --git a/lib/components/track_presentation/use_track_tile_play_callback.dart b/lib/components/track_presentation/use_track_tile_play_callback.dart new file mode 100644 index 00000000..b519f781 --- /dev/null +++ b/lib/components/track_presentation/use_track_tile_play_callback.dart @@ -0,0 +1,89 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/dialogs/select_device_dialog.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; +import 'package:spotube/components/track_presentation/presentation_state.dart'; +import 'package:spotube/extensions/list.dart'; + +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/history/history.dart'; + +Future Function(Track track, int index) useTrackTilePlayCallback( + WidgetRef ref, +) { + final context = useContext(); + final options = TrackPresentationOptions.of(context); + final playlist = ref.watch(audioPlayerProvider); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryActionsProvider); + + final isActive = useMemoized( + () => playlist.collections.contains(options.collectionId), + [playlist.collections, options.collectionId], + ); + + final onTapTrackTile = useCallback((Track track, int index) async { + final state = ref.read(presentationStateProvider(options.collection)); + final notifier = + ref.read(presentationStateProvider(options.collection).notifier); + + if (state.selectedTracks.isNotEmpty) { + if (state.selectedTracks.contains(track)) { + notifier.deselectTrack(track); + } else { + notifier.selectTrack(track); + } + return; + } + + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice == null) return; + + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final remoteQueue = ref.read(queueProvider); + if (remoteQueue.collections.contains(options.collectionId) || + remoteQueue.tracks.any((s) => s.id == track.id)) { + await playlistNotifier.jumpToTrack(track); + } else { + final tracks = await options.pagination.onFetchAll(); + await remotePlayback.load( + options.collection is AlbumSimple + ? WebSocketLoadEventData.album( + tracks: tracks, + collection: options.collection as AlbumSimple, + initialIndex: index, + ) + : WebSocketLoadEventData.playlist( + tracks: tracks, + collection: options.collection as PlaylistSimple, + initialIndex: index, + ), + ); + } + } else { + if (isActive || playlist.tracks.containsBy(track, (a) => a.id)) { + await playlistNotifier.jumpToTrack(track); + } else { + final tracks = await options.pagination.onFetchAll(); + await playlistNotifier.load( + tracks, + initialIndex: index, + autoPlay: true, + ); + playlistNotifier.addCollection(options.collectionId); + if (options.collection is AlbumSimple) { + historyNotifier.addAlbums([options.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists([options.collection as PlaylistSimple]); + } + } + } + }, [isActive, playlist, options, playlistNotifier, historyNotifier]); + + return onTapTrackTile; +} diff --git a/lib/components/track_tile/track_options.dart b/lib/components/track_tile/track_options.dart index d2cb92cf..b1105c7b 100644 --- a/lib/components/track_tile/track_options.dart +++ b/lib/components/track_tile/track_options.dart @@ -1,12 +1,16 @@ import 'dart:io'; -import 'package:flutter/material.dart' hide Page; +import 'package:auto_route/auto_route.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotify/spotify.dart' hide Offset; import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart'; @@ -20,7 +24,6 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/local_track.dart'; -import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart'; @@ -28,7 +31,6 @@ import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/utils/service_utils.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -67,16 +69,20 @@ class TrackOptions extends HookConsumerWidget { 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, - ), - ), - ); + if (context.mounted) { + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + child: Text( + context.l10n.copied_to_clipboard(data), + textAlign: TextAlign.center, + ), + ); + }, + ); + } }); } @@ -159,9 +165,7 @@ class TrackOptions extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final scaffoldMessenger = ScaffoldMessenger.of(context); final mediaQuery = MediaQuery.of(context); - final router = GoRouter.of(context); final ThemeData(:colorScheme) = Theme.of(context); final playlist = ref.watch(audioPlayerProvider); @@ -202,12 +206,12 @@ class TrackOptions extends HookConsumerWidget { final isLocalTrack = track is LocalTrack; final adaptivePopSheetList = AdaptivePopSheetList( + tooltip: context.l10n.more_actions, onSelected: (value) async { switch (value) { case TrackOptionValue.album: - await router.push( - '/album/${track.album!.id}', - extra: track.album!, + await context.navigateTo( + AlbumRoute(id: track.album!.id!, album: track.album!), ); break; case TrackOptionValue.delete: @@ -217,36 +221,57 @@ class TrackOptions extends HookConsumerWidget { case TrackOptionValue.addToQueue: await playback.addTrack(track); if (context.mounted) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.added_track_to_queue(track.name!), - ), - ), + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + child: Text( + context.l10n.added_track_to_queue(track.name!), + textAlign: TextAlign.center, + ), + ); + }, ); } break; case TrackOptionValue.playNext: playback.addTracksAtFirst([track]); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.track_will_play_next(track.name!), - ), - ), - ); + + if (context.mounted) { + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + child: Text( + context.l10n.track_will_play_next(track.name!), + textAlign: TextAlign.center, + ), + ); + }, + ); + } break; case TrackOptionValue.removeFromQueue: playback.removeTrack(track.id!); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.removed_track_from_queue( - track.name!, - ), - ), - ), - ); + + if (context.mounted) { + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + child: Text( + context.l10n.removed_track_from_queue( + track.name!, + ), + textAlign: TextAlign.center, + ), + ); + }, + ); + } break; case TrackOptionValue.favorite: favorites.toggleTrackLike(track); @@ -283,7 +308,10 @@ class TrackOptions extends HookConsumerWidget { case TrackOptionValue.details: showDialog( context: context, - builder: (context) => TrackDetailsDialog(track: track), + builder: (context) => ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: TrackDetailsDialog(track: track), + ), ); break; case TrackOptionValue.download: @@ -296,8 +324,7 @@ class TrackOptions extends HookConsumerWidget { }, icon: icon ?? const Icon(SpotubeIcons.moreHorizontal), headings: [ - ListTile( - dense: true, + Basic( leading: AspectRatio( aspectRatio: 1, child: ClipRRect( @@ -313,18 +340,13 @@ class TrackOptions extends HookConsumerWidget { track.name!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), + ).semiBold(), subtitle: Align( alignment: Alignment.centerLeft, child: ArtistLink( artists: track.artists!, - onOverflowArtistClick: () => ServiceUtils.pushNamed( - context, - TrackPage.name, - pathParameters: { - "id": track.id!, - }, + onOverflowArtistClick: () => context.navigateTo( + TrackRoute(trackId: track.id!), ), ), ), @@ -332,38 +354,47 @@ class TrackOptions extends HookConsumerWidget { ], children: [ if (isLocalTrack) - PopSheetEntry( + AdaptiveMenuButton( value: TrackOptionValue.delete, leading: const Icon(SpotubeIcons.trash), - title: Text(context.l10n.delete), + child: Text(context.l10n.delete), ), if (mediaQuery.smAndDown && !isLocalTrack) - PopSheetEntry( + AdaptiveMenuButton( value: TrackOptionValue.album, leading: const Icon(SpotubeIcons.album), - title: Text(context.l10n.go_to_album), - subtitle: Text(track.album!.name!), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.go_to_album), + Text( + track.album!.name!, + style: context.theme.typography.xSmall, + ), + ], + ), ), if (!playlist.containsTrack(track)) ...[ - PopSheetEntry( + AdaptiveMenuButton( value: TrackOptionValue.addToQueue, leading: const Icon(SpotubeIcons.queueAdd), - title: Text(context.l10n.add_to_queue), + child: Text(context.l10n.add_to_queue), ), - PopSheetEntry( + AdaptiveMenuButton( value: TrackOptionValue.playNext, leading: const Icon(SpotubeIcons.lightning), - title: Text(context.l10n.play_next), + child: Text(context.l10n.play_next), ), ] else - PopSheetEntry( + AdaptiveMenuButton( value: TrackOptionValue.removeFromQueue, enabled: playlist.activeTrack?.id != track.id, leading: const Icon(SpotubeIcons.queueRemove), - title: Text(context.l10n.remove_from_queue), + child: Text(context.l10n.remove_from_queue), ), if (me.asData?.value != null && !isLocalTrack) - PopSheetEntry( + AdaptiveMenuButton( value: TrackOptionValue.favorite, leading: favorites.isLiked ? const Icon( @@ -371,32 +402,32 @@ class TrackOptions extends HookConsumerWidget { color: Colors.pink, ) : const Icon(SpotubeIcons.heart), - title: Text( + child: Text( favorites.isLiked ? context.l10n.remove_from_favorites : context.l10n.save_as_favorite, ), ), if (auth.asData?.value != null && !isLocalTrack) ...[ - PopSheetEntry( + AdaptiveMenuButton( value: TrackOptionValue.startRadio, leading: const Icon(SpotubeIcons.radio), - title: Text(context.l10n.start_a_radio), + child: Text(context.l10n.start_a_radio), ), - PopSheetEntry( + AdaptiveMenuButton( value: TrackOptionValue.addToPlaylist, leading: const Icon(SpotubeIcons.playlistAdd), - title: Text(context.l10n.add_to_playlist), + child: Text(context.l10n.add_to_playlist), ), ], if (userPlaylist && auth.asData?.value != null && !isLocalTrack) - PopSheetEntry( + AdaptiveMenuButton( value: TrackOptionValue.removeFromPlaylist, leading: const Icon(SpotubeIcons.removeFilled), - title: Text(context.l10n.remove_from_playlist), + child: Text(context.l10n.remove_from_playlist), ), if (!isLocalTrack) - PopSheetEntry( + AdaptiveMenuButton( value: TrackOptionValue.download, enabled: !isInQueue, leading: isInQueue @@ -407,55 +438,58 @@ class TrackOptions extends HookConsumerWidget { ); }) : const Icon(SpotubeIcons.download), - title: Text(context.l10n.download_track), + child: Text(context.l10n.download_track), ), if (!isLocalTrack) - PopSheetEntry( + AdaptiveMenuButton( value: TrackOptionValue.blacklist, - leading: const Icon(SpotubeIcons.playlistRemove), - iconColor: isBlackListed != true ? Colors.red[400] : null, - textColor: isBlackListed != true ? Colors.red[400] : null, - title: Text( + leading: Icon( + SpotubeIcons.playlistRemove, + color: isBlackListed != true ? Colors.red[400] : null, + ), + child: Text( isBlackListed == true ? context.l10n.remove_from_blacklist : context.l10n.add_to_blacklist, + style: TextStyle( + color: isBlackListed != true ? Colors.red[400] : null, + ), ), ), if (!isLocalTrack) - PopSheetEntry( + AdaptiveMenuButton( value: TrackOptionValue.share, leading: const Icon(SpotubeIcons.share), - title: Text(context.l10n.share), + child: Text(context.l10n.share), ), if (!isLocalTrack) - PopSheetEntry( + AdaptiveMenuButton( value: TrackOptionValue.songlink, leading: Assets.logos.songlinkTransparent.image( width: 22, height: 22, - color: colorScheme.onSurface.withOpacity(0.5), + color: colorScheme.foreground.withOpacity(0.5), ), - title: Text(context.l10n.song_link), + child: Text(context.l10n.song_link), ), if (!isLocalTrack) - PopSheetEntry( + AdaptiveMenuButton( value: TrackOptionValue.details, leading: const Icon(SpotubeIcons.info), - title: Text(context.l10n.details), + child: Text(context.l10n.details), ), ], ); //! This is the most ANTI pattern I've ever done, but it works showMenuCbRef?.value = (relativeRect) { - adaptivePopSheetList.showPopupMenu(context, relativeRect); + final offsetFromRect = Offset( + relativeRect.left, + relativeRect.top, + ); + adaptivePopSheetList.showDropdownMenu(context, offsetFromRect); }; - return ListTileTheme( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - child: adaptivePopSheetList, - ); + return adaptivePopSheetList; } } diff --git a/lib/components/track_tile/track_tile.dart b/lib/components/track_tile/track_tile.dart index 8ab889f8..9bb300f4 100644 --- a/lib/components/track_tile/track_tile.dart +++ b/lib/components/track_tile/track_tile.dart @@ -1,29 +1,31 @@ import 'dart:async'; +import 'package:auto_route/auto_route.dart'; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/hover_builder.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/links/artist_link.dart'; import 'package:spotube/components/links/link_text.dart'; import 'package:spotube/components/track_tile/track_options.dart'; +import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/extensions/button_variance.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; -import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/service_utils.dart'; class TrackTile extends HookConsumerWidget { /// [index] will not be shown if null @@ -88,9 +90,10 @@ class TrackTile extends HookConsumerWidget { }, child: HoverBuilder( permanentState: isSelected || constrains.smAndDown ? true : null, - builder: (context, isHovering) => ListTile( + builder: (context, isHovering) => ButtonTile( selected: isSelected, - onTap: () async { + onPressed: () async { + if (isBlackListed) return; try { isLoading.value = true; await onTap?.call(); @@ -101,46 +104,58 @@ class TrackTile extends HookConsumerWidget { } }, onLongPress: onLongPress, - enabled: !isBlackListed, - contentPadding: EdgeInsets.zero, - tileColor: isBlackListed ? theme.colorScheme.errorContainer : null, - horizontalTitleGap: 12, - leadingAndTrailingTextStyle: theme.textTheme.bodyMedium, + style: (isBlackListed + ? ButtonVariance.destructive + : ButtonVariance.ghost) + .copyWith( + padding: (context, states) => + const EdgeInsets.symmetric(vertical: 8, horizontal: 0), + ), 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, + AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + crossFadeState: index != null && onChanged == null + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: Checkbox( + state: selected + ? CheckboxState.checked + : CheckboxState.unchecked, + onChanged: (state) => + onChanged?.call(state == CheckboxState.checked), ), + secondChild: constrains.smAndDown + ? const SizedBox(width: 16) + : SizedBox( + width: 50, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + '${(index ?? 0) + 1}', + maxLines: 1, + style: theme.typography.small, + textAlign: TextAlign.center, + ), + ), + ), + ), Stack( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: AspectRatio( - aspectRatio: 1, - child: UniversalImage( - path: (track.album?.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), + Container( + height: 40, + width: 40, + decoration: BoxDecoration( + borderRadius: theme.borderRadiusMd, + image: DecorationImage( fit: BoxFit.cover, + image: UniversalImage.imageProvider( + (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + ), ), ), ), @@ -148,46 +163,49 @@ class TrackTile extends HookConsumerWidget { child: AnimatedContainer( duration: const Duration(milliseconds: 300), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), + borderRadius: theme.borderRadiusMd, color: isHovering - ? Colors.black.withOpacity(0.4) + ? Colors.black.withAlpha(102) : Colors.transparent, ), ), ), Positioned.fill( child: Center( - child: IconTheme( - data: theme.iconTheme - .copyWith(size: 26, color: Colors.white), - child: Skeleton.ignore( - child: Consumer( - builder: (context, ref, _) { - final isFetchingActiveTrack = - ref.watch(queryingTrackInfoProvider); - return AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: (isPlaying && isFetchingActiveTrack) || - isLoading.value - ? const SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: Colors.white, - ), - ) - : isPlaying - ? Icon( - SpotubeIcons.pause, - color: theme.colorScheme.primary, - ) - : !isHovering - ? const SizedBox.shrink() - : const Icon(SpotubeIcons.play), - ); - }, - ), + child: Skeleton.ignore( + child: Consumer( + builder: (context, ref, _) { + final isFetchingActiveTrack = + ref.watch(queryingTrackInfoProvider); + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: switch (( + isPlaying, + isFetchingActiveTrack, + isPlaying, + isHovering, + isLoading.value + )) { + (true, true, _, _, _) || + (_, _, _, _, true) => + const SizedBox( + width: 26, + height: 26, + child: + CircularProgressIndicator(size: 1.5), + ), + (_, _, true, _, _) => Icon( + SpotubeIcons.pause, + color: theme.colorScheme.primary, + ), + (_, _, _, true, _) => const Icon( + SpotubeIcons.play, + color: Colors.white, + ), + _ => const SizedBox.shrink(), + }, + ); + }, ), ), ), @@ -206,12 +224,26 @@ class TrackTile extends HookConsumerWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), - _ => LinkText( - track.name!, - "/track/${track.id}", - push: true, - maxLines: 1, - overflow: TextOverflow.ellipsis, + _ => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Button( + style: ButtonVariance.link.copyWith( + padding: (context, states) => EdgeInsets.zero, + ), + onPressed: () { + context + .navigateTo(TrackRoute(trackId: track.id!)); + }, + child: Text( + track.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], ), }, ), @@ -229,8 +261,8 @@ class TrackTile extends HookConsumerWidget { alignment: Alignment.centerLeft, child: LinkText( track.album!.name!, - "/album/${track.album?.id}", - extra: track.album, + AlbumRoute( + album: track.album!, id: track.album!.id!), push: true, overflow: TextOverflow.ellipsis, ), @@ -251,13 +283,11 @@ class TrackTile extends HookConsumerWidget { constraints: const BoxConstraints(maxHeight: 40), child: ArtistLink( artists: track.artists ?? [], - onOverflowArtistClick: () => ServiceUtils.pushNamed( - context, - TrackPage.name, - pathParameters: { - "id": track.id!, - }, - ), + onOverflowArtistClick: () { + context.navigateTo( + TrackRoute(trackId: track.id!), + ); + }, ), ), ), diff --git a/lib/components/tracks_view/sections/body/track_view_body.dart b/lib/components/tracks_view/sections/body/track_view_body.dart deleted file mode 100644 index 0f161b0c..00000000 --- a/lib/components/tracks_view/sections/body/track_view_body.dart +++ /dev/null @@ -1,192 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:fuzzywuzzy/fuzzywuzzy.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/components/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/expandable_search/expandable_search.dart'; -import 'package:spotube/components/track_tile/track_tile.dart'; -import 'package:spotube/components/tracks_view/sections/body/track_view_body_headers.dart'; -import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; -import 'package:spotube/components/tracks_view/track_view_provider.dart'; -import 'package:spotube/extensions/list.dart'; -import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/provider/connect/connect.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:very_good_infinite_list/very_good_infinite_list.dart'; - -class TrackViewBodySection extends HookConsumerWidget { - const TrackViewBodySection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final playlist = ref.watch(audioPlayerProvider); - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryActionsProvider); - final props = InheritedTrackView.of(context); - final trackViewState = ref.watch(trackViewProvider(props.tracks)); - - 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); - - final onTapTrackTile = useCallback((Track track, int index) async { - if (trackViewState.isSelecting) { - trackViewState.toggleTrackSelection(track.id!); - return; - } - - final isRemoteDevice = await showSelectDeviceDialog(context, ref); - - if (isRemoteDevice) { - final remotePlayback = ref.read(connectProvider.notifier); - final remoteQueue = ref.read(queueProvider); - if (remoteQueue.collections.contains(props.collectionId) || - remoteQueue.tracks.any((s) => s.id == track.id)) { - await playlistNotifier.jumpToTrack(track); - } else { - final tracks = await props.pagination.onFetchAll(); - await remotePlayback.load( - props.collection is AlbumSimple - ? WebSocketLoadEventData.album( - tracks: tracks, - collection: props.collection as AlbumSimple, - initialIndex: index, - ) - : WebSocketLoadEventData.playlist( - tracks: tracks, - collection: props.collection as PlaylistSimple, - initialIndex: index, - ), - ); - } - } else { - if (isActive || playlist.tracks.containsBy(track, (a) => a.id)) { - await playlistNotifier.jumpToTrack(track); - } else { - final tracks = await props.pagination.onFetchAll(); - await playlistNotifier.load( - tracks, - initialIndex: index, - autoPlay: true, - ); - playlistNotifier.addCollection(props.collectionId); - if (props.collection is AlbumSimple) { - historyNotifier.addAlbums([props.collection as AlbumSimple]); - } else { - historyNotifier.addPlaylists([props.collection as PlaylistSimple]); - } - } - } - }, [isActive, playlist, props, playlistNotifier, historyNotifier]); - - return SliverMainAxisGroup( - slivers: [ - SliverToBoxAdapter( - 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( - playlist: playlist, - track: FakeData.track, - index: 0, - ), - ), - emptyBuilder: (context) => Skeletonizer( - enabled: true, - child: Column( - children: List.generate( - 10, - (index) => TrackTile( - track: FakeData.track, - index: index, - playlist: playlist, - ), - ), - ), - ), - itemBuilder: (context, index) { - final track = tracks[index]; - return TrackTile( - playlist: playlist, - 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: () => onTapTrackTile(track, index), - ); - }, - ), - ), - ], - ); - } -} diff --git a/lib/components/tracks_view/sections/body/track_view_body_headers.dart b/lib/components/tracks_view/sections/body/track_view_body_headers.dart deleted file mode 100644 index 82cc7706..00000000 --- a/lib/components/tracks_view/sections/body/track_view_body_headers.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/expandable_search/expandable_search.dart'; -import 'package:spotube/components/sort_tracks_dropdown.dart'; -import 'package:spotube/components/tracks_view/sections/body/track_view_options.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; -import 'package:spotube/components/tracks_view/track_view_provider.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/utils/platform.dart'; - -class TrackViewBodyHeaders extends HookConsumerWidget { - final ValueNotifier isFiltering; - final FocusNode searchFocus; - - const TrackViewBodyHeaders({ - super.key, - required this.isFiltering, - required this.searchFocus, - }); - - @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(), - if (kIsDesktop) const Gap(10), - ], - ); - }, - ); - } -} diff --git a/lib/components/tracks_view/sections/body/track_view_options.dart b/lib/components/tracks_view/sections/body/track_view_options.dart deleted file mode 100644 index 23198aec..00000000 --- a/lib/components/tracks_view/sections/body/track_view_options.dart +++ /dev/null @@ -1,140 +0,0 @@ -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/adaptive/adaptive_pop_sheet_list.dart'; -import 'package:spotube/components/dialogs/confirm_download_dialog.dart'; -import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; -import 'package:spotube/components/tracks_view/track_view_provider.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class TrackViewBodyOptions extends HookConsumerWidget { - const TrackViewBodyOptions({super.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(audioPlayerProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryActionsProvider); - 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); - if (props.collection is AlbumSimple) { - historyNotifier.addAlbums([props.collection as AlbumSimple]); - } else { - historyNotifier - .addPlaylists([props.collection as PlaylistSimple]); - } - trackViewState.deselectAll(); - break; - } - case "add-to-queue": - { - playlistNotifier.addTracks(selectedTracks); - playlistNotifier.addCollection(props.collectionId); - if (props.collection is AlbumSimple) { - historyNotifier.addAlbums([props.collection as AlbumSimple]); - } else { - historyNotifier - .addPlaylists([props.collection as PlaylistSimple]); - } - trackViewState.deselectAll(); - break; - } - 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/tracks_view/sections/header/flexible_header.dart b/lib/components/tracks_view/sections/header/flexible_header.dart deleted file mode 100644 index 508d289c..00000000 --- a/lib/components/tracks_view/sections/header/flexible_header.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/tracks_view/sections/header/header_actions.dart'; -import 'package:spotube/components/tracks_view/sections/header/header_buttons.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; -import 'package:gap/gap.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/string.dart'; -import 'package:spotube/hooks/utils/use_palette_color.dart'; -import 'package:spotube/utils/platform.dart'; - -class TrackViewFlexHeader extends HookConsumerWidget { - const TrackViewFlexHeader({super.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 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: kIsMobile, - 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 (props.description != null && - props.description!.isNotEmpty) - Text( - props.description! - .unescapeHtml() - .cleanHtml(), - 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/tracks_view/sections/header/header_actions.dart b/lib/components/tracks_view/sections/header/header_actions.dart deleted file mode 100644 index 8e378f97..00000000 --- a/lib/components/tracks_view/sections/header/header_actions.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; -import 'package:spotube/components/heart_button/heart_button.dart'; -import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication/authentication.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; - -class TrackViewHeaderActions extends HookConsumerWidget { - const TrackViewHeaderActions({super.key}); - - @override - Widget build(BuildContext context, ref) { - final props = InheritedTrackView.of(context); - - final playlist = ref.watch(audioPlayerProvider); - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryActionsProvider); - - final isActive = playlist.collections.contains(props.collectionId); - - final isUserPlaylist = useIsUserPlaylist(ref, props.collectionId); - - final scaffoldMessenger = ScaffoldMessenger.of(context); - - final auth = ref.watch(authenticationProvider); - - final copiedText = - context.l10n.copied_shareurl_to_clipboard(props.shareUrl); - - 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( - copiedText, - 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.collection is AlbumSimple) { - historyNotifier - .addAlbums([props.collection as AlbumSimple]); - } else { - historyNotifier - .addPlaylists([props.collection as PlaylistSimple]); - } - }, - ), - if (props.onHeart != null && auth.asData?.value != null) - HeartButton( - isLiked: props.isLiked, - icon: isUserPlaylist ? SpotubeIcons.trash : null, - tooltip: props.isLiked - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - onPressed: () async { - final shouldPop = await props.onHeart?.call(); - if (isUserPlaylist && shouldPop == true && context.mounted) { - context.pop(); - } - }, - ), - if (isUserPlaylist) - IconButton( - icon: const Icon(SpotubeIcons.edit), - onPressed: () { - showDialog( - context: context, - builder: (context) { - return PlaylistCreateDialog( - playlistId: props.collectionId, - trackIds: props.tracks.map((e) => e.id!).toList(), - ); - }, - ); - }, - ), - ], - ); - } -} diff --git a/lib/components/tracks_view/sections/header/header_buttons.dart b/lib/components/tracks_view/sections/header/header_buttons.dart deleted file mode 100644 index 54e0f0cf..00000000 --- a/lib/components/tracks_view/sections/header/header_buttons.dart +++ /dev/null @@ -1,206 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:palette_generator/palette_generator.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/provider/connect/connect.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; - -class TrackViewHeaderButtons extends HookConsumerWidget { - final PaletteColor color; - final bool compact; - const TrackViewHeaderButtons({ - super.key, - required this.color, - this.compact = false, - }); - - @override - Widget build(BuildContext context, ref) { - final props = InheritedTrackView.of(context); - final playlist = ref.watch(audioPlayerProvider); - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryActionsProvider); - - final isActive = playlist.collections.contains(props.collectionId); - - final isLoading = useState(false); - - const progressIndicator = Center( - child: SizedBox.square( - dimension: 20, - child: CircularProgressIndicator(strokeWidth: .8), - ), - ); - - void onShuffle() async { - try { - isLoading.value = true; - - final initialTracks = props.tracks; - if (!context.mounted) return; - - final isRemoteDevice = await showSelectDeviceDialog(context, ref); - if (isRemoteDevice) { - final allTracks = await props.pagination.onFetchAll(); - final remotePlayback = ref.read(connectProvider.notifier); - await remotePlayback.load( - props.collection is AlbumSimple - ? WebSocketLoadEventData.album( - tracks: allTracks, - collection: props.collection as AlbumSimple, - initialIndex: Random().nextInt(allTracks.length)) - : WebSocketLoadEventData.playlist( - tracks: allTracks, - collection: props.collection as PlaylistSimple, - initialIndex: Random().nextInt(allTracks.length), - ), - ); - await remotePlayback.setShuffle(true); - } else { - await playlistNotifier.load( - initialTracks, - autoPlay: true, - initialIndex: Random().nextInt(initialTracks.length), - ); - await audioPlayer.setShuffle(true); - playlistNotifier.addCollection(props.collectionId); - if (props.collection is AlbumSimple) { - historyNotifier.addAlbums([props.collection as AlbumSimple]); - } else { - historyNotifier.addPlaylists([props.collection as PlaylistSimple]); - } - - final allTracks = await props.pagination.onFetchAll(); - - await playlistNotifier.addTracks( - allTracks.sublist(initialTracks.length), - ); - } - } finally { - isLoading.value = false; - } - } - - void onPlay() async { - try { - isLoading.value = true; - - final initialTracks = props.tracks; - - if (!context.mounted) return; - - final isRemoteDevice = await showSelectDeviceDialog(context, ref); - if (isRemoteDevice) { - final allTracks = await props.pagination.onFetchAll(); - final remotePlayback = ref.read(connectProvider.notifier); - await remotePlayback.load( - props.collection is AlbumSimple - ? WebSocketLoadEventData.album( - tracks: allTracks, - collection: props.collection as AlbumSimple, - ) - : WebSocketLoadEventData.playlist( - tracks: allTracks, - collection: props.collection as PlaylistSimple, - ), - ); - } else { - await playlistNotifier.load(initialTracks, autoPlay: true); - playlistNotifier.addCollection(props.collectionId); - if (props.collection is AlbumSimple) { - historyNotifier.addAlbums([props.collection as AlbumSimple]); - } else { - historyNotifier.addPlaylists([props.collection as PlaylistSimple]); - } - - final allTracks = await props.pagination.onFetchAll(); - - await playlistNotifier.addTracks( - allTracks.sublist(initialTracks.length), - ); - } - } finally { - if (context.mounted) { - isLoading.value = false; - } - } - } - - if (compact) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (!isActive && !isLoading.value) - IconButton( - icon: const Icon(SpotubeIcons.shuffle), - onPressed: props.tracks.isEmpty ? null : onShuffle, - ), - const Gap(10), - IconButton.filledTonal( - icon: isActive - ? const Icon(SpotubeIcons.pause) - : isLoading.value - ? progressIndicator - : const Icon(SpotubeIcons.play), - onPressed: isActive || props.tracks.isEmpty || isLoading.value - ? null - : onPlay, - ), - const Gap(10), - ], - ); - } - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedOpacity( - duration: const Duration(milliseconds: 300), - opacity: isActive || isLoading.value ? 0 : 1, - child: AnimatedSize( - duration: const Duration(milliseconds: 300), - child: SizedBox.square( - dimension: isActive || isLoading.value ? 0 : null, - child: FilledButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - minimumSize: const Size(150, 40)), - label: Text(context.l10n.shuffle), - icon: const Icon(SpotubeIcons.shuffle), - onPressed: props.tracks.isEmpty ? null : onShuffle, - ), - ), - ), - ), - const Gap(10), - FilledButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: color.color, - foregroundColor: color.bodyTextColor, - minimumSize: const Size(150, 40)), - onPressed: isActive || props.tracks.isEmpty || isLoading.value - ? null - : onPlay, - icon: isActive - ? const Icon(SpotubeIcons.pause) - : isLoading.value - ? progressIndicator - : const Icon(SpotubeIcons.play), - label: Text(context.l10n.play), - ), - ], - ); - } -} diff --git a/lib/components/tracks_view/track_view.dart b/lib/components/tracks_view/track_view.dart deleted file mode 100644 index 2a3f5237..00000000 --- a/lib/components/tracks_view/track_view.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:sliver_tools/sliver_tools.dart'; -import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/components/tracks_view/sections/header/flexible_header.dart'; -import 'package:spotube/components/tracks_view/sections/body/track_view_body.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; -import 'package:spotube/utils/platform.dart'; - -class TrackView extends HookConsumerWidget { - const TrackView({super.key}); - - @override - Widget build(BuildContext context, ref) { - final props = InheritedTrackView.of(context); - final controller = useScrollController(); - - 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: true, - body: RefreshIndicator( - onRefresh: props.pagination.onRefresh, - child: InterScrollbar( - controller: controller, - child: CustomScrollView( - controller: controller, - slivers: const [ - TrackViewFlexHeader(), - SliverAnimatedSwitcher( - duration: Duration(milliseconds: 500), - child: TrackViewBodySection(), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/components/tracks_view/track_view_provider.dart b/lib/components/tracks_view/track_view_provider.dart deleted file mode 100644 index 16aa6d9c..00000000 --- a/lib/components/tracks_view/track_view_provider.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/modules/library/user_local_tracks.dart'; - -class TrackViewNotifier extends ChangeNotifier { - List tracks; - List selectedTrackIds; - SortBy sortBy; - String? searchQuery; - - TrackViewNotifier( - this.tracks, { - this.selectedTrackIds = const [], - this.sortBy = SortBy.none, - this.searchQuery, - }); - - bool get isSelecting => selectedTrackIds.isNotEmpty; - - bool get hasSelectedAll => - selectedTrackIds.length == tracks.length && tracks.isNotEmpty; - - List get selectedTracks => - tracks.where((e) => selectedTrackIds.contains(e.id)).toList(); - - void selectTrack(String trackId) { - selectedTrackIds = [...selectedTrackIds, trackId]; - notifyListeners(); - } - - void unselectTrack(String trackId) { - selectedTrackIds = selectedTrackIds.where((e) => e != trackId).toList(); - notifyListeners(); - } - - void toggleTrackSelection(String trackId) { - if (selectedTrackIds.contains(trackId)) { - unselectTrack(trackId); - } else { - selectTrack(trackId); - } - } - - void selectAll() { - selectedTrackIds = tracks.map((e) => e.id!).toList(); - notifyListeners(); - } - - void deselectAll() { - selectedTrackIds = []; - notifyListeners(); - } - - void sort(SortBy sortBy) { - this.sortBy = sortBy; - notifyListeners(); - } -} - -final trackViewProvider = ChangeNotifierProvider.autoDispose - .family>((ref, tracks) { - return TrackViewNotifier(tracks); -}); diff --git a/lib/components/ui/button_tile.dart b/lib/components/ui/button_tile.dart new file mode 100644 index 00000000..8f5a7581 --- /dev/null +++ b/lib/components/ui/button_tile.dart @@ -0,0 +1,109 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class ButtonTile extends StatelessWidget { + final Widget? title; + final Widget? subtitle; + final Widget? leading; + final Widget? trailing; + final bool enabled; + final VoidCallback? onPressed; + final VoidCallback? onLongPress; + final bool selected; + final ButtonVariance style; + final EdgeInsets? padding; + + const ButtonTile({ + super.key, + this.title, + this.subtitle, + this.leading, + this.trailing, + this.enabled = true, + this.onPressed, + this.onLongPress, + this.selected = false, + this.padding, + this.style = ButtonVariance.outline, + }); + + @override + Widget build(BuildContext context) { + final ThemeData(:colorScheme, :typography) = Theme.of(context); + + return GestureDetector( + onLongPress: onLongPress, + child: Button( + enabled: enabled, + onPressed: onPressed, + style: style.copyWith( + padding: + padding != null ? (context, states, value) => padding! : null, + decoration: (context, states, value) { + final decoration = + style.decoration(context, states) as BoxDecoration; + + if (selected) { + return switch (style) { + ButtonVariance.outline => decoration.copyWith( + border: Border.all( + color: colorScheme.primary, + width: 1.0, + ), + color: colorScheme.primary.withAlpha(25), + ), + ButtonVariance.ghost || _ => decoration.copyWith( + color: colorScheme.primary.withAlpha(25), + ), + }; + } + + return decoration; + }, + iconTheme: (context, states, value) { + final iconTheme = style.iconTheme(context, states); + + if (selected && style == ButtonVariance.outline) { + return iconTheme.copyWith( + color: colorScheme.primary, + ); + } + + return iconTheme; + }, + textStyle: (context, states, value) { + final textStyle = style.textStyle(context, states); + + if (selected && style == ButtonVariance.outline) { + return textStyle.copyWith( + color: colorScheme.primary, + ); + } + + return textStyle; + }, + ), + alignment: Alignment.centerLeft, + child: SizedBox( + width: double.infinity, + child: Basic( + padding: EdgeInsets.zero, + leadingAlignment: Alignment.center, + trailingAlignment: Alignment.center, + leading: leading, + title: title, + subtitle: + style == ButtonVariance.outline && selected && subtitle != null + ? DefaultTextStyle( + style: typography.xSmall.copyWith( + color: colorScheme.primary, + ), + child: subtitle!, + ) + : subtitle, + trailing: trailing, + ), + ), + ), + ); + } +} diff --git a/lib/extensions/button_variance.dart b/lib/extensions/button_variance.dart new file mode 100644 index 00000000..cf66d528 --- /dev/null +++ b/lib/extensions/button_variance.dart @@ -0,0 +1,21 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +extension CopyWithButtonVarianceExtension on ButtonVariance { + ButtonVariance copyWith({ + ButtonStateProperty? padding, + ButtonStateProperty? decoration, + ButtonStateProperty? mouseCursor, + ButtonStateProperty? iconTheme, + ButtonStateProperty? margin, + ButtonStateProperty? textStyle, + }) { + return ButtonVariance( + padding: padding ?? this.padding, + decoration: decoration ?? this.decoration, + mouseCursor: mouseCursor ?? this.mouseCursor, + iconTheme: iconTheme ?? this.iconTheme, + margin: margin ?? this.margin, + textStyle: textStyle ?? this.textStyle, + ); + } +} diff --git a/lib/extensions/color.dart b/lib/extensions/color.dart index 68cd8ef7..bc7d65a2 100644 --- a/lib/extensions/color.dart +++ b/lib/extensions/color.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; extension ColorAlterer on Color { Color darken(double amount) { diff --git a/lib/extensions/constrains.dart b/lib/extensions/constrains.dart index dc1027e2..b7353c4f 100644 --- a/lib/extensions/constrains.dart +++ b/lib/extensions/constrains.dart @@ -106,3 +106,22 @@ extension ScreenBreakpoints on MediaQueryData { bool get lgAndDown => isXs || isSm || isMd || isLg; bool get xlAndDown => isXs || isSm || isMd || isLg || isXl; } + +extension SizeBreakpoints on Size { + bool get isXs => width <= Breakpoints.xs; + bool get isSm => width > Breakpoints.xs && width <= Breakpoints.sm; + bool get isMd => width > Breakpoints.sm && width <= Breakpoints.md; + bool get isLg => width > Breakpoints.md && width <= Breakpoints.lg; + bool get isXl => width > Breakpoints.lg && width <= Breakpoints.xl; + bool get is2Xl => width > Breakpoints.xl; + + bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl; + bool get mdAndUp => isMd || isLg || isXl || is2Xl; + bool get lgAndUp => isLg || isXl || is2Xl; + bool get xlAndUp => isXl || is2Xl; + + bool get smAndDown => isXs || isSm; + bool get mdAndDown => isXs || isSm || isMd; + bool get lgAndDown => isXs || isSm || isMd || isLg; + bool get xlAndDown => isXs || isSm || isMd || isLg || isXl; +} diff --git a/lib/extensions/context.dart b/lib/extensions/context.dart index 9ca1e237..f6c5915c 100644 --- a/lib/extensions/context.dart +++ b/lib/extensions/context.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; extension AppLocale on BuildContext { diff --git a/lib/extensions/page.dart b/lib/extensions/page.dart deleted file mode 100644 index 34343fb5..00000000 --- a/lib/extensions/page.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:spotify/spotify.dart'; - -extension CursorPageJson on CursorPage { - static CursorPage fromJson( - Map json, - T Function(dynamic json) itemFromJson, - ) { - final metadata = Paging.fromJson(json["metadata"]); - final paging = CursorPaging(); - paging.cursors = Cursor.fromJson(json["metadata"])..after = json["after"]; - paging.href = metadata.href; - paging.itemsNative = paging.itemsNative; - paging.limit = metadata.limit; - paging.next = metadata.next; - return CursorPage( - paging, - itemFromJson, - ); - } - - Map toJson() { - return { - "after": after, - "metadata": metadata.toJson(), - }; - } -} - -extension PagingToJson on Paging { - Map toJson() { - return { - "items": itemsNative, - "total": total, - "next": next, - "previous": previous, - "limit": limit, - "offset": offset, - "href": href, - }; - } -} - -extension PageJson on Page { - static Page fromJson( - Map json, - T Function(dynamic json) itemFromJson, - ) { - return Page( - Paging.fromJson( - Map.castFrom(json["metadata"]), - ), - itemFromJson, - ); - } - - Map toJson() { - return { - "metadata": metadata.toJson(), - }; - } -} diff --git a/lib/extensions/theme.dart b/lib/extensions/theme.dart deleted file mode 100644 index 22a1ce84..00000000 --- a/lib/extensions/theme.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/material.dart'; - -class ShimmerColorTheme extends ThemeExtension { - final Color? shimmerColor; - final Color? shimmerBackgroundColor; - - ShimmerColorTheme({ - this.shimmerBackgroundColor, - this.shimmerColor, - }); - - @override - ThemeExtension copyWith( - {Color? shimmerColor, Color? shimmerBackgroundColor}) { - return ShimmerColorTheme( - shimmerBackgroundColor: - shimmerBackgroundColor ?? this.shimmerBackgroundColor, - shimmerColor: shimmerColor ?? this.shimmerColor, - ); - } - - @override - ThemeExtension lerp( - ThemeExtension? other, double t) { - if (other is! ShimmerColorTheme) { - return this; - } - return ShimmerColorTheme( - shimmerBackgroundColor: - Color.lerp(shimmerBackgroundColor, other.shimmerBackgroundColor, t), - shimmerColor: Color.lerp(shimmerColor, other.shimmerColor, t), - ); - } -} diff --git a/lib/hooks/configurators/use_check_yt_dlp_installed.dart b/lib/hooks/configurators/use_check_yt_dlp_installed.dart new file mode 100644 index 00000000..1d948258 --- /dev/null +++ b/lib/hooks/configurators/use_check_yt_dlp_installed.dart @@ -0,0 +1,40 @@ +import 'dart:io'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/modules/settings/youtube_engine_not_installed_dialog.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; +import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart'; + +void useCheckYtDlpInstalled(WidgetRef ref) { + final context = useContext(); + + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + final youtubeEngine = ref.read( + userPreferencesProvider.select( + (value) => value.youtubeClientEngine, + ), + ); + + final customPath = + KVStoreService.getYoutubeEnginePath(YoutubeClientEngine.ytDlp); + + if (youtubeEngine == YoutubeClientEngine.ytDlp && + !await YtDlpEngine.isInstalled() && + (customPath == null || !await File(customPath).exists()) && + context.mounted) { + await showDialog( + context: context, + builder: (context) => + YouTubeEngineNotInstalledDialog(engine: youtubeEngine), + ); + } + }); + + return null; + }, []); +} diff --git a/lib/hooks/configurators/use_deep_linking.dart b/lib/hooks/configurators/use_deep_linking.dart index ec6d8516..67000d49 100644 --- a/lib/hooks/configurators/use_deep_linking.dart +++ b/lib/hooks/configurators/use_deep_linking.dart @@ -4,6 +4,7 @@ import 'package:app_links/app_links.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/routes.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:flutter_sharing_intent/flutter_sharing_intent.dart'; import 'package:flutter_sharing_intent/model/sharing_file.dart'; @@ -13,10 +14,9 @@ import 'package:spotube/utils/platform.dart'; final appLinks = AppLinks(); final linkStream = appLinks.stringLinkStream.asBroadcastStream(); -void useDeepLinking(WidgetRef ref) { +void useDeepLinking(WidgetRef ref, AppRouter router) { // single instance no worries final spotify = ref.watch(spotifyProvider); - final router = ref.watch(routerProvider); useEffect(() { void uriListener(List files) async { @@ -27,24 +27,21 @@ void useDeepLinking(WidgetRef ref) { switch (url.pathSegments.first) { case "album": - router.push( - "/album/${url.pathSegments.last}", - extra: await spotify.albums.get(url.pathSegments.last), + final album = await spotify.albums.get(url.pathSegments.last); + router.navigate( + AlbumRoute(id: album.id!, album: album), ); break; case "artist": - router.push("/artist/${url.pathSegments.last}"); + router.navigate(ArtistRoute(artistId: url.pathSegments.last)); break; case "playlist": - router.push( - "/playlist/${url.pathSegments.last}", - extra: await spotify.playlists.get(url.pathSegments.last), - ); + final playlist = await spotify.playlists.get(url.pathSegments.last); + router + .navigate(PlaylistRoute(id: playlist.id!, playlist: playlist)); break; case "track": - router.push( - "/track/${url.pathSegments.last}", - ); + router.navigate(TrackRoute(trackId: url.pathSegments.last)); break; default: break; @@ -68,21 +65,21 @@ void useDeepLinking(WidgetRef ref) { switch (startSegment) { case "spotify:album": - await router.push( - "/album/$endSegment", - extra: await spotify.albums.get(endSegment), + final album = await spotify.albums.get(endSegment); + await router.navigate( + AlbumRoute(id: album.id!, album: album), ); break; case "spotify:artist": - await router.push("/artist/$endSegment"); + await router.navigate(ArtistRoute(artistId: endSegment)); break; case "spotify:track": - await router.push("/track/$endSegment"); + await router.navigate(TrackRoute(trackId: endSegment)); break; case "spotify:playlist": - await router.push( - "/playlist/$endSegment", - extra: await spotify.playlists.get(endSegment), + final playlist = await spotify.playlists.get(endSegment); + await router.navigate( + PlaylistRoute(id: playlist.id!, playlist: playlist), ); break; default: diff --git a/lib/hooks/configurators/use_fix_window_stretching.dart b/lib/hooks/configurators/use_fix_window_stretching.dart index a6603d59..b94098ab 100644 --- a/lib/hooks/configurators/use_fix_window_stretching.dart +++ b/lib/hooks/configurators/use_fix_window_stretching.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:spotube/utils/platform.dart'; import 'package:window_manager/window_manager.dart'; diff --git a/lib/hooks/configurators/use_has_touch.dart b/lib/hooks/configurators/use_has_touch.dart index 75353f27..5ce309b8 100644 --- a/lib/hooks/configurators/use_has_touch.dart +++ b/lib/hooks/configurators/use_has_touch.dart @@ -1,5 +1,5 @@ import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:spotube/utils/platform.dart'; diff --git a/lib/hooks/controllers/use_auto_scroll_controller.dart b/lib/hooks/controllers/use_auto_scroll_controller.dart index 0c7119e4..befc4351 100644 --- a/lib/hooks/controllers/use_auto_scroll_controller.dart +++ b/lib/hooks/controllers/use_auto_scroll_controller.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; diff --git a/lib/hooks/controllers/use_package_info.dart b/lib/hooks/controllers/use_package_info.dart index b3c05665..07b53af6 100644 --- a/lib/hooks/controllers/use_package_info.dart +++ b/lib/hooks/controllers/use_package_info.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:package_info_plus/package_info_plus.dart'; diff --git a/lib/hooks/controllers/use_shadcn_text_editing_controller.dart b/lib/hooks/controllers/use_shadcn_text_editing_controller.dart new file mode 100644 index 00000000..ae33f4e4 --- /dev/null +++ b/lib/hooks/controllers/use_shadcn_text_editing_controller.dart @@ -0,0 +1,97 @@ +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class _TextEditingControllerHookCreator { + const _TextEditingControllerHookCreator(); + + /// Creates a [TextEditingController] that will be disposed automatically. + /// + /// The [text] parameter can be used to set the initial value of the + /// controller. + TextEditingController call({String? text, List? keys}) { + return use(_TextEditingControllerHook(text, keys)); + } + + /// Creates a [TextEditingController] from the initial [value] that will + /// be disposed automatically. + TextEditingController fromValue( + TextEditingValue value, [ + List? keys, + ]) { + return use(_TextEditingControllerHook.fromValue(value, keys)); + } +} + +/// Creates a [TextEditingController], either via an initial text or an initial +/// [TextEditingValue]. +/// +/// To use a [TextEditingController] with an optional initial text, use: +/// ```dart +/// final controller = useTextEditingController(text: 'initial text'); +/// ``` +/// +/// To use a [TextEditingController] with an optional initial value, use: +/// ```dart +/// final controller = useTextEditingController +/// .fromValue(TextEditingValue.empty); +/// ``` +/// +/// Changing the text or initial value after the widget has been built has no +/// effect whatsoever. To update the value in a callback, for instance after a +/// button was pressed, use the [TextEditingController.text] or +/// [TextEditingController.value] setters. To have the [TextEditingController] +/// reflect changing values, you can use [useEffect]. This example will update +/// the [TextEditingController.text] whenever a provided [ValueListenable] +/// changes: +/// ```dart +/// final controller = useTextEditingController(); +/// final update = useValueListenable(myTextControllerUpdates); +/// +/// useEffect(() { +/// controller.text = update; +/// }, [update]); +/// ``` +/// +/// See also: +/// - [TextEditingController], which this hook creates. +const useShadcnTextEditingController = _TextEditingControllerHookCreator(); + +class _TextEditingControllerHook extends Hook { + const _TextEditingControllerHook( + this.initialText, [ + List? keys, + ]) : initialValue = null, + super(keys: keys); + + const _TextEditingControllerHook.fromValue( + TextEditingValue this.initialValue, [ + List? keys, + ]) : initialText = null, + super(keys: keys); + + final String? initialText; + final TextEditingValue? initialValue; + + @override + _TextEditingControllerHookState createState() { + return _TextEditingControllerHookState(); + } +} + +class _TextEditingControllerHookState + extends HookState { + late final _controller = hook.initialValue != null + ? TextEditingController.fromValue( + hook.initialValue ?? TextEditingValue.empty, + ) + : TextEditingController(text: hook.initialText); + + @override + TextEditingController build(BuildContext context) => _controller; + + @override + void dispose() => _controller.dispose(); + + @override + String get debugLabel => 'useTextEditingController'; +} diff --git a/lib/hooks/controllers/use_sidebarx_controller.dart b/lib/hooks/controllers/use_sidebarx_controller.dart deleted file mode 100644 index a14c3305..00000000 --- a/lib/hooks/controllers/use_sidebarx_controller.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:sidebarx/sidebarx.dart'; - -/// Creates [SidebarXController] that will be disposed automatically. -/// -/// See also: -/// - [SidebarXController] -SidebarXController useSidebarXController({ - required int selectedIndex, - bool? extended, - List? keys, -}) { - return use( - _SidebarXControllerHook( - selectedIndex: selectedIndex, - extended: extended, - keys: keys, - ), - ); -} - -class _SidebarXControllerHook extends Hook { - const _SidebarXControllerHook({ - required this.selectedIndex, - this.extended, - super.keys, - }); - - final int selectedIndex; - final bool? extended; - - @override - HookState> createState() => - _SidebarXControllerHookState(); -} - -class _SidebarXControllerHookState - extends HookState { - late final SidebarXController controller; - - @override - void initHook() { - super.initHook(); - controller = SidebarXController( - selectedIndex: hook.selectedIndex, - extended: hook.extended, - ); - } - - @override - SidebarXController build(BuildContext context) => controller; - - @override - void dispose() => controller.dispose(); - - @override - String get debugLabel => 'useSidebarXController'; -} diff --git a/lib/hooks/utils/use_breakpoint_value.dart b/lib/hooks/utils/use_breakpoint_value.dart index b2592124..74b2f860 100644 --- a/lib/hooks/utils/use_breakpoint_value.dart +++ b/lib/hooks/utils/use_breakpoint_value.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:spotube/extensions/constrains.dart'; diff --git a/lib/hooks/utils/use_brightness_value.dart b/lib/hooks/utils/use_brightness_value.dart index d3823b2f..64e3f27c 100644 --- a/lib/hooks/utils/use_brightness_value.dart +++ b/lib/hooks/utils/use_brightness_value.dart @@ -1,4 +1,5 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; T useBrightnessValue( diff --git a/lib/hooks/utils/use_custom_status_bar_color.dart b/lib/hooks/utils/use_custom_status_bar_color.dart index 8afc6a59..f34ae7a8 100644 --- a/lib/hooks/utils/use_custom_status_bar_color.dart +++ b/lib/hooks/utils/use_custom_status_bar_color.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -9,7 +9,7 @@ VoidCallback useCustomStatusBarColor( bool? automaticSystemUiAdjustment, }) { final context = useContext(); - final backgroundColor = Theme.of(context).scaffoldBackgroundColor; + final backgroundColor = Theme.of(context).colorScheme.background; // ignore: invalid_use_of_visible_for_testing_member final previousState = SystemChrome.latestStyle; diff --git a/lib/hooks/utils/use_palette_color.dart b/lib/hooks/utils/use_palette_color.dart index 64994d2b..c70bcf72 100644 --- a/lib/hooks/utils/use_palette_color.dart +++ b/lib/hooks/utils/use_palette_color.dart @@ -1,4 +1,5 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; @@ -6,7 +7,7 @@ import 'package:spotube/components/image/universal_image.dart'; final _paletteColorState = StateProvider( (ref) { - return PaletteColor(Colors.grey[300]!, 0); + return PaletteColor(Colors.gray[300], 0); }, ); diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 8cb52b38..f4cbde9b 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -401,5 +401,30 @@ "export_cache_files": "تصدير الملفات المخزنة مؤقتًا", "found_n_files": "تم العثور على {count} ملف", "export_cache_confirmation": "هل تريد تصدير هذه الملفات إلى", - "exported_n_out_of_m_files": "تم تصدير {filesExported} من أصل {files} ملفات" + "exported_n_out_of_m_files": "تم تصدير {filesExported} من أصل {files} ملفات", + "playlist": "قائمة التشغيل", + "no_loop": "بدون تكرار", + "generate": "إنشاء", + "undo": "تراجع", + "download_all": "تنزيل الكل", + "add_all_to_playlist": "إضافة الكل إلى قائمة التشغيل", + "add_all_to_queue": "إضافة الكل إلى القائمة", + "play_all_next": "تشغيل الكل بعد ذلك", + "pause": "إيقاف مؤقت", + "view_all": "عرض الكل", + "no_tracks_added_yet": "يبدو أنك لم تضف أي مسارات بعد", + "no_tracks": "يبدو أنه لا يوجد أي مسارات هنا", + "no_tracks_listened_yet": "يبدو أنك لم تستمع إلى أي شيء بعد", + "not_following_artists": "أنت لا تتابع أي فنانين", + "no_favorite_albums_yet": "يبدو أنك لم تضف أي ألبومات إلى المفضلة بعد", + "no_logs_found": "لم يتم العثور على سجلات", + "youtube_engine": "محرك يوتيوب", + "youtube_engine_not_installed_title": "{engine} غير مثبت", + "youtube_engine_not_installed_message": "{engine} غير مثبت في نظامك.", + "youtube_engine_set_path": "تأكد من أنه متاح في متغير PATH أو\nحدد المسار الكامل للملف القابل للتنفيذ {engine} أدناه", + "youtube_engine_unix_issue_message": "في أنظمة macOS/Linux/Unix مثل الأنظمة، لن يعمل تعيين المسار في .zshrc/.bashrc/.bash_profile وما إلى ذلك.\nيجب تعيين المسار في ملف تكوين الصدفة", + "download": "تنزيل", + "file_not_found": "الملف غير موجود", + "custom": "مخصص", + "add_custom_url": "إضافة URL مخصص" } \ No newline at end of file diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index ff49aafd..cc2971ce 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -401,5 +401,30 @@ "export_cache_files": "ক্যাশে ফাইল রপ্তানি", "found_n_files": "{count} টি ফাইল পাওয়া গেছে", "export_cache_confirmation": "আপনি কি এই ফাইলগুলি রপ্তানি করতে চান", - "exported_n_out_of_m_files": "{filesExported} টি ফাইল রপ্তানি করা হয়েছে {files} এর মধ্যে" + "exported_n_out_of_m_files": "{filesExported} টি ফাইল রপ্তানি করা হয়েছে {files} এর মধ্যে", + "playlist": "প্লেলিস্ট", + "no_loop": "কোনো লুপ নেই", + "generate": "উৎপন্ন করুন", + "undo": "পূর্বাবস্থায় ফিরুন", + "download_all": "সব ডাউনলোড করুন", + "add_all_to_playlist": "সব প্লেলিস্টে যোগ করুন", + "add_all_to_queue": "সব কিউতে যোগ করুন", + "play_all_next": "সব পরবর্তী খেলুন", + "pause": "বিরতি", + "view_all": "সব দেখুন", + "no_tracks_added_yet": "এখনও কোনো ট্র্যাক যোগ করা হয়নি মনে হচ্ছে", + "no_tracks": "এখানে কোনো ট্র্যাক নেই মনে হচ্ছে", + "no_tracks_listened_yet": "এখনও কিছু শোনা হয়নি মনে হচ্ছে", + "not_following_artists": "আপনি কোনো শিল্পীকে অনুসরণ করছেন না", + "no_favorite_albums_yet": "এখনও কোনো অ্যালবাম প্রিয় তালিকায় যোগ করা হয়নি মনে হচ্ছে", + "no_logs_found": "কোনো লগ পাওয়া যায়নি", + "youtube_engine": "ইউটিউব ইঞ্জিন", + "youtube_engine_not_installed_title": "{engine} ইনস্টল করা নেই", + "youtube_engine_not_installed_message": "{engine} আপনার সিস্টেমে ইনস্টল করা নেই।", + "youtube_engine_set_path": "এটি PATH ভেরিয়েবলে উপলব্ধ কিনা নিশ্চিত করুন অথবা\nনীচে {engine} এক্সিকিউটেবল এর পূর্ণপথ সেট করুন", + "youtube_engine_unix_issue_message": "macOS/Linux/Unix-এর মতো অপারেটিং সিস্টেমে, .zshrc/.bashrc/.bash_profile ইত্যাদিতে পাথ সেট করা কাজ করবে না।\nআপনাকে শেল কনফিগারেশন ফাইলে পাথ সেট করতে হবে", + "download": "ডাউনলোড", + "file_not_found": "ফাইল পাওয়া যায়নি", + "custom": "কাস্টম", + "add_custom_url": "কাস্টম URL যোগ করুন" } \ No newline at end of file diff --git a/lib/l10n/app_ca.arb b/lib/l10n/app_ca.arb index aee39ffd..7cb007c4 100644 --- a/lib/l10n/app_ca.arb +++ b/lib/l10n/app_ca.arb @@ -401,5 +401,30 @@ "export_cache_files": "Exportar arxius en caché", "found_n_files": "S'han trobat {count} arxius", "export_cache_confirmation": "Voleu exportar aquests arxius a", - "exported_n_out_of_m_files": "S'han exportat {filesExported} de {files} arxius" + "exported_n_out_of_m_files": "S'han exportat {filesExported} de {files} arxius", + "playlist": "Llista de reproducció", + "no_loop": "Sense repetició", + "generate": "Generar", + "undo": "Desfer", + "download_all": "Descarregar tot", + "add_all_to_playlist": "Afegir tot a la llista de reproducció", + "add_all_to_queue": "Afegir tot a la cua", + "play_all_next": "Reproduir tot a continuació", + "pause": "Pausa", + "view_all": "Veure tot", + "no_tracks_added_yet": "Sembla que encara no has afegit cap pista", + "no_tracks": "Sembla que no hi ha pistes aquí", + "no_tracks_listened_yet": "Sembla que no has escoltat res encara", + "not_following_artists": "No estàs seguint cap artista", + "no_favorite_albums_yet": "Sembla que encara no has afegit cap àlbum als teus favorits", + "no_logs_found": "No s'han trobat registres", + "youtube_engine": "Motor de YouTube", + "youtube_engine_not_installed_title": "{engine} no està instal·lat", + "youtube_engine_not_installed_message": "{engine} no està instal·lat al teu sistema.", + "youtube_engine_set_path": "Assegura't que estigui disponible a la variable PATH o\nestableix el camí absolut a l'executable de {engine} a continuació", + "youtube_engine_unix_issue_message": "En macOS/Linux/Unix com a sistemes operatius, establir el camí a .zshrc/.bashrc/.bash_profile etc. no funcionarà.\nHas de configurar el camí al fitxer de configuració de la shell", + "download": "Descarregar", + "file_not_found": "Fitxer no trobat", + "custom": "Personalitzat", + "add_custom_url": "Afegir URL personalitzada" } \ No newline at end of file diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index a40251c0..a6fdf25c 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -401,5 +401,30 @@ "export_cache_files": "Exportovat soubory z mezipaměti", "found_n_files": "Nalezeno {count} souborů", "export_cache_confirmation": "Chcete exportovat tyto soubory do", - "exported_n_out_of_m_files": "Exportováno {filesExported} z {files} souborů" + "exported_n_out_of_m_files": "Exportováno {filesExported} z {files} souborů", + "playlist": "Seznam skladeb", + "no_loop": "Žádné opakování", + "generate": "Generovat", + "undo": "Zpět", + "download_all": "Stáhnout vše", + "add_all_to_playlist": "Přidat vše do seznamu skladeb", + "add_all_to_queue": "Přidat vše do fronty", + "play_all_next": "Přehrát vše následně", + "pause": "Pauza", + "view_all": "Zobrazit vše", + "no_tracks_added_yet": "Zdá se, že jste ještě nepřidali žádné skladby", + "no_tracks": "Zdá se, že zde nejsou žádné skladby", + "no_tracks_listened_yet": "Zdá se, že jste ještě nic neposlouchali", + "not_following_artists": "Nezajímáte se o žádné umělce", + "no_favorite_albums_yet": "Zdá se, že jste ještě nepřidali žádné alba mezi oblíbené", + "no_logs_found": "Žádné záznamy nenalezeny", + "youtube_engine": "YouTube Engine", + "youtube_engine_not_installed_title": "{engine} není nainstalován", + "youtube_engine_not_installed_message": "{engine} není nainstalován ve vašem systému.", + "youtube_engine_set_path": "Ujistěte se, že je k dispozici v proměnné PATH nebo\nnastavte absolutní cestu k {engine} spustitelnému souboru níže", + "youtube_engine_unix_issue_message": "V macOS/Linux/Unixových systémech nebude fungovat nastavení cesty v .zshrc/.bashrc/.bash_profile atd.\nMusíte nastavit cestu v konfiguračním souboru shellu", + "download": "Stáhnout", + "file_not_found": "Soubor nenalezen", + "custom": "Vlastní", + "add_custom_url": "Přidat vlastní URL" } \ No newline at end of file diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 76ec2218..af2b26ad 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -401,5 +401,30 @@ "export_cache_files": "Cachedateien exportieren", "found_n_files": "{count} Dateien gefunden", "export_cache_confirmation": "Möchten Sie diese Dateien exportieren nach", - "exported_n_out_of_m_files": "{filesExported} von {files} Dateien exportiert" + "exported_n_out_of_m_files": "{filesExported} von {files} Dateien exportiert", + "playlist": "Playlist", + "no_loop": "Kein Loop", + "generate": "Generieren", + "undo": "Rückgängig", + "download_all": "Alle herunterladen", + "add_all_to_playlist": "Alle zur Playlist hinzufügen", + "add_all_to_queue": "Alle zur Warteschlange hinzufügen", + "play_all_next": "Alle als Nächstes abspielen", + "pause": "Pause", + "view_all": "Alle ansehen", + "no_tracks_added_yet": "Sie haben noch keine Titel hinzugefügt.", + "no_tracks": "Es sieht so aus, als ob hier keine Titel sind.", + "no_tracks_listened_yet": "Es scheint, dass Sie noch nichts gehört haben.", + "not_following_artists": "Sie folgen noch keinem Künstler.", + "no_favorite_albums_yet": "Es sieht so aus, als ob Sie noch keine Alben zu Ihren Favoriten hinzugefügt haben.", + "no_logs_found": "Keine Protokolle gefunden", + "youtube_engine": "YouTube-Engine", + "youtube_engine_not_installed_title": "{engine} ist nicht installiert", + "youtube_engine_not_installed_message": "{engine} ist nicht auf Ihrem System installiert.", + "youtube_engine_set_path": "Stellen Sie sicher, dass es im PATH verfügbar ist oder\nsetzen Sie den absoluten Pfad zur {engine} ausführbaren Datei unten.", + "youtube_engine_unix_issue_message": "In macOS/Linux/unixähnlichen Betriebssystemen funktioniert das Setzen des Pfads in .zshrc/.bashrc/.bash_profile usw. nicht.\nSie müssen den Pfad in der Shell-Konfigurationsdatei festlegen.", + "download": "Herunterladen", + "file_not_found": "Datei nicht gefunden", + "custom": "Benutzerdefiniert", + "add_custom_url": "Benutzerdefinierte URL hinzufügen" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f949480e..e3e6d330 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,405 +1,428 @@ { - "guest": "Guest", - "browse": "Browse", - "search": "Search", - "library": "Library", - "lyrics": "Lyrics", - "settings": "Settings", - "genre_categories_filter": "Filter categories or genres...", - "genre": "Genre", - "personalized": "Personalized", - "featured": "Featured", - "new_releases": "New Releases", - "songs": "Songs", - "playing_track": "Playing {track}", - "queue_clear_alert": "This will clear the current queue. {track_length} tracks will be removed\nDo you want to continue?", - "load_more": "Load more", - "playlists": "Playlists", - "artists": "Artists", - "albums": "Albums", - "tracks": "Tracks", - "downloads": "Downloads", - "filter_playlists": "Filter your playlists...", - "liked_tracks": "Liked Tracks", - "liked_tracks_description": "All your liked tracks", - "create_playlist": "Create Playlist", - "create_a_playlist": "Create a playlist", - "update_playlist": "Update playlist", - "create": "Create", - "cancel": "Cancel", - "update": "Update", - "playlist_name": "Playlist Name", - "name_of_playlist": "Name of the playlist", - "description": "Description", - "public": "Public", - "collaborative": "Collaborative", - "search_local_tracks": "Search local tracks...", - "play": "Play", - "delete": "Delete", - "none": "None", - "sort_a_z": "Sort by A-Z", - "sort_z_a": "Sort by Z-A", - "sort_artist": "Sort by Artist", - "sort_album": "Sort by Album", - "sort_duration": "Sort by Duration", - "sort_tracks": "Sort Tracks", - "currently_downloading": "Currently Downloading ({tracks_length})", - "cancel_all": "Cancel All", - "filter_artist": "Filter artists...", - "followers": "{followers} Followers", - "add_artist_to_blacklist": "Add artist to blacklist", - "top_tracks": "Top Tracks", - "fans_also_like": "Fans also like", - "loading": "Loading...", - "artist": "Artist", - "blacklisted": "Blacklisted", - "following": "Following", - "follow": "Follow", - "artist_url_copied": "Artist URL copied to clipboard", - "added_to_queue": "Added {tracks} tracks to queue", - "filter_albums": "Filter albums...", - "synced": "Synced", - "plain": "Plain", - "shuffle": "Shuffle", - "search_tracks": "Search tracks...", - "released": "Released", - "error": "Error {error}", - "title": "Title", - "time": "Time", - "more_actions": "More actions", - "download_count": "Download ({count})", - "add_count_to_playlist": "Add ({count}) to Playlist", - "add_count_to_queue": "Add ({count}) to Queue", - "play_count_next": "Play ({count}) next", - "album": "Album", - "copied_to_clipboard": "Copied {data} to clipboard", - "add_to_following_playlists": "Add {track} to following Playlists", - "add": "Add", - "added_track_to_queue": "Added {track} to queue", - "add_to_queue": "Add to queue", - "track_will_play_next": "{track} will play next", - "play_next": "Play next", - "removed_track_from_queue": "Removed {track} from queue", - "remove_from_queue": "Remove from queue", - "remove_from_favorites": "Remove from favorites", - "save_as_favorite": "Save as favorite", - "add_to_playlist": "Add to playlist", - "remove_from_playlist": "Remove from playlist", - "add_to_blacklist": "Add to blacklist", - "remove_from_blacklist": "Remove from blacklist", - "share": "Share", - "mini_player": "Mini Player", - "slide_to_seek": "Slide to seek forward or backward", - "shuffle_playlist": "Shuffle playlist", - "unshuffle_playlist": "Unshuffle playlist", - "previous_track": "Previous track", - "next_track": "Next track", - "pause_playback": "Pause Playback", - "resume_playback": "Resume Playback", - "loop_track": "Loop track", - "repeat_playlist": "Repeat playlist", - "queue": "Queue", - "alternative_track_sources": "Alternative track sources", - "download_track": "Download track", - "tracks_in_queue": "{tracks} tracks in queue", - "clear_all": "Clear all", - "show_hide_ui_on_hover": "Show/Hide UI on hover", - "always_on_top": "Always on top", - "exit_mini_player": "Exit Mini player", - "download_location": "Download location", - "local_library": "Local library", - "add_library_location": "Add to library", - "remove_library_location": "Remove from library", - "account": "Account", - "login_with_spotify": "Login with your Spotify account", - "connect_with_spotify": "Connect with Spotify", - "logout": "Logout", - "logout_of_this_account": "Logout of this account", - "language_region": "Language & Region", - "language": "Language", - "system_default": "System Default", - "market_place_region": "Marketplace Region", - "recommendation_country": "Recommendation Country", - "appearance": "Appearance", - "layout_mode": "Layout Mode", - "override_layout_settings": "Override responsive layout mode settings", - "adaptive": "Adaptive", - "compact": "Compact", - "extended": "Extended", - "theme": "Theme", - "dark": "Dark", - "light": "Light", - "system": "System", - "accent_color": "Accent Color", - "sync_album_color": "Sync album color", - "sync_album_color_description": "Uses the dominant color of the album art as the accent color", - "playback": "Playback", - "audio_quality": "Audio Quality", - "high": "High", - "low": "Low", - "pre_download_play": "Pre-download and play", - "pre_download_play_description": "Instead of streaming audio, download bytes and play instead (Recommended for higher bandwidth users)", - "skip_non_music": "Skip non-music segments (SponsorBlock)", - "blacklist_description": "Blacklisted tracks and artists", - "wait_for_download_to_finish": "Please wait for the current download to finish", - "desktop": "Desktop", - "close_behavior": "Close Behavior", - "close": "Close", - "minimize_to_tray": "Minimize to tray", - "show_tray_icon": "Show System tray icon", - "about": "About", - "u_love_spotube": "We know you love Spotube", - "check_for_updates": "Check for updates", - "about_spotube": "About Spotube", - "blacklist": "Blacklist", - "please_sponsor": "Please Sponsor/Donate", - "spotube_description": "Spotube, a lightweight, cross-platform, free-for-all spotify client", - "version": "Version", - "build_number": "Build Number", - "founder": "Founder", - "repository": "Repository", - "bug_issues": "Bug+Issues", - "made_with": "Made with ❤️ in Bangladesh🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "License", - "add_spotify_credentials": "Add your spotify credentials to get started", - "credentials_will_not_be_shared_disclaimer": "Don't worry, any of your credentials won't be collected or shared with anyone", - "know_how_to_login": "Don't know how to do this?", - "follow_step_by_step_guide": "Follow along the Step by Step guide", - "spotify_cookie": "Spotify {name} Cookie", - "cookie_name_cookie": "{name} Cookie", - "fill_in_all_fields": "Please fill in all the fields", - "submit": "Submit", - "exit": "Exit", - "previous": "Previous", - "next": "Next", - "done": "Done", - "step_1": "Step 1", - "first_go_to": "First, Go to", - "login_if_not_logged_in": "and Login/Signup if you are not logged in", - "step_2": "Step 2", - "step_2_steps": "1. Once you're logged in, press F12 or Mouse Right Click > Inspect to Open the Browser devtools.\n2. Then go the \"Application\" Tab (Chrome, Edge, Brave etc..) or \"Storage\" Tab (Firefox, Palemoon etc..)\n3. Go to the \"Cookies\" section then the \"https://accounts.spotify.com\" subsection", - "step_3": "Step 3", - "step_3_steps": "Copy the value of \"sp_dc\" Cookie", - "success_emoji": "Success🥳", - "success_message": "Now you've successfully Logged in with your Spotify account. Good Job, mate!", - "step_4": "Step 4", - "step_4_steps": "Paste the copied \"sp_dc\" value", - "something_went_wrong": "Something went wrong", - "piped_instance": "Piped Server Instance", - "piped_description": "The Piped server instance to use for track matching", - "piped_warning": "Some of them might not work well. So use at your own risk", - "invidious_instance": "Invidious Server Instance", - "invidious_description": "The Invidious server instance to use for track matching", - "invidious_warning": "Some of them might not work well. So use at your own risk", - "generate_playlist": "Generate Playlist", - "track_exists": "Track {track} already exists", - "replace_downloaded_tracks": "Replace all downloaded tracks", - "skip_download_tracks": "Skip downloading all downloaded tracks", - "do_you_want_to_replace": "Do you want to replace the existing track??", - "replace": "Replace", - "skip": "Skip", - "select_up_to_count_type": "Select up to {count} {type}", - "select_genres": "Select Genres", - "add_genres": "Add Genres", - "country": "Country", - "number_of_tracks_generate": "Number of tracks to generate", - "acousticness": "Acousticness", - "danceability": "Danceability", - "energy": "Energy", - "instrumentalness": "Instrumentalness", - "liveness": "Liveness", - "loudness": "Loudness", - "speechiness": "Speechiness", - "valence": "Valence", - "popularity": "Popularity", - "key": "Key", - "duration": "Duration (s)", - "tempo": "Tempo (BPM)", - "mode": "Mode", - "time_signature": "Time Signature", - "short": "Short", - "medium": "Medium", - "long": "Long", - "min": "Min", - "max": "Max", - "target": "Target", - "moderate": "Moderate", - "deselect_all": "Deselect All", - "select_all": "Select All", - "are_you_sure": "Are you sure?", - "generating_playlist": "Generating your custom playlist...", - "selected_count_tracks": "Selected {count} tracks", - "download_warning": "If you download all Tracks at bulk you're clearly pirating Music & causing damage to the creative society of Music. I hope you are aware of this. Always, try respecting & supporting Artist's hard work", - "download_ip_ban_warning": "BTW, your IP can get blocked on YouTube due excessive download requests than usual. IP block means you can't use YouTube (even if you're logged in) for at least 2-3 months from that IP device. And Spotube doesn't hold any responsibility if this ever happens", - "by_clicking_accept_terms": "By clicking 'accept' you agree to following terms:", - "download_agreement_1": "I know I'm pirating Music. I'm bad", - "download_agreement_2": "I'll support the Artist wherever I can and I'm only doing this because I don't have money to buy their art", - "download_agreement_3": "I'm completely aware that my IP can get blocked on YouTube & I don't hold Spotube or his owners/contributors responsible for any accidents caused by my current action", - "decline": "Decline", - "accept": "Accept", - "details": "Details", - "youtube": "YouTube", - "channel": "Channel", - "likes": "Likes", - "dislikes": "Dislikes", - "views": "Views", - "streamUrl": "Stream URL", - "stop": "Stop", - "sort_newest": "Sort by newest added", - "sort_oldest": "Sort by oldest added", - "sleep_timer": "Sleep Timer", - "mins": "{minutes} Minutes", - "hours": "{hours} Hours", - "hour": "{hours} Hour", - "custom_hours": "Custom Hours", - "logs": "Logs", - "developers": "Developers", - "not_logged_in": "You're not logged in", - "search_mode": "Search Mode", - "audio_source": "Audio Source", - "ok": "Ok", - "failed_to_encrypt": "Failed to encrypt", - "encryption_failed_warning": "Spotube uses encryption to securely store your data. But failed to do so. So it'll fallback to insecure storage\nIf you're using linux, please make sure you've any secret-service (gnome-keyring, kde-wallet, keepassxc etc) installed", - "querying_info": "Querying info...", - "piped_api_down": "Piped API is down", - "piped_down_error_instructions": "The Piped instance {pipedInstance} is currently down\n\nEither change the instance or change the 'API type' to official YouTube API\n\nMake sure to restart the app after change", - "you_are_offline": "You are currently offline", - "connection_restored": "Your internet connection was restored", - "use_system_title_bar": "Use system title bar", - "crunching_results": "Crunching results...", - "search_to_get_results": "Search to get results", - "use_amoled_mode": "Pitch black dark theme", - "pitch_dark_theme": "AMOLED Mode", - "normalize_audio": "Normalize audio", - "change_cover": "Change cover", - "add_cover": "Add cover", - "restore_defaults": "Restore defaults", - "download_music_codec": "Download music codec", - "streaming_music_codec": "Streaming music codec", - "login_with_lastfm": "Login with Last.fm", - "connect": "Connect", - "disconnect_lastfm": "Disconnect Last.fm", - "disconnect": "Disconnect", - "username": "Username", - "password": "Password", - "login": "Login", - "login_with_your_lastfm": "Login with your Last.fm account", - "scrobble_to_lastfm": "Scrobble to Last.fm", - "go_to_album": "Go to Album", - "discord_rich_presence": "Discord Rich Presence", - "browse_all": "Browse All", - "genres": "Genres", - "explore_genres": "Explore Genres", - "friends": "Friends", - "no_lyrics_available": "Sorry, unable find lyrics for this track", - "start_a_radio": "Start a Radio", - "how_to_start_radio": "How do you want to start the radio?", - "replace_queue_question": "Do you want to replace the current queue or append to it?", - "endless_playback": "Endless Playback", - "delete_playlist": "Delete Playlist", - "delete_playlist_confirmation": "Are you sure you want to delete this playlist?", - "local_tracks": "Local Tracks", - "local_tab": "Local", - "song_link": "Song Link", - "skip_this_nonsense": "Skip this nonsense", - "freedom_of_music": "“Freedom of Music”", - "freedom_of_music_palm": "“Freedom of Music in the palm of your hand”", - "get_started": "Let's get started", - "youtube_source_description": "Recommended and works best.", - "piped_source_description": "Feeling free? Same as YouTube but a lot free.", - "jiosaavn_source_description": "Best for South Asian region.", - "invidious_source_description": "Similar to Piped but with higher availability.", - "highest_quality": "Highest Quality: {quality}", - "select_audio_source": "Select Audio Source", - "endless_playback_description": "Automatically append new songs\nto the end of the queue", - "choose_your_region": "Choose your region", - "choose_your_region_description": "This will help Spotube show you the right content\nfor your location.", - "choose_your_language": "Choose your language", - "help_project_grow": "Help this project grow", - "help_project_grow_description": "Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.", - "contribute_on_github": "Contribute on GitHub", - "donate_on_open_collective": "Donate on Open Collective", - "browse_anonymously": "Browse Anonymously", - "enable_connect": "Enable Connect", - "enable_connect_description": "Control Spotube from other devices", - "devices": "Devices", - "select": "Select", - "connect_client_alert": "You're being controlled by {client}", - "this_device": "This Device", - "remote": "Remote", - "stats": "Stats", - "and_n_more": "and {count} more", - "recently_played": "Recently Played", - "browse_more": "Browse More", - "no_title": "No Title", - "not_playing": "Not playing", - "epic_failure": "Epic failure!", - "added_num_tracks_to_queue": "Added {tracks_length} tracks to queue", - "spotube_has_an_update": "Spotube has an update", - "download_now": "Download Now", - "nightly_version": "Spotube Nightly {nightlyBuildNum} has been released", - "release_version": "Spotube v{version} has been released", - "read_the_latest": "Read the latest ", - "release_notes": "release notes", - "pick_color_scheme": "Pick color scheme", - "save": "Save", - "choose_the_device": "Choose the device:", - "multiple_device_connected": "There are multiple device connected.\nChoose the device you want this action to take place", - "nothing_found": "Nothing found", - "the_box_is_empty": "The box is empty", - "top_artists": "Top Artists", - "top_albums": "Top Albums", - "this_week": "This week", - "this_month": "This month", - "last_6_months": "Last 6 months", - "this_year": "This year", - "last_2_years": "Last 2 years", - "all_time": "All time", - "powered_by_provider": "Powered by {providerName}", - "email": "Email", - "profile_followers": "Followers", - "birthday": "Birthday", - "subscription": "Subscription", - "not_born": "Not born", - "hacker": "Hacker", - "profile": "Profile", - "no_name": "No Name", - "edit": "Edit", - "user_profile": "User Profile", - "count_plays": "{count} plays", - "streaming_fees_hypothetical": "Streaming fees (hypothetical)", - "minutes_listened": "Minutes listened", - "streamed_songs": "Streamed songs", - "count_streams": "{count} streams", - "owned_by_you": "Owned by you", - "copied_shareurl_to_clipboard": "Copied {shareUrl} to clipboard", - "spotify_hipotetical_calculation": "*This is calculated based on Spotify's per stream\npayout of $0.003 to $0.005. This is a hypothetical\ncalculation to give user insight about how much they\nwould have paid to the artists if they were to listen\ntheir song in Spotify.", - "count_mins": "{minutes} mins", - "summary_minutes": "minutes", - "summary_listened_to_music": "Listened to music", - "summary_songs": "songs", - "summary_streamed_overall": "Streamed overall", - "summary_owed_to_artists": "Owed to artists\nthis month", - "summary_artists": "artist's", - "summary_music_reached_you": "Music reached you", - "summary_full_albums": "full albums", - "summary_got_your_love": "Got your love", - "summary_playlists": "playlists", - "summary_were_on_repeat": "Were on repeat", - "total_money": "Total {money}", - "webview_not_found": "Webview not found", - "webview_not_found_description": "No webview runtime is installed in your device.\nIf it's installed make sure it's in the Environment PATH\n\nAfter installing, restart the app", - "unsupported_platform": "Unsupported platform", - "cache_music": "Cache music", - "open": "Open", - "cache_folder": "Cache folder", - "export": "Export", - "clear_cache": "Clear cache", - "clear_cache_confirmation": "Do you want to clear the cache?", - "export_cache_files": "Export Cached Files", - "found_n_files": "Found {count} files", - "export_cache_confirmation": "Do you want to export these files to", - "exported_n_out_of_m_files": "Exported {filesExported} out of {files} files" -} \ No newline at end of file + "guest": "Guest", + "browse": "Browse", + "search": "Search", + "library": "Library", + "lyrics": "Lyrics", + "settings": "Settings", + "genre_categories_filter": "Filter categories or genres...", + "genre": "Genre", + "personalized": "Personalized", + "featured": "Featured", + "new_releases": "New Releases", + "songs": "Songs", + "playing_track": "Playing {track}", + "queue_clear_alert": "This will clear the current queue. {track_length} tracks will be removed\nDo you want to continue?", + "load_more": "Load more", + "playlists": "Playlists", + "artists": "Artists", + "albums": "Albums", + "tracks": "Tracks", + "downloads": "Downloads", + "filter_playlists": "Filter your playlists...", + "liked_tracks": "Liked Tracks", + "liked_tracks_description": "All your liked tracks", + "playlist": "Playlist", + "create_a_playlist": "Create a playlist", + "update_playlist": "Update playlist", + "create": "Create", + "cancel": "Cancel", + "update": "Update", + "playlist_name": "Playlist Name", + "name_of_playlist": "Name of the playlist", + "description": "Description", + "public": "Public", + "collaborative": "Collaborative", + "search_local_tracks": "Search local tracks...", + "play": "Play", + "delete": "Delete", + "none": "None", + "sort_a_z": "Sort by A-Z", + "sort_z_a": "Sort by Z-A", + "sort_artist": "Sort by Artist", + "sort_album": "Sort by Album", + "sort_duration": "Sort by Duration", + "sort_tracks": "Sort Tracks", + "currently_downloading": "Currently Downloading ({tracks_length})", + "cancel_all": "Cancel All", + "filter_artist": "Filter artists...", + "followers": "{followers} Followers", + "add_artist_to_blacklist": "Add artist to blacklist", + "top_tracks": "Top Tracks", + "fans_also_like": "Fans also like", + "loading": "Loading...", + "artist": "Artist", + "blacklisted": "Blacklisted", + "following": "Following", + "follow": "Follow", + "artist_url_copied": "Artist URL copied to clipboard", + "added_to_queue": "Added {tracks} tracks to queue", + "filter_albums": "Filter albums...", + "synced": "Synced", + "plain": "Plain", + "shuffle": "Shuffle", + "search_tracks": "Search tracks...", + "released": "Released", + "error": "Error {error}", + "title": "Title", + "time": "Time", + "more_actions": "More actions", + "download_count": "Download ({count})", + "add_count_to_playlist": "Add ({count}) to Playlist", + "add_count_to_queue": "Add ({count}) to Queue", + "play_count_next": "Play ({count}) next", + "album": "Album", + "copied_to_clipboard": "Copied {data} to clipboard", + "add_to_following_playlists": "Add {track} to following Playlists", + "add": "Add", + "added_track_to_queue": "Added {track} to queue", + "add_to_queue": "Add to queue", + "track_will_play_next": "{track} will play next", + "play_next": "Play next", + "removed_track_from_queue": "Removed {track} from queue", + "remove_from_queue": "Remove from queue", + "remove_from_favorites": "Remove from favorites", + "save_as_favorite": "Save as favorite", + "add_to_playlist": "Add to playlist", + "remove_from_playlist": "Remove from playlist", + "add_to_blacklist": "Add to blacklist", + "remove_from_blacklist": "Remove from blacklist", + "share": "Share", + "mini_player": "Mini Player", + "slide_to_seek": "Slide to seek forward or backward", + "shuffle_playlist": "Shuffle playlist", + "unshuffle_playlist": "Unshuffle playlist", + "previous_track": "Previous track", + "next_track": "Next track", + "pause_playback": "Pause Playback", + "resume_playback": "Resume Playback", + "loop_track": "Loop track", + "no_loop": "No loop", + "repeat_playlist": "Repeat playlist", + "queue": "Queue", + "alternative_track_sources": "Alternative track sources", + "download_track": "Download track", + "tracks_in_queue": "{tracks} tracks in queue", + "clear_all": "Clear all", + "show_hide_ui_on_hover": "Show/Hide UI on hover", + "always_on_top": "Always on top", + "exit_mini_player": "Exit Mini player", + "download_location": "Download location", + "local_library": "Local library", + "add_library_location": "Add to library", + "remove_library_location": "Remove from library", + "account": "Account", + "login_with_spotify": "Login with your Spotify account", + "connect_with_spotify": "Connect with Spotify", + "logout": "Logout", + "logout_of_this_account": "Logout of this account", + "language_region": "Language & Region", + "language": "Language", + "system_default": "System Default", + "market_place_region": "Marketplace Region", + "recommendation_country": "Recommendation Country", + "appearance": "Appearance", + "layout_mode": "Layout Mode", + "override_layout_settings": "Override responsive layout mode settings", + "adaptive": "Adaptive", + "compact": "Compact", + "extended": "Extended", + "theme": "Theme", + "dark": "Dark", + "light": "Light", + "system": "System", + "accent_color": "Accent Color", + "sync_album_color": "Sync album color", + "sync_album_color_description": "Uses the dominant color of the album art as the accent color", + "playback": "Playback", + "audio_quality": "Audio Quality", + "high": "High", + "low": "Low", + "pre_download_play": "Pre-download and play", + "pre_download_play_description": "Instead of streaming audio, download bytes and play instead (Recommended for higher bandwidth users)", + "skip_non_music": "Skip non-music segments (SponsorBlock)", + "blacklist_description": "Blacklisted tracks and artists", + "wait_for_download_to_finish": "Please wait for the current download to finish", + "desktop": "Desktop", + "close_behavior": "Close Behavior", + "close": "Close", + "minimize_to_tray": "Minimize to tray", + "show_tray_icon": "Show System tray icon", + "about": "About", + "u_love_spotube": "We know you love Spotube", + "check_for_updates": "Check for updates", + "about_spotube": "About Spotube", + "blacklist": "Blacklist", + "please_sponsor": "Please Sponsor/Donate", + "spotube_description": "Spotube, a lightweight, cross-platform, free-for-all spotify client", + "version": "Version", + "build_number": "Build Number", + "founder": "Founder", + "repository": "Repository", + "bug_issues": "Bug+Issues", + "made_with": "Made with ❤️ in Bangladesh🇧🇩", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "License", + "add_spotify_credentials": "Add your spotify credentials to get started", + "credentials_will_not_be_shared_disclaimer": "Don't worry, any of your credentials won't be collected or shared with anyone", + "know_how_to_login": "Don't know how to do this?", + "follow_step_by_step_guide": "Follow along the Step by Step guide", + "spotify_cookie": "Spotify {name} Cookie", + "cookie_name_cookie": "{name} Cookie", + "fill_in_all_fields": "Please fill in all the fields", + "submit": "Submit", + "exit": "Exit", + "previous": "Previous", + "next": "Next", + "done": "Done", + "step_1": "Step 1", + "first_go_to": "First, Go to", + "login_if_not_logged_in": "and Login/Signup if you are not logged in", + "step_2": "Step 2", + "step_2_steps": "1. Once you're logged in, press F12 or Mouse Right Click > Inspect to Open the Browser devtools.\n2. Then go the \"Application\" Tab (Chrome, Edge, Brave etc..) or \"Storage\" Tab (Firefox, Palemoon etc..)\n3. Go to the \"Cookies\" section then the \"https://accounts.spotify.com\" subsection", + "step_3": "Step 3", + "step_3_steps": "Copy the value of \"sp_dc\" Cookie", + "success_emoji": "Success🥳", + "success_message": "Now you've successfully Logged in with your Spotify account. Good Job, mate!", + "step_4": "Step 4", + "step_4_steps": "Paste the copied \"sp_dc\" value", + "something_went_wrong": "Something went wrong", + "piped_instance": "Piped Server Instance", + "piped_description": "The Piped server instance to use for track matching", + "piped_warning": "Some of them might not work well. So use at your own risk", + "invidious_instance": "Invidious Server Instance", + "invidious_description": "The Invidious server instance to use for track matching", + "invidious_warning": "Some of them might not work well. So use at your own risk", + "generate": "Generate", + "track_exists": "Track {track} already exists", + "replace_downloaded_tracks": "Replace all downloaded tracks", + "skip_download_tracks": "Skip downloading all downloaded tracks", + "do_you_want_to_replace": "Do you want to replace the existing track??", + "replace": "Replace", + "skip": "Skip", + "select_up_to_count_type": "Select up to {count} {type}", + "select_genres": "Select Genres", + "add_genres": "Add Genres", + "country": "Country", + "number_of_tracks_generate": "Number of tracks to generate", + "acousticness": "Acousticness", + "danceability": "Danceability", + "energy": "Energy", + "instrumentalness": "Instrumentalness", + "liveness": "Liveness", + "loudness": "Loudness", + "speechiness": "Speechiness", + "valence": "Valence", + "popularity": "Popularity", + "key": "Key", + "duration": "Duration (s)", + "tempo": "Tempo (BPM)", + "mode": "Mode", + "time_signature": "Time Signature", + "short": "Short", + "medium": "Medium", + "long": "Long", + "min": "Min", + "max": "Max", + "target": "Target", + "moderate": "Moderate", + "deselect_all": "Deselect All", + "select_all": "Select All", + "are_you_sure": "Are you sure?", + "generating_playlist": "Generating your custom playlist...", + "selected_count_tracks": "Selected {count} tracks", + "download_warning": "If you download all Tracks at bulk you're clearly pirating Music & causing damage to the creative society of Music. I hope you are aware of this. Always, try respecting & supporting Artist's hard work", + "download_ip_ban_warning": "BTW, your IP can get blocked on YouTube due excessive download requests than usual. IP block means you can't use YouTube (even if you're logged in) for at least 2-3 months from that IP device. And Spotube doesn't hold any responsibility if this ever happens", + "by_clicking_accept_terms": "By clicking 'accept' you agree to following terms:", + "download_agreement_1": "I know I'm pirating Music. I'm bad", + "download_agreement_2": "I'll support the Artist wherever I can and I'm only doing this because I don't have money to buy their art", + "download_agreement_3": "I'm completely aware that my IP can get blocked on YouTube & I don't hold Spotube or his owners/contributors responsible for any accidents caused by my current action", + "decline": "Decline", + "accept": "Accept", + "details": "Details", + "youtube": "YouTube", + "channel": "Channel", + "likes": "Likes", + "dislikes": "Dislikes", + "views": "Views", + "streamUrl": "Stream URL", + "stop": "Stop", + "sort_newest": "Sort by newest added", + "sort_oldest": "Sort by oldest added", + "sleep_timer": "Sleep Timer", + "mins": "{minutes} Minutes", + "hours": "{hours} Hours", + "hour": "{hours} Hour", + "custom_hours": "Custom Hours", + "logs": "Logs", + "developers": "Developers", + "not_logged_in": "You're not logged in", + "search_mode": "Search Mode", + "audio_source": "Audio Source", + "ok": "Ok", + "failed_to_encrypt": "Failed to encrypt", + "encryption_failed_warning": "Spotube uses encryption to securely store your data. But failed to do so. So it'll fallback to insecure storage\nIf you're using linux, please make sure you've any secret-service (gnome-keyring, kde-wallet, keepassxc etc) installed", + "querying_info": "Querying info...", + "piped_api_down": "Piped API is down", + "piped_down_error_instructions": "The Piped instance {pipedInstance} is currently down\n\nEither change the instance or change the 'API type' to official YouTube API\n\nMake sure to restart the app after change", + "you_are_offline": "You are currently offline", + "connection_restored": "Your internet connection was restored", + "use_system_title_bar": "Use system title bar", + "crunching_results": "Crunching results...", + "search_to_get_results": "Search to get results", + "use_amoled_mode": "Pitch black dark theme", + "pitch_dark_theme": "AMOLED Mode", + "normalize_audio": "Normalize audio", + "change_cover": "Change cover", + "add_cover": "Add cover", + "restore_defaults": "Restore defaults", + "download_music_codec": "Download music codec", + "streaming_music_codec": "Streaming music codec", + "login_with_lastfm": "Login with Last.fm", + "connect": "Connect", + "disconnect_lastfm": "Disconnect Last.fm", + "disconnect": "Disconnect", + "username": "Username", + "password": "Password", + "login": "Login", + "login_with_your_lastfm": "Login with your Last.fm account", + "scrobble_to_lastfm": "Scrobble to Last.fm", + "go_to_album": "Go to Album", + "discord_rich_presence": "Discord Rich Presence", + "browse_all": "Browse All", + "genres": "Genres", + "explore_genres": "Explore Genres", + "friends": "Friends", + "no_lyrics_available": "Sorry, unable find lyrics for this track", + "start_a_radio": "Start a Radio", + "how_to_start_radio": "How do you want to start the radio?", + "replace_queue_question": "Do you want to replace the current queue or append to it?", + "endless_playback": "Endless Playback", + "delete_playlist": "Delete Playlist", + "delete_playlist_confirmation": "Are you sure you want to delete this playlist?", + "local_tracks": "Local Tracks", + "local_tab": "Local", + "song_link": "Song Link", + "skip_this_nonsense": "Skip this nonsense", + "freedom_of_music": "“Freedom of Music”", + "freedom_of_music_palm": "“Freedom of Music in the palm of your hand”", + "get_started": "Let's get started", + "youtube_source_description": "Recommended and works best.", + "piped_source_description": "Feeling free? Same as YouTube but a lot free.", + "jiosaavn_source_description": "Best for South Asian region.", + "invidious_source_description": "Similar to Piped but with higher availability.", + "highest_quality": "Highest Quality: {quality}", + "select_audio_source": "Select Audio Source", + "endless_playback_description": "Automatically append new songs\nto the end of the queue", + "choose_your_region": "Choose your region", + "choose_your_region_description": "This will help Spotube show you the right content\nfor your location.", + "choose_your_language": "Choose your language", + "help_project_grow": "Help this project grow", + "help_project_grow_description": "Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.", + "contribute_on_github": "Contribute on GitHub", + "donate_on_open_collective": "Donate on Open Collective", + "browse_anonymously": "Browse Anonymously", + "enable_connect": "Enable Connect", + "enable_connect_description": "Control Spotube from other devices", + "devices": "Devices", + "select": "Select", + "connect_client_alert": "You're being controlled by {client}", + "this_device": "This Device", + "remote": "Remote", + "stats": "Stats", + "and_n_more": "and {count} more", + "recently_played": "Recently Played", + "browse_more": "Browse More", + "no_title": "No Title", + "not_playing": "Not playing", + "epic_failure": "Epic failure!", + "added_num_tracks_to_queue": "Added {tracks_length} tracks to queue", + "spotube_has_an_update": "Spotube has an update", + "download_now": "Download Now", + "nightly_version": "Spotube Nightly {nightlyBuildNum} has been released", + "release_version": "Spotube v{version} has been released", + "read_the_latest": "Read the latest ", + "release_notes": "release notes", + "pick_color_scheme": "Pick color scheme", + "save": "Save", + "choose_the_device": "Choose the device:", + "multiple_device_connected": "There are multiple device connected.\nChoose the device you want this action to take place", + "nothing_found": "Nothing found", + "the_box_is_empty": "The box is empty", + "top_artists": "Top Artists", + "top_albums": "Top Albums", + "this_week": "This week", + "this_month": "This month", + "last_6_months": "Last 6 months", + "this_year": "This year", + "last_2_years": "Last 2 years", + "all_time": "All time", + "powered_by_provider": "Powered by {providerName}", + "email": "Email", + "profile_followers": "Followers", + "birthday": "Birthday", + "subscription": "Subscription", + "not_born": "Not born", + "hacker": "Hacker", + "profile": "Profile", + "no_name": "No Name", + "edit": "Edit", + "user_profile": "User Profile", + "count_plays": "{count} plays", + "streaming_fees_hypothetical": "Streaming fees (hypothetical)", + "minutes_listened": "Minutes listened", + "streamed_songs": "Streamed songs", + "count_streams": "{count} streams", + "owned_by_you": "Owned by you", + "copied_shareurl_to_clipboard": "Copied {shareUrl} to clipboard", + "spotify_hipotetical_calculation": "*This is calculated based on Spotify's per stream\npayout of $0.003 to $0.005. This is a hypothetical\ncalculation to give user insight about how much they\nwould have paid to the artists if they were to listen\ntheir song in Spotify.", + "count_mins": "{minutes} mins", + "summary_minutes": "minutes", + "summary_listened_to_music": "Listened to music", + "summary_songs": "songs", + "summary_streamed_overall": "Streamed overall", + "summary_owed_to_artists": "Owed to artists\nthis month", + "summary_artists": "artist's", + "summary_music_reached_you": "Music reached you", + "summary_full_albums": "full albums", + "summary_got_your_love": "Got your love", + "summary_playlists": "playlists", + "summary_were_on_repeat": "Were on repeat", + "total_money": "Total {money}", + "webview_not_found": "Webview not found", + "webview_not_found_description": "No webview runtime is installed in your device.\nIf it's installed make sure it's in the Environment PATH\n\nAfter installing, restart the app", + "unsupported_platform": "Unsupported platform", + "cache_music": "Cache music", + "open": "Open", + "cache_folder": "Cache folder", + "export": "Export", + "clear_cache": "Clear cache", + "clear_cache_confirmation": "Do you want to clear the cache?", + "export_cache_files": "Export Cached Files", + "found_n_files": "Found {count} files", + "export_cache_confirmation": "Do you want to export these files to", + "exported_n_out_of_m_files": "Exported {filesExported} out of {files} files", + "undo": "Undo", + "download_all": "Download all", + "add_all_to_playlist": "Add all to playlist", + "add_all_to_queue": "Add all to queue", + "play_all_next": "Play all next", + "pause": "Pause", + "view_all": "View all", + "no_tracks_added_yet": "Looks like you haven't added any tracks yet", + "no_tracks": "Looks like there are no tracks here", + "no_tracks_listened_yet": "Looks like you haven't listened to anything yet", + "not_following_artists": "You're not following any artists", + "no_favorite_albums_yet": "Looks like you haven't added any albums to your favorites yet", + "no_logs_found": "No logs found", + "youtube_engine": "YouTube Engine", + "youtube_engine_not_installed_title": "{engine} is not installed", + "youtube_engine_not_installed_message": "{engine} is not installed in your system.", + "youtube_engine_set_path": "Make sure it's available in the PATH variable or\nset the absolute path to the {engine} executable below", + "youtube_engine_unix_issue_message": "In macOS/Linux/unix like OS's, setting path on .zshrc/.bashrc/.bash_profile etc. won't work.\nYou need to set the path in the shell configuration file", + "download": "Download", + "file_not_found": "File not found", + "custom": "Custom", + "add_custom_url": "Add custom URL" +} diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 9fc7e560..565c786a 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -401,5 +401,30 @@ "export_cache_files": "Exportar archivos en caché", "found_n_files": "Se encontraron {count} archivos", "export_cache_confirmation": "¿Desea exportar estos archivos a", - "exported_n_out_of_m_files": "Se exportaron {filesExported} de {files} archivos" + "exported_n_out_of_m_files": "Se exportaron {filesExported} de {files} archivos", + "playlist": "Lista de reproducción", + "no_loop": "Sin bucle", + "generate": "Generar", + "undo": "Deshacer", + "download_all": "Descargar todo", + "add_all_to_playlist": "Agregar todo a la lista de reproducción", + "add_all_to_queue": "Agregar todo a la cola", + "play_all_next": "Reproducir todo a continuación", + "pause": "Pausa", + "view_all": "Ver todo", + "no_tracks_added_yet": "Parece que aún no has agregado ninguna canción.", + "no_tracks": "Parece que no hay canciones aquí.", + "no_tracks_listened_yet": "Parece que no has escuchado nada todavía.", + "not_following_artists": "No sigues a ningún artista.", + "no_favorite_albums_yet": "Parece que aún no has agregado ningún álbum a tus favoritos.", + "no_logs_found": "No se encontraron registros", + "youtube_engine": "Motor de YouTube", + "youtube_engine_not_installed_title": "{engine} no está instalado", + "youtube_engine_not_installed_message": "{engine} no está instalado en tu sistema.", + "youtube_engine_set_path": "Asegúrate de que esté disponible en la variable PATH o\nestablece la ruta absoluta del ejecutable de {engine} a continuación.", + "youtube_engine_unix_issue_message": "En macOS/Linux/sistemas operativos similares a Unix, establecer la ruta en .zshrc/.bashrc/.bash_profile etc. no funcionará.\nNecesitas establecer la ruta en el archivo de configuración del shell.", + "download": "Descargar", + "file_not_found": "Archivo no encontrado", + "custom": "Personalizado", + "add_custom_url": "Agregar URL personalizada" } \ No newline at end of file diff --git a/lib/l10n/app_eu.arb b/lib/l10n/app_eu.arb index 98596725..70a581a7 100644 --- a/lib/l10n/app_eu.arb +++ b/lib/l10n/app_eu.arb @@ -401,5 +401,30 @@ "export_cache_files": "Esportatu cache fitxategiak", "found_n_files": "{count} fitxategi aurkitu dira", "export_cache_confirmation": "Fitxategi hauek esportatu nahi al dituzu", - "exported_n_out_of_m_files": "{filesExported} fitxategi esportatu dira {files} -tik" + "exported_n_out_of_m_files": "{filesExported} fitxategi esportatu dira {files} -tik", + "playlist": "Playlist", + "no_loop": "Ez dago loop-ik", + "generate": "Sortu", + "undo": "Desegondu", + "download_all": "Guztia deskargatu", + "add_all_to_playlist": "Guztia playlist-era gehitu", + "add_all_to_queue": "Guztia zerrendara gehitu", + "play_all_next": "Guztia hurrengoan jolastu", + "pause": "Pausatu", + "view_all": "Ikusi guztia", + "no_tracks_added_yet": "Dirudienez, oraindik ez duzu abestirik gehitu.", + "no_tracks": "Ez dirudi hemen abestirik dagoenik.", + "no_tracks_listened_yet": "Dirudienez, oraindik ez duzu ezer entzun.", + "not_following_artists": "Ez zaude artisten atzetik.", + "no_favorite_albums_yet": "Dirudienez, oraindik ez duzu albumik gehitu zure gogokoen artean.", + "no_logs_found": "Ez dira log-ak aurkitu", + "youtube_engine": "YouTube Motorra", + "youtube_engine_not_installed_title": "{engine} ez dago instalatuta", + "youtube_engine_not_installed_message": "{engine} ez dago zure sisteman instalatuta.", + "youtube_engine_set_path": "Ziurtatu PATH aldagaiaren barruan dagoela edo\nezarri {engine} exekutagarriaren helbide absolutua behean.", + "youtube_engine_unix_issue_message": "macOS/Linux/Unix bezalako sistemetan, .zshrc/.bashrc/.bash_profile bezalako fitxategietan bidearen ezarpenak ez dira funtzionatuko.\nBidearen ezarpena shell konfigurazio fitxategian egin behar duzu.", + "download": "Deskargatu", + "file_not_found": "Fitxategia ez da aurkitu", + "custom": "Pertsonalizatua", + "add_custom_url": "Gehitu URL pertsonalizatua" } \ No newline at end of file diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 4d11dd81..d3918e55 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -401,5 +401,30 @@ "export_cache_files": "صادر کردن فایل‌های حافظه موقت", "found_n_files": "{count} فایل یافت شد", "export_cache_confirmation": "آیا می‌خواهید این فایل‌ها را صادر کنید به", - "exported_n_out_of_m_files": "{filesExported} از {files} فایل صادر شد" + "exported_n_out_of_m_files": "{filesExported} از {files} فایل صادر شد", + "playlist": "لیست پخش", + "no_loop": "بدون حلقه", + "generate": "ایجاد", + "undo": "بازگشت", + "download_all": "دانلود همه", + "add_all_to_playlist": "افزودن همه به لیست پخش", + "add_all_to_queue": "افزودن همه به صف", + "play_all_next": "پخش همه بعدی", + "pause": "مکث", + "view_all": "مشاهده همه", + "no_tracks_added_yet": "به نظر می‌رسد هنوز هیچ آهنگی اضافه نکرده‌اید.", + "no_tracks": "به نظر می‌رسد هیچ آهنگی در اینجا وجود ندارد.", + "no_tracks_listened_yet": "به نظر می‌رسد هنوز چیزی نشنیده‌اید.", + "not_following_artists": "شما هیچ هنرمندی را دنبال نمی‌کنید.", + "no_favorite_albums_yet": "به نظر می‌رسد هنوز هیچ آلبومی را به علاقه‌مندی‌هایتان اضافه نکرده‌اید.", + "no_logs_found": "هیچ لاگی پیدا نشد", + "youtube_engine": "موتور YouTube", + "youtube_engine_not_installed_title": "{engine} نصب نشده است", + "youtube_engine_not_installed_message": "{engine} در سیستم شما نصب نشده است.", + "youtube_engine_set_path": "اطمینان حاصل کنید که در متغیر PATH موجود است یا\nآدرس مطلق فایل اجرایی {engine} را در زیر تنظیم کنید.", + "youtube_engine_unix_issue_message": "در macOS/Linux/سیستم‌عامل‌های مشابه Unix، تنظیم مسیر در .zshrc/.bashrc/.bash_profile و غیره کار نمی‌کند.\nباید مسیر را در فایل پیکربندی شل تنظیم کنید.", + "download": "دانلود", + "file_not_found": "فایل پیدا نشد", + "custom": "شخصی‌سازی شده", + "add_custom_url": "اضافه کردن URL سفارشی" } \ No newline at end of file diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index f6794043..797c36f7 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -401,5 +401,30 @@ "export_cache_files": "Vie välimuistitiedostot", "found_n_files": "Löydettiin {count} tiedostoa", "export_cache_confirmation": "Haluatko viedä nämä tiedostot", - "exported_n_out_of_m_files": "Vietiin {filesExported}/{files} tiedostoa" + "exported_n_out_of_m_files": "Vietiin {filesExported}/{files} tiedostoa", + "playlist": "Soittolista", + "no_loop": "Ei silmukkaa", + "generate": "Luo", + "undo": "Peruuta", + "download_all": "Lataa kaikki", + "add_all_to_playlist": "Lisää kaikki soittolistalle", + "add_all_to_queue": "Lisää kaikki jonoon", + "play_all_next": "Toista kaikki seuraavaksi", + "pause": "Pysäytä", + "view_all": "Näytä kaikki", + "no_tracks_added_yet": "Näyttää siltä, että et ole lisännyt vielä mitään kappaleita.", + "no_tracks": "Näyttää siltä, että täällä ei ole kappaleita.", + "no_tracks_listened_yet": "Näyttää siltä, että et ole kuunnellut mitään vielä.", + "not_following_artists": "Et seuraa yhtään artistia.", + "no_favorite_albums_yet": "Näyttää siltä, että et ole lisännyt yhtään albumia suosikkeihisi.", + "no_logs_found": "Ei lokitietoja löydetty", + "youtube_engine": "YouTube-moottori", + "youtube_engine_not_installed_title": "{engine} ei ole asennettu", + "youtube_engine_not_installed_message": "{engine} ei ole asennettu järjestelmääsi.", + "youtube_engine_set_path": "Varmista, että se on saatavilla PATH-muuttujassa tai\nasetetaan {engine} suoritettavan tiedoston absoluuttinen polku alla.", + "youtube_engine_unix_issue_message": "macOS/Linux/unix-tyyppisissä käyttöjärjestelmissä polun asettaminen .zshrc/.bashrc/.bash_profile jne. ei toimi.\nSinun täytyy asettaa polku shellin asetustiedostoon.", + "download": "Lataa", + "file_not_found": "Tiedostoa ei löydy", + "custom": "Mukautettu", + "add_custom_url": "Lisää mukautettu URL" } \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 9062ada7..636cffec 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -401,5 +401,30 @@ "export_cache_files": "Exporter les fichiers en cache", "found_n_files": "{count} fichiers trouvés", "export_cache_confirmation": "Voulez-vous exporter ces fichiers vers", - "exported_n_out_of_m_files": "{filesExported} fichiers exportés sur {files}" + "exported_n_out_of_m_files": "{filesExported} fichiers exportés sur {files}", + "playlist": "Playlist", + "no_loop": "Pas de boucle", + "generate": "Générer", + "undo": "Annuler", + "download_all": "Télécharger tout", + "add_all_to_playlist": "Ajouter tout à la playlist", + "add_all_to_queue": "Ajouter tout à la file d'attente", + "play_all_next": "Lire tout suivant", + "pause": "Pause", + "view_all": "Voir tout", + "no_tracks_added_yet": "Il semble que vous n'avez encore ajouté aucun morceau.", + "no_tracks": "Il semble qu'il n'y ait pas de morceaux ici.", + "no_tracks_listened_yet": "Il semble que vous n'avez encore rien écouté.", + "not_following_artists": "Vous ne suivez aucun artiste.", + "no_favorite_albums_yet": "Il semble que vous n'ayez encore ajouté aucun album à vos favoris.", + "no_logs_found": "Aucun log trouvé", + "youtube_engine": "Moteur YouTube", + "youtube_engine_not_installed_title": "{engine} n'est pas installé", + "youtube_engine_not_installed_message": "{engine} n'est pas installé sur votre système.", + "youtube_engine_set_path": "Assurez-vous qu'il est disponible dans la variable PATH ou\nfixez le chemin absolu du fichier exécutable {engine} ci-dessous.", + "youtube_engine_unix_issue_message": "Dans macOS/Linux/les systèmes d'exploitation similaires à Unix, définir le chemin dans .zshrc/.bashrc/.bash_profile etc. ne fonctionnera pas.\nVous devez définir le chemin dans le fichier de configuration du shell.", + "download": "Télécharger", + "file_not_found": "Fichier non trouvé", + "custom": "Personnalisé", + "add_custom_url": "Ajouter une URL personnalisée" } \ No newline at end of file diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 7a1eae4e..fc59d31a 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -401,5 +401,30 @@ "export_cache_files": "कैश फ़ाइलें निर्यात करें", "found_n_files": "{count} फ़ाइलें मिलीं", "export_cache_confirmation": "क्या आप इन फ़ाइलों को निर्यात करना चाहते हैं", - "exported_n_out_of_m_files": "{filesExported} फ़ाइलें निर्यात की गईं {files} में से" + "exported_n_out_of_m_files": "{filesExported} फ़ाइलें निर्यात की गईं {files} में से", + "playlist": "प्लेलिस्ट", + "no_loop": "कोई लूप नहीं", + "generate": "उत्पन्न करें", + "undo": "पूर्ववत करें", + "download_all": "सभी डाउनलोड करें", + "add_all_to_playlist": "सभी को प्लेलिस्ट में जोड़ें", + "add_all_to_queue": "सभी को कतार में जोड़ें", + "play_all_next": "सभी को अगले खेलने के लिए", + "pause": "रोकें", + "view_all": "सभी देखें", + "no_tracks_added_yet": "लगता है आपने अभी तक कोई ट्रैक नहीं जोड़ा है।", + "no_tracks": "लगता है यहाँ कोई ट्रैक नहीं है।", + "no_tracks_listened_yet": "लगता है आपने अभी तक कुछ नहीं सुना है।", + "not_following_artists": "आप किसी भी कलाकार को फॉलो नहीं कर रहे हैं।", + "no_favorite_albums_yet": "लगता है आपने अभी तक कोई एल्बम अपनी पसंदीदा सूची में नहीं जोड़ा है।", + "no_logs_found": "कोई लॉग नहीं मिला", + "youtube_engine": "YouTube इंजन", + "youtube_engine_not_installed_title": "{engine} स्थापित नहीं है", + "youtube_engine_not_installed_message": "{engine} आपके सिस्टम में स्थापित नहीं है।", + "youtube_engine_set_path": "यह सुनिश्चित करें कि यह PATH वेरिएबल में उपलब्ध हो या\nनीचे {engine} निष्पादन योग्य फ़ाइल का पूर्ण पथ सेट करें।", + "youtube_engine_unix_issue_message": "macOS/Linux/यूनिक्स जैसे OS में, .zshrc/.bashrc/.bash_profile आदि में पथ सेट करना काम नहीं करेगा।\nआपको पथ को शेल कॉन्फ़िगरेशन फ़ाइल में सेट करना होगा।", + "download": "डाउनलोड करें", + "file_not_found": "फाइल नहीं मिली", + "custom": "कस्टम", + "add_custom_url": "कस्टम URL जोड़ें" } \ No newline at end of file diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 5e041dc0..91bc9aef 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -401,5 +401,30 @@ "export_cache_files": "Export Cached Files", "found_n_files": "Found {count} files", "export_cache_confirmation": "Do you want to export these files to", - "exported_n_out_of_m_files": "Exported {filesExported} out of {files} files" + "exported_n_out_of_m_files": "Exported {filesExported} out of {files} files", + "playlist": "Playlist", + "no_loop": "No loop", + "generate": "Generate", + "undo": "Undo", + "download_all": "Download all", + "add_all_to_playlist": "Add all to playlist", + "add_all_to_queue": "Add all to queue", + "play_all_next": "Play all next", + "pause": "Pause", + "view_all": "View all", + "no_tracks_added_yet": "Looks like you haven't added any tracks yet", + "no_tracks": "Looks like there are no tracks here", + "no_tracks_listened_yet": "Looks like you haven't listened to anything yet", + "not_following_artists": "You're not following any artists", + "no_favorite_albums_yet": "Looks like you haven't added any albums to your favorites yet", + "no_logs_found": "No logs found", + "youtube_engine": "YouTube Engine", + "youtube_engine_not_installed_title": "{engine} is not installed", + "youtube_engine_not_installed_message": "{engine} is not installed in your system.", + "youtube_engine_set_path": "Make sure it's available in the PATH variable or\nset the absolute path to the {engine} executable below", + "youtube_engine_unix_issue_message": "In macOS/Linux/unix like OS's, setting path on .zshrc/.bashrc/.bash_profile etc. won't work.\nYou need to set the path in the shell configuration file", + "download": "Download", + "file_not_found": "File not found", + "custom": "Custom", + "add_custom_url": "Add custom URL" } \ No newline at end of file diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index c4954dd1..f598d363 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -402,5 +402,30 @@ "export_cache_files": "Esporta file nella cache", "found_n_files": "Trovati {count} file", "export_cache_confirmation": "Vuoi esportare questi file su", - "exported_n_out_of_m_files": "Esportati {filesExported} su {files} file" + "exported_n_out_of_m_files": "Esportati {filesExported} su {files} file", + "playlist": "Playlist", + "no_loop": "Nessun ciclo", + "generate": "Genera", + "undo": "Annulla", + "download_all": "Scarica tutto", + "add_all_to_playlist": "Aggiungi tutto alla playlist", + "add_all_to_queue": "Aggiungi tutto alla coda", + "play_all_next": "Riproduci tutto dopo", + "pause": "Pausa", + "view_all": "Vedi tutto", + "no_tracks_added_yet": "Sembra che non hai ancora aggiunto nessun brano", + "no_tracks": "Sembra che non ci siano brani qui", + "no_tracks_listened_yet": "Sembra che non hai ascoltato nulla ancora", + "not_following_artists": "Non stai seguendo alcun artista", + "no_favorite_albums_yet": "Sembra che non hai ancora aggiunto album ai tuoi preferiti", + "no_logs_found": "Nessun registro trovato", + "youtube_engine": "Motore YouTube", + "youtube_engine_not_installed_title": "{engine} non è installato", + "youtube_engine_not_installed_message": "{engine} non è installato nel tuo sistema.", + "youtube_engine_set_path": "Assicurati che sia disponibile nella variabile PATH o\nimposta il percorso assoluto all'eseguibile {engine} qui sotto", + "youtube_engine_unix_issue_message": "In macOS/Linux/os simili a unix, impostare il percorso su .zshrc/.bashrc/.bash_profile ecc. non funzionerà.\nDevi impostare il percorso nel file di configurazione della shell", + "download": "Scarica", + "file_not_found": "File non trovato", + "custom": "Personalizzato", + "add_custom_url": "Aggiungi URL personalizzato" } \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 4f299025..b885fa59 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -401,5 +401,30 @@ "export_cache_files": "キャッシュされたファイルをエクスポート", "found_n_files": "{count}ファイルが見つかりました", "export_cache_confirmation": "これらのファイルをエクスポートしますか", - "exported_n_out_of_m_files": "{filesExported} / {files}ファイルがエクスポートされました" + "exported_n_out_of_m_files": "{filesExported} / {files}ファイルがエクスポートされました", + "playlist": "プレイリスト", + "no_loop": "ループなし", + "generate": "生成", + "undo": "元に戻す", + "download_all": "すべてをダウンロード", + "add_all_to_playlist": "すべてをプレイリストに追加", + "add_all_to_queue": "すべてをキューに追加", + "play_all_next": "次にすべてを再生", + "pause": "一時停止", + "view_all": "すべてを見る", + "no_tracks_added_yet": "まだ曲を追加していないようです", + "no_tracks": "ここには曲がないようです", + "no_tracks_listened_yet": "まだ何も聞いていないようです", + "not_following_artists": "アーティストをフォローしていません", + "no_favorite_albums_yet": "まだお気に入りのアルバムを追加していないようです", + "no_logs_found": "ログが見つかりませんでした", + "youtube_engine": "YouTubeエンジン", + "youtube_engine_not_installed_title": "{engine}はインストールされていません", + "youtube_engine_not_installed_message": "{engine}はシステムにインストールされていません。", + "youtube_engine_set_path": "PATH変数に設定されていることを確認するか\n{engine}実行ファイルの絶対パスを下記に設定してください", + "youtube_engine_unix_issue_message": "macOS/Linux/Unix系OSでは、.zshrc/.bashrc/.bash_profileなどでパスを設定しても動作しません。\nシェルの設定ファイルにパスを設定する必要があります", + "download": "ダウンロード", + "file_not_found": "ファイルが見つかりません", + "custom": "カスタム", + "add_custom_url": "カスタムURLを追加" } \ No newline at end of file diff --git a/lib/l10n/app_ka.arb b/lib/l10n/app_ka.arb index 3bcd0748..8bc9cf36 100644 --- a/lib/l10n/app_ka.arb +++ b/lib/l10n/app_ka.arb @@ -401,5 +401,30 @@ "export_cache_files": "ქეშირებული ფაილების ექსპორტი", "found_n_files": "ნაპოვნია {count} ფაილი", "export_cache_confirmation": "გსურთ ამ ფაილების ექსპორტი", - "exported_n_out_of_m_files": "{filesExported} ფაილი {files}-დან ექსპორტირებულია" + "exported_n_out_of_m_files": "{filesExported} ფაილი {files}-დან ექსპორტირებულია", + "playlist": "პლეისთი", + "no_loop": "არ არის ციკლი", + "generate": "გააგენერირეთ", + "undo": "დაბრუნება", + "download_all": "ყველას ჩამოტვირთვა", + "add_all_to_playlist": "ყველა დაამატეთ პლეისთში", + "add_all_to_queue": "ყველა დაამატეთ რიგში", + "play_all_next": "ყველა შემდეგ ითამაშე", + "pause": "შეჩერება", + "view_all": "ყველა ნახვა", + "no_tracks_added_yet": "გაჩნდება რომ ჯერ არ გაქვთ დამატებული ტრეკები", + "no_tracks": "გავლებული არ ჩანს არ არსებობს ტრეკები", + "no_tracks_listened_yet": "გქონდეთ გრძნობა, რომ ჯერ არაფერი უსმენია", + "not_following_artists": "არ მიჰყვებით რომელიმე არტისტს", + "no_favorite_albums_yet": "გაჩნდება რომ ჯერ არ გაქვთ დამატებული ალბომები თქვენს ფავორიტებში", + "no_logs_found": "ჩაწერები ვერ მოიძებნა", + "youtube_engine": "YouTube ძრავა", + "youtube_engine_not_installed_title": "{engine} არ არის ინსტალირებული", + "youtube_engine_not_installed_message": "{engine} არ არის ინსტალირებული თქვენს სისტემაში.", + "youtube_engine_set_path": "დარწმუნდით, რომ ის ხელმისაწვდომია PATH ცვლადში ან\nდაუყავით {engine} პროგრამის ფაილის სრული გზა", + "youtube_engine_unix_issue_message": "macOS/Linux/Unix მსგავსი ოპერაციული სისტემებში, .zshrc/.bashrc/.bash_profile-ით პათის დაყენება ვერ იმუშავებს.\nთქვენ უნდა დააყენოთ პათი შელ ფაილში", + "download": "ჩამოტვირთვა", + "file_not_found": "ფაილი ვერ მოიძებნა", + "custom": "პერსონალიზირებული", + "add_custom_url": "დამატება პერსონალური URL" } \ No newline at end of file diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 7e368081..6c8031b5 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -402,5 +402,30 @@ "export_cache_files": "캐시된 파일 내보내기", "found_n_files": "{count}개의 파일을 찾았습니다", "export_cache_confirmation": "이 파일들을 내보내시겠습니까", - "exported_n_out_of_m_files": "{files}개 중 {filesExported}개 파일을 내보냈습니다" + "exported_n_out_of_m_files": "{files}개 중 {filesExported}개 파일을 내보냈습니다", + "playlist": "재생 목록", + "no_loop": "반복 없음", + "generate": "생성", + "undo": "실행 취소", + "download_all": "모두 다운로드", + "add_all_to_playlist": "모두 재생 목록에 추가", + "add_all_to_queue": "모두 큐에 추가", + "play_all_next": "모두 다음에 재생", + "pause": "일시 정지", + "view_all": "모두 보기", + "no_tracks_added_yet": "아직 트랙을 추가하지 않은 것 같습니다", + "no_tracks": "여기에 트랙이 없는 것 같습니다", + "no_tracks_listened_yet": "아직 아무 것도 듣지 않은 것 같습니다", + "not_following_artists": "아티스트를 팔로우하지 않고 있습니다", + "no_favorite_albums_yet": "아직 즐겨찾기 앨범을 추가하지 않은 것 같습니다", + "no_logs_found": "로그를 찾을 수 없습니다", + "youtube_engine": "YouTube 엔진", + "youtube_engine_not_installed_title": "{engine}가 설치되지 않았습니다", + "youtube_engine_not_installed_message": "{engine}가 시스템에 설치되지 않았습니다.", + "youtube_engine_set_path": "PATH 변수에서 사용할 수 있는지 확인하거나\n아래에 {engine} 실행 파일의 절대 경로를 설정하세요", + "youtube_engine_unix_issue_message": "macOS/Linux/unix와 같은 운영 체제에서는 .zshrc/.bashrc/.bash_profile 등에 경로 설정이 작동하지 않습니다.\n셸 구성 파일에 경로를 설정해야 합니다", + "download": "다운로드", + "file_not_found": "파일을 찾을 수 없습니다", + "custom": "사용자 정의", + "add_custom_url": "사용자 정의 URL 추가" } \ No newline at end of file diff --git a/lib/l10n/app_ne.arb b/lib/l10n/app_ne.arb index 77eea7d0..beddc3ad 100644 --- a/lib/l10n/app_ne.arb +++ b/lib/l10n/app_ne.arb @@ -401,5 +401,30 @@ "export_cache_files": "क्यास फाइलहरू निर्यात गर्नुहोस्", "found_n_files": "{count} फाइलहरू फेला परे", "export_cache_confirmation": "यी फाइलहरू निर्यात गर्न चाहनुहुन्छ", - "exported_n_out_of_m_files": "{filesExported} मध्ये {files} फाइलहरू निर्यात गरियो" + "exported_n_out_of_m_files": "{filesExported} मध्ये {files} फाइलहरू निर्यात गरियो", + "playlist": "प्लेलिस्ट", + "no_loop": "कोई लूप नहीं", + "generate": "जनरेट", + "undo": "पूर्ववत", + "download_all": "सभी डाउनलोड करें", + "add_all_to_playlist": "सभी को प्लेलिस्ट में जोड़ें", + "add_all_to_queue": "सभी को कतार में जोड़ें", + "play_all_next": "सभी को अगला प्ले करें", + "pause": "विराम", + "view_all": "सभी देखें", + "no_tracks_added_yet": "लगता है आपने अभी तक कोई ट्रैक नहीं जोड़ा है", + "no_tracks": "यहाँ कोई ट्रैक नहीं दिख रहे हैं", + "no_tracks_listened_yet": "आपने अभी तक कुछ नहीं सुना है ऐसा लगता है", + "not_following_artists": "आप किसी कलाकार को फॉलो नहीं कर रहे हैं", + "no_favorite_albums_yet": "लगता है आपने अभी तक कोई एल्बम पसंदीदा में नहीं जोड़ा है", + "no_logs_found": "कोई लॉग नहीं मिला", + "youtube_engine": "YouTube इंजन", + "youtube_engine_not_installed_title": "{engine} इंस्टॉल नहीं है", + "youtube_engine_not_installed_message": "{engine} आपके सिस्टम में इंस्टॉल नहीं है।", + "youtube_engine_set_path": "सुनिश्चित करें कि यह PATH वेरिएबल में उपलब्ध है या\nनीचे {engine} एक्जीक्यूटेबल का पूर्ण पथ सेट करें", + "youtube_engine_unix_issue_message": "macOS/Linux/unix जैसे ऑपरेटिंग सिस्टम में, .zshrc/.bashrc/.bash_profile आदि में पथ सेट करना काम नहीं करेगा।\nआपको शेल कॉन्फ़िगरेशन फ़ाइल में पथ सेट करना होगा", + "download": "डाउनलोड", + "file_not_found": "फ़ाइल नहीं मिली", + "custom": "कस्टम", + "add_custom_url": "कस्टम URL जोड़ें" } \ No newline at end of file diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 50b5e3bd..2127b382 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -402,5 +402,30 @@ "export_cache_files": "Gecacheerde bestanden exporteren", "found_n_files": "{count} bestanden gevonden", "export_cache_confirmation": "Wilt u deze bestanden exporteren naar", - "exported_n_out_of_m_files": "{filesExported} van de {files} bestanden geëxporteerd" + "exported_n_out_of_m_files": "{filesExported} van de {files} bestanden geëxporteerd", + "playlist": "Afspeellijst", + "no_loop": "Geen herhaling", + "generate": "Genereren", + "undo": "Ongedaan maken", + "download_all": "Alles downloaden", + "add_all_to_playlist": "Voeg alles toe aan afspeellijst", + "add_all_to_queue": "Voeg alles toe aan wachtrij", + "play_all_next": "Speel alles volgende", + "pause": "Pauzeren", + "view_all": "Bekijk alles", + "no_tracks_added_yet": "Het lijkt erop dat je nog geen nummers hebt toegevoegd", + "no_tracks": "Het lijkt erop dat er hier geen nummers zijn", + "no_tracks_listened_yet": "Het lijkt erop dat je nog niets hebt beluisterd", + "not_following_artists": "Je volgt geen artiesten", + "no_favorite_albums_yet": "Het lijkt erop dat je nog geen albums aan je favorieten hebt toegevoegd", + "no_logs_found": "Geen logbestanden gevonden", + "youtube_engine": "YouTube Engine", + "youtube_engine_not_installed_title": "{engine} is niet geïnstalleerd", + "youtube_engine_not_installed_message": "{engine} is niet geïnstalleerd op je systeem.", + "youtube_engine_set_path": "Zorg ervoor dat het beschikbaar is in de PATH-variabele of\nstel het absolute pad naar de {engine} uitvoerbare bestanden in", + "youtube_engine_unix_issue_message": "Op macOS/Linux/unix-achtige besturingssystemen werkt het instellen van paden in .zshrc/.bashrc/.bash_profile enz. niet.\nJe moet het pad instellen in het shell-configuratiebestand", + "download": "Downloaden", + "file_not_found": "Bestand niet gevonden", + "custom": "Aangepast", + "add_custom_url": "Voeg aangepaste URL toe" } \ No newline at end of file diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 11ab51ce..ade74c90 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -401,5 +401,30 @@ "export_cache_files": "Eksportuj pliki z pamięci podręcznej", "found_n_files": "Znaleziono {count} plików", "export_cache_confirmation": "Czy chcesz wyeksportować te pliki do", - "exported_n_out_of_m_files": "Wyeksportowano {filesExported} z {files} plików" + "exported_n_out_of_m_files": "Wyeksportowano {filesExported} z {files} plików", + "playlist": "Playlista", + "no_loop": "Brak pętli", + "generate": "Generuj", + "undo": "Cofnij", + "download_all": "Pobierz wszystko", + "add_all_to_playlist": "Dodaj wszystko do playlisty", + "add_all_to_queue": "Dodaj wszystko do kolejki", + "play_all_next": "Odtwórz wszystko następnie", + "pause": "Pauza", + "view_all": "Zobacz wszystko", + "no_tracks_added_yet": "Wygląda na to, że jeszcze nie dodałeś żadnych utworów", + "no_tracks": "Wygląda na to, że tutaj nie ma żadnych utworów", + "no_tracks_listened_yet": "Wygląda na to, że jeszcze nic nie słuchałeś", + "not_following_artists": "Nie obserwujesz żadnych artystów", + "no_favorite_albums_yet": "Wygląda na to, że jeszcze nie dodałeś żadnych albumów do ulubionych", + "no_logs_found": "Nie znaleziono żadnych logów", + "youtube_engine": "Silnik YouTube", + "youtube_engine_not_installed_title": "{engine} nie jest zainstalowany", + "youtube_engine_not_installed_message": "{engine} nie jest zainstalowany w systemie.", + "youtube_engine_set_path": "Upewnij się, że jest dostępny w zmiennej PATH lub\nustaw absolutną ścieżkę do pliku wykonywalnego {engine} poniżej", + "youtube_engine_unix_issue_message": "W systemach macOS/Linux/unix, ustawianie ścieżki w .zshrc/.bashrc/.bash_profile itp. nie będzie działać.\nMusisz ustawić ścieżkę w pliku konfiguracyjnym powłoki", + "download": "Pobierz", + "file_not_found": "Plik nie znaleziony", + "custom": "Niestandardowy", + "add_custom_url": "Dodaj niestandardowy URL" } \ No newline at end of file diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 72841eab..6b1098a9 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -401,5 +401,30 @@ "export_cache_files": "Exportar Arquivos em Cache", "found_n_files": "Encontrados {count} arquivos", "export_cache_confirmation": "Deseja exportar estes arquivos para", - "exported_n_out_of_m_files": "Exportados {filesExported} de {files} arquivos" + "exported_n_out_of_m_files": "Exportados {filesExported} de {files} arquivos", + "playlist": "Playlist", + "no_loop": "Sem loop", + "generate": "Gerar", + "undo": "Desfazer", + "download_all": "Baixar tudo", + "add_all_to_playlist": "Adicionar tudo à playlist", + "add_all_to_queue": "Adicionar tudo à fila", + "play_all_next": "Reproduzir tudo a seguir", + "pause": "Pausar", + "view_all": "Ver tudo", + "no_tracks_added_yet": "Parece que você ainda não adicionou nenhuma faixa", + "no_tracks": "Parece que não há faixas aqui", + "no_tracks_listened_yet": "Parece que você ainda não ouviu nada", + "not_following_artists": "Você não está seguindo nenhum artista", + "no_favorite_albums_yet": "Parece que você ainda não adicionou nenhum álbum aos favoritos", + "no_logs_found": "Nenhum log encontrado", + "youtube_engine": "Motor YouTube", + "youtube_engine_not_installed_title": "{engine} não está instalado", + "youtube_engine_not_installed_message": "{engine} não está instalado no seu sistema.", + "youtube_engine_set_path": "Certifique-se de que está disponível na variável PATH ou\ndefina o caminho absoluto para o executável {engine} abaixo", + "youtube_engine_unix_issue_message": "Em sistemas macOS/Linux/unix, definir o caminho no .zshrc/.bashrc/.bash_profile etc. não funcionará.\nVocê precisa definir o caminho no arquivo de configuração do shell", + "download": "Baixar", + "file_not_found": "Arquivo não encontrado", + "custom": "Personalizado", + "add_custom_url": "Adicionar URL personalizada" } \ No newline at end of file diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 6be53ba9..461e8da8 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -401,5 +401,30 @@ "export_cache_files": "Экспортировать кэшированные файлы", "found_n_files": "Найдено {count} файлов", "export_cache_confirmation": "Вы хотите экспортировать эти файлы в", - "exported_n_out_of_m_files": "Экспортировано {filesExported} из {files} файлов" + "exported_n_out_of_m_files": "Экспортировано {filesExported} из {files} файлов", + "playlist": "Плейлист", + "no_loop": "Без повтора", + "generate": "Генерировать", + "undo": "Отменить", + "download_all": "Скачать все", + "add_all_to_playlist": "Добавить все в плейлист", + "add_all_to_queue": "Добавить все в очередь", + "play_all_next": "Воспроизвести все следующее", + "pause": "Пауза", + "view_all": "Просмотреть все", + "no_tracks_added_yet": "Похоже, вы ещё не добавили ни одного трека", + "no_tracks": "Похоже, здесь нет треков", + "no_tracks_listened_yet": "Похоже, вы ещё ничего не слушали", + "not_following_artists": "Вы не подписаны на художников", + "no_favorite_albums_yet": "Похоже, вы ещё не добавили ни одного альбома в избранное", + "no_logs_found": "Логи не найдены", + "youtube_engine": "YouTube Движок", + "youtube_engine_not_installed_title": "{engine} не установлен", + "youtube_engine_not_installed_message": "{engine} не установлен в вашей системе.", + "youtube_engine_set_path": "Убедитесь, что он доступен в переменной PATH или\nустановите абсолютный путь к исполнимому файлу {engine} ниже", + "youtube_engine_unix_issue_message": "В macOS/Linux/Unix-подобных ОС, установка пути в .zshrc/.bashrc/.bash_profile и т.д. не будет работать.\nВы должны установить путь в файле конфигурации оболочки", + "download": "Скачать", + "file_not_found": "Файл не найден", + "custom": "Пользовательский", + "add_custom_url": "Добавить пользовательский URL" } \ No newline at end of file diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 1b72f1a3..8e9a0318 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -402,5 +402,30 @@ "export_cache_files": "ส่งออกไฟล์แคช", "found_n_files": "พบ {count} ไฟล์", "export_cache_confirmation": "คุณต้องการส่งออกไฟล์เหล่านี้ไปยัง", - "exported_n_out_of_m_files": "ส่งออก {filesExported} จาก {files} ไฟล์" + "exported_n_out_of_m_files": "ส่งออก {filesExported} จาก {files} ไฟล์", + "playlist": "เพลย์ลิสต์", + "no_loop": "ไม่มีการวนซ้ำ", + "generate": "สร้าง", + "undo": "ย้อนกลับ", + "download_all": "ดาวน์โหลดทั้งหมด", + "add_all_to_playlist": "เพิ่มทั้งหมดในเพลย์ลิสต์", + "add_all_to_queue": "เพิ่มทั้งหมดในคิว", + "play_all_next": "เล่นทั้งหมดถัดไป", + "pause": "หยุดชั่วคราว", + "view_all": "ดูทั้งหมด", + "no_tracks_added_yet": "ดูเหมือนคุณยังไม่ได้เพิ่มเพลงใด ๆ", + "no_tracks": "ดูเหมือนจะไม่มีเพลงที่นี่", + "no_tracks_listened_yet": "ดูเหมือนคุณยังไม่ได้ฟังอะไรเลย", + "not_following_artists": "คุณไม่ได้ติดตามศิลปินใด ๆ", + "no_favorite_albums_yet": "ดูเหมือนคุณยังไม่ได้เพิ่มอัลบัมใด ๆ ในรายการโปรด", + "no_logs_found": "ไม่พบบันทึก", + "youtube_engine": "เครื่องมือ YouTube", + "youtube_engine_not_installed_title": "{engine} ยังไม่ได้ติดตั้ง", + "youtube_engine_not_installed_message": "{engine} ยังไม่ได้ติดตั้งในระบบของคุณ", + "youtube_engine_set_path": "ตรวจสอบให้แน่ใจว่ามันมีอยู่ในตัวแปร PATH หรือ\nตั้งค่าพาธที่แท้จริงของไฟล์ที่สามารถทำงานได้ {engine} ด้านล่าง", + "youtube_engine_unix_issue_message": "ใน macOS/Linux/Unix อย่าง OS การตั้งค่าพาธใน .zshrc/.bashrc/.bash_profile เป็นต้น จะไม่ทำงาน\nคุณต้องตั้งค่าพาธในไฟล์การกำหนดค่า shell", + "download": "ดาวน์โหลด", + "file_not_found": "ไม่พบไฟล์", + "custom": "กำหนดเอง", + "add_custom_url": "เพิ่ม URL แบบกำหนดเอง" } \ No newline at end of file diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 7f2bf5fb..1e659cc5 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -401,5 +401,30 @@ "export_cache_files": "Önbelleğe Alınmış Dosyaları Dışa Aktar", "found_n_files": "{count} dosya bulundu", "export_cache_confirmation": "Bu dosyaları dışa aktarmak istiyor musunuz", - "exported_n_out_of_m_files": "{filesExported} / {files} dosya dışa aktarıldı" + "exported_n_out_of_m_files": "{filesExported} / {files} dosya dışa aktarıldı", + "playlist": "Çalma Listesi", + "no_loop": "Dönüş Yok", + "generate": "Oluştur", + "undo": "Geri Al", + "download_all": "Tümünü İndir", + "add_all_to_playlist": "Hepsini çalma listesine ekle", + "add_all_to_queue": "Hepsini kuyruğa ekle", + "play_all_next": "Hepsini bir sonraki çal", + "pause": "Duraklat", + "view_all": "Tümünü Gör", + "no_tracks_added_yet": "Henüz hiçbir şarkı eklemediniz gibi görünüyor", + "no_tracks": "Burada hiç şarkı yok gibi görünüyor", + "no_tracks_listened_yet": "Henüz hiçbir şey dinlemediniz gibi görünüyor", + "not_following_artists": "Hiçbir sanatçıyı takip etmiyorsunuz", + "no_favorite_albums_yet": "Henüz favorilerinize herhangi bir albüm eklemediniz gibi görünüyor", + "no_logs_found": "Log bulunamadı", + "youtube_engine": "YouTube Motoru", + "youtube_engine_not_installed_title": "{engine} Yüklü değil", + "youtube_engine_not_installed_message": "{engine} sisteminizde yüklü değil.", + "youtube_engine_set_path": "PATH değişkeninde kullanılabilir olduğundan emin olun veya\n{engine} çalıştırılabilir dosyasının mutlak yolunu aşağıda ayarlayın", + "youtube_engine_unix_issue_message": "macOS/Linux/Unix benzeri işletim sistemlerinde, .zshrc/.bashrc/.bash_profile gibi dosyalarda yol ayarlamak işe yaramaz.\nYolunuzu kabuk yapılandırma dosyasına ayarlamanız gerekir", + "download": "İndir", + "file_not_found": "Dosya bulunamadı", + "custom": "Özel", + "add_custom_url": "Özel URL ekle" } \ No newline at end of file diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 245c87e1..bc731240 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -401,5 +401,30 @@ "export_cache_files": "Експортувати кешовані файли", "found_n_files": "Знайдено {count} файлів", "export_cache_confirmation": "Ви хочете експортувати ці файли до", - "exported_n_out_of_m_files": "Експортовано {filesExported} з {files} файлів" + "exported_n_out_of_m_files": "Експортовано {filesExported} з {files} файлів", + "playlist": "Плейлист", + "no_loop": "Без повтору", + "generate": "Генерувати", + "undo": "Скасувати", + "download_all": "Завантажити все", + "add_all_to_playlist": "Додати все до плейлиста", + "add_all_to_queue": "Додати все в чергу", + "play_all_next": "Відтворити все наступне", + "pause": "Пауза", + "view_all": "Переглянути все", + "no_tracks_added_yet": "Здається, ви ще не додали жодної пісні", + "no_tracks": "Здається, тут немає пісень", + "no_tracks_listened_yet": "Здається, ви ще нічого не слухали", + "not_following_artists": "Ви не підписані на жодного артиста", + "no_favorite_albums_yet": "Здається, ви ще не додали жодного альбому в улюблені", + "no_logs_found": "Жодних журналів не знайдено", + "youtube_engine": "YouTube Двигун", + "youtube_engine_not_installed_title": "{engine} не встановлено", + "youtube_engine_not_installed_message": "{engine} не встановлено на вашій системі.", + "youtube_engine_set_path": "Переконайтесь, що він доступний у змінній PATH або\nвстановіть абсолютний шлях до виконуваного файлу {engine} нижче", + "youtube_engine_unix_issue_message": "У macOS/Linux/Unix-подібних ОС, встановлення шляху в .zshrc/.bashrc/.bash_profile тощо не працює.\nВам потрібно налаштувати шлях у файлі конфігурації оболонки", + "download": "Завантажити", + "file_not_found": "Файл не знайдено", + "custom": "Користувацький", + "add_custom_url": "Додати користувацький URL" } \ No newline at end of file diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 37f7f709..75f8e3f7 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -401,5 +401,30 @@ "export_cache_files": "Xuất các tệp được lưu trong bộ nhớ đệm", "found_n_files": "Tìm thấy {count} tệp", "export_cache_confirmation": "Bạn có muốn xuất các tệp này đến", - "exported_n_out_of_m_files": "Đã xuất {filesExported} trên {files} tệp" + "exported_n_out_of_m_files": "Đã xuất {filesExported} trên {files} tệp", + "playlist": "Danh sách phát", + "no_loop": "Không lặp lại", + "generate": "Tạo", + "undo": "Hoàn tác", + "download_all": "Tải xuống tất cả", + "add_all_to_playlist": "Thêm tất cả vào danh sách phát", + "add_all_to_queue": "Thêm tất cả vào danh sách chờ", + "play_all_next": "Chơi tất cả tiếp theo", + "pause": "Tạm dừng", + "view_all": "Xem tất cả", + "no_tracks_added_yet": "Có vẻ bạn chưa thêm bất kỳ bài hát nào", + "no_tracks": "Có vẻ không có bài hát nào ở đây", + "no_tracks_listened_yet": "Có vẻ bạn chưa nghe gì cả", + "not_following_artists": "Bạn không đang theo dõi bất kỳ nghệ sĩ nào", + "no_favorite_albums_yet": "Có vẻ bạn chưa thêm album nào vào danh sách yêu thích", + "no_logs_found": "Không tìm thấy nhật ký", + "youtube_engine": "Công cụ YouTube", + "youtube_engine_not_installed_title": "{engine} chưa được cài đặt", + "youtube_engine_not_installed_message": "{engine} chưa được cài đặt trong hệ thống của bạn.", + "youtube_engine_set_path": "Đảm bảo nó có sẵn trong biến PATH hoặc\nđặt đường dẫn tuyệt đối đến tệp thực thi {engine} dưới đây", + "youtube_engine_unix_issue_message": "Trên macOS/Linux/Unix, việc thiết lập đường dẫn trong .zshrc/.bashrc/.bash_profile v.v. sẽ không hoạt động.\nBạn cần thiết lập đường dẫn trong tệp cấu hình shell", + "download": "Tải xuống", + "file_not_found": "Không tìm thấy tệp", + "custom": "Tùy chỉnh", + "add_custom_url": "Thêm URL tùy chỉnh" } \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index dc2920ed..03ebae12 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -401,5 +401,30 @@ "export_cache_files": "导出缓存文件", "found_n_files": "找到 {count} 个文件", "export_cache_confirmation": "您要导出这些文件到", - "exported_n_out_of_m_files": "导出了 {filesExported} / {files} 个文件" + "exported_n_out_of_m_files": "导出了 {filesExported} / {files} 个文件", + "playlist": "播放列表", + "no_loop": "无循环", + "generate": "生成", + "undo": "撤销", + "download_all": "下载全部", + "add_all_to_playlist": "将全部添加到播放列表", + "add_all_to_queue": "将全部添加到队列", + "play_all_next": "播放全部下一首", + "pause": "暂停", + "view_all": "查看所有", + "no_tracks_added_yet": "看起来你还没有添加任何曲目", + "no_tracks": "看起来这里没有任何曲目", + "no_tracks_listened_yet": "看起来你还没有听任何东西", + "not_following_artists": "你没有关注任何艺术家", + "no_favorite_albums_yet": "看起来你还没有将任何专辑添加到收藏夹", + "no_logs_found": "未找到日志", + "youtube_engine": "YouTube 引擎", + "youtube_engine_not_installed_title": "{engine} 未安装", + "youtube_engine_not_installed_message": "{engine} 未在您的系统中安装。", + "youtube_engine_set_path": "确保它可用在 PATH 变量中,或\n设置 {engine} 可执行文件的绝对路径", + "youtube_engine_unix_issue_message": "在 macOS/Linux/Unix 类操作系统中,在 .zshrc/.bashrc/.bash_profile 等文件中设置路径无效。\n您需要在 shell 配置文件中设置路径", + "download": "下载", + "file_not_found": "文件未找到", + "custom": "自定义", + "add_custom_url": "添加自定义 URL" } \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index ebdc4b61..2dba8370 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -16,7 +16,7 @@ library l10n; -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; class L10n { static final all = [ diff --git a/lib/main.dart b/lib/main.dart index f13991e2..5cd916e5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,12 +3,13 @@ import 'dart:ui'; import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:flutter_discord_rpc/flutter_discord_rpc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:hive/hive.dart'; + +import 'package:home_widget/home_widget.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:local_notifier/local_notifier.dart'; import 'package:media_kit/media_kit.dart'; @@ -16,8 +17,8 @@ import 'package:metadata_god/metadata_god.dart'; import 'package:smtc_windows/smtc_windows.dart'; import 'package:spotube/collections/env.dart'; import 'package:spotube/collections/initializers.dart'; -import 'package:spotube/collections/routes.dart'; import 'package:spotube/collections/intents.dart'; +import 'package:spotube/collections/routes.dart'; import 'package:spotube/hooks/configurators/use_close_behavior.dart'; import 'package:spotube/hooks/configurators/use_deep_linking.dart'; import 'package:spotube/hooks/configurators/use_disable_battery_optimizations.dart'; @@ -25,14 +26,15 @@ import 'package:spotube/hooks/configurators/use_fix_window_stretching.dart'; import 'package:spotube/hooks/configurators/use_get_storage_perms.dart'; import 'package:spotube/hooks/configurators/use_has_touch.dart'; import 'package:spotube/models/database/database.dart'; +import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/provider/audio_player/audio_player_streams.dart'; import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/provider/glance/glance.dart'; import 'package:spotube/provider/server/bonsoir.dart'; import 'package:spotube/provider/server/server.dart'; import 'package:spotube/provider/tray_manager/tray_manager.dart'; import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/provider/connect/clients.dart'; -import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/cli/cli.dart'; @@ -40,17 +42,16 @@ import 'package:spotube/services/kv_store/encrypted_kv_store.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/wm_tools/wm_tools.dart'; -import 'package:spotube/themes/theme.dart'; -import 'package:spotube/utils/migrations/hive.dart'; import 'package:spotube/utils/migrations/sandbox.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:system_theme/system_theme.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:timezone/data/latest.dart' as tz; import 'package:window_manager/window_manager.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:yt_dlp_dart/yt_dlp_dart.dart'; +import 'package:flutter_new_pipe_extractor/flutter_new_pipe_extractor.dart'; Future main(List rawArgs) async { if (rawArgs.contains("web_view_title_bar")) { @@ -78,19 +79,23 @@ Future main(List rawArgs) async { // force High Refresh Rate on some Android devices (like One Plus) if (kIsAndroid) { await FlutterDisplayMode.setHighRefreshRate(); + await NewPipeExtractor.init(); } - if (kIsDesktop) { - await windowManager.setPreventClose(true); - } - - await SystemTheme.accentColor.load(); - if (!kIsWeb) { MetadataGod.initialize(); } + await KVStoreService.initialize(); + if (kIsDesktop) { + await windowManager.setPreventClose(true); + await YtDlp.instance + .setBinaryLocation( + KVStoreService.getYoutubeEnginePath(YoutubeClientEngine.ytDlp) ?? + "yt-dlp${kIsWindows ? '.exe' : ''}", + ) + .catchError((e, stack) => null); await FlutterDiscordRPC.initialize(Env.discordAppId); } @@ -98,23 +103,19 @@ Future main(List rawArgs) async { await SMTCWindows.initialize(); } - await KVStoreService.initialize(); await EncryptedKvStoreService.initialize(); - final hiveCacheDir = - kIsWeb ? null : (await getApplicationSupportDirectory()).path; - - Hive.init(hiveCacheDir); - final database = AppDatabase(); - await migrateFromHiveToDrift(database); - if (kIsDesktop) { await localNotifier.setup(appName: "Spotube"); await WindowManagerTools.initialize(); } + if (kIsIOS) { + HomeWidget.setAppGroupId("group.spotube_home_player_widget"); + } + runApp( ProviderScope( overrides: [ @@ -136,14 +137,10 @@ class Spotube extends HookConsumerWidget { Widget build(BuildContext context, ref) { final themeMode = ref.watch(userPreferencesProvider.select((s) => s.themeMode)); + final locale = ref.watch(userPreferencesProvider.select((s) => s.locale)); final accentMaterialColor = ref.watch(userPreferencesProvider.select((s) => s.accentColorScheme)); - final isAmoledTheme = - ref.watch(userPreferencesProvider.select((s) => s.amoledDarkTheme)); - final locale = ref.watch(userPreferencesProvider.select((s) => s.locale)); - final paletteColor = - ref.watch(paletteProvider.select((s) => s?.dominantColor?.color)); - final router = ref.watch(routerProvider); + final router = useMemoized(() => AppRouter(ref), []); final hasTouchSupport = useHasTouch(); ref.listen(audioPlayerStreamListenersProvider, (_, __) {}); @@ -154,13 +151,17 @@ class Spotube extends HookConsumerWidget { useFixWindowStretching(); useDisableBatteryOptimizations(); - useDeepLinking(ref); + useDeepLinking(ref, router); useCloseBehavior(ref); useGetStoragePermissions(ref); useEffect(() { FlutterNativeSplash.remove(); + if (kIsMobile) { + HomeWidget.registerInteractivityCallback(glanceBackgroundCallback); + } + return () { /// For enabling hot reload for audio player if (!kDebugMode) return; @@ -168,20 +169,7 @@ class Spotube extends HookConsumerWidget { }; }, []); - final lightTheme = useMemoized( - () => theme(paletteColor ?? accentMaterialColor, Brightness.light, false), - [paletteColor, accentMaterialColor], - ); - final darkTheme = useMemoized( - () => theme( - paletteColor ?? accentMaterialColor, - Brightness.dark, - isAmoledTheme, - ), - [paletteColor, accentMaterialColor, isAmoledTheme], - ); - - return MaterialApp.router( + return ShadcnApp.router( supportedLocales: L10n.all, locale: locale.languageCode == "system" ? null : locale, localizationsDelegates: const [ @@ -190,7 +178,7 @@ class Spotube extends HookConsumerWidget { GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], - routerConfig: router, + routerConfig: router.config(), debugShowCheckedModeBanner: false, title: 'Spotube', builder: (context, child) { @@ -207,13 +195,49 @@ class Spotube extends HookConsumerWidget { child: child!, ); - if (kIsDesktop && !kIsMacOS) child = DragToResizeArea(child: child); + if (kIsLinux) { + child = DragToResizeArea( + resizeEdgeSize: 2.5, + child: child, + ); + } return child; }, + scaling: const AdaptiveScaling(1), + theme: ThemeData( + radius: .5, + iconTheme: const IconThemeProperties(), + colorScheme: + colorSchemeMap[accentMaterialColor.name]?.call(ThemeMode.light) ?? + ColorSchemes.lightOrange(), + surfaceOpacity: .8, + surfaceBlur: 10, + ), + darkTheme: ThemeData( + radius: .5, + iconTheme: const IconThemeProperties(), + colorScheme: + colorSchemeMap[accentMaterialColor.name]?.call(ThemeMode.dark) ?? + ColorSchemes.darkOrange(), + surfaceOpacity: .8, + surfaceBlur: 10, + ), + materialTheme: material.ThemeData( + brightness: switch (themeMode) { + ThemeMode.system => MediaQuery.platformBrightnessOf(context), + ThemeMode.light => Brightness.light, + ThemeMode.dark => Brightness.dark, + }, + splashFactory: material.NoSplash.splashFactory, + appBarTheme: const material.AppBarTheme( + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: 0, + shadowColor: Colors.transparent, + elevation: 0, + ), + ), themeMode: themeMode, - theme: lightTheme, - darkTheme: darkTheme, shortcuts: { ...WidgetsApp.defaultShortcuts.map((key, value) { return MapEntry( @@ -228,22 +252,42 @@ class Spotube extends HookConsumerWidget { LogicalKeyboardKey.digit1, LogicalKeyboardKey.control, LogicalKeyboardKey.shift, - ): HomeTabIntent(ref, tab: HomeTabs.browse), + ): HomeTabIntent(router, tab: HomeTabs.browse), LogicalKeySet( LogicalKeyboardKey.digit2, LogicalKeyboardKey.control, LogicalKeyboardKey.shift, - ): HomeTabIntent(ref, tab: HomeTabs.search), + ): HomeTabIntent(router, tab: HomeTabs.search), LogicalKeySet( LogicalKeyboardKey.digit3, LogicalKeyboardKey.control, LogicalKeyboardKey.shift, - ): HomeTabIntent(ref, tab: HomeTabs.library), + ): HomeTabIntent(router, tab: HomeTabs.lyrics), LogicalKeySet( LogicalKeyboardKey.digit4, LogicalKeyboardKey.control, LogicalKeyboardKey.shift, - ): HomeTabIntent(ref, tab: HomeTabs.lyrics), + ): HomeTabIntent(router, tab: HomeTabs.userPlaylists), + LogicalKeySet( + LogicalKeyboardKey.digit5, + LogicalKeyboardKey.control, + LogicalKeyboardKey.shift, + ): HomeTabIntent(router, tab: HomeTabs.userArtists), + LogicalKeySet( + LogicalKeyboardKey.digit6, + LogicalKeyboardKey.control, + LogicalKeyboardKey.shift, + ): HomeTabIntent(router, tab: HomeTabs.userAlbums), + LogicalKeySet( + LogicalKeyboardKey.digit7, + LogicalKeyboardKey.control, + LogicalKeyboardKey.shift, + ): HomeTabIntent(router, tab: HomeTabs.userLocalLibrary), + LogicalKeySet( + LogicalKeyboardKey.digit8, + LogicalKeyboardKey.control, + LogicalKeyboardKey.shift, + ): HomeTabIntent(router, tab: HomeTabs.userDownloads), LogicalKeySet( LogicalKeyboardKey.keyW, LogicalKeyboardKey.control, diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart index 0f30df19..199e7147 100644 --- a/lib/models/database/database.dart +++ b/lib/models/database/database.dart @@ -8,15 +8,19 @@ import 'package:encrypt/encrypt.dart'; import 'package:media_kit/media_kit.dart' hide Track; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' show ThemeMode, Colors; import 'package:spotify/spotify.dart' hide Playlist; import 'package:spotube/models/database/database.steps.dart'; import 'package:spotube/models/lyrics.dart'; import 'package:spotube/services/kv_store/encrypted_kv_store.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/sourced_track/enums.dart'; -import 'package:flutter/material.dart' hide Table, Key, View; +import 'package:flutter/widgets.dart' hide Table, Key, View; import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; import 'package:drift/native.dart'; +import 'package:spotube/services/youtube_engine/newpipe_engine.dart'; +import 'package:spotube/services/youtube_engine/youtube_explode_engine.dart'; +import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart'; import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; @@ -58,7 +62,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); @override - int get schemaVersion => 3; + int get schemaVersion => 4; @override MigrationStrategy get migration { @@ -77,6 +81,12 @@ class AppDatabase extends _$AppDatabase { schema.preferencesTable.cacheMusic, ); }, + from3To4: (m, schema) async { + await m.addColumn( + schema.preferencesTable, + schema.preferencesTable.youtubeClientEngine, + ); + }, ), ); } diff --git a/lib/models/database/database.g.dart b/lib/models/database/database.g.dart index 951b2ed5..cd004d69 100644 --- a/lib/models/database/database.g.dart +++ b/lib/models/database/database.g.dart @@ -760,6 +760,17 @@ class $PreferencesTableTable extends PreferencesTable defaultValue: Constant(AudioSource.youtube.name)) .withConverter( $PreferencesTableTable.$converteraudioSource); + static const VerificationMeta _youtubeClientEngineMeta = + const VerificationMeta('youtubeClientEngine'); + @override + late final GeneratedColumnWithTypeConverter + youtubeClientEngine = GeneratedColumn( + 'youtube_client_engine', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(YoutubeClientEngine.youtubeExplode.name)) + .withConverter( + $PreferencesTableTable.$converteryoutubeClientEngine); static const VerificationMeta _streamMusicCodecMeta = const VerificationMeta('streamMusicCodec'); @override @@ -845,6 +856,7 @@ class $PreferencesTableTable extends PreferencesTable invidiousInstance, themeMode, audioSource, + youtubeClientEngine, streamMusicCodec, downloadMusicCodec, discordPresence, @@ -937,6 +949,8 @@ class $PreferencesTableTable extends PreferencesTable } context.handle(_themeModeMeta, const VerificationResult.success()); context.handle(_audioSourceMeta, const VerificationResult.success()); + context.handle( + _youtubeClientEngineMeta, const VerificationResult.success()); context.handle(_streamMusicCodecMeta, const VerificationResult.success()); context.handle(_downloadMusicCodecMeta, const VerificationResult.success()); if (data.containsKey('discord_presence')) { @@ -1025,6 +1039,9 @@ class $PreferencesTableTable extends PreferencesTable audioSource: $PreferencesTableTable.$converteraudioSource.fromSql( attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}audio_source'])!), + youtubeClientEngine: $PreferencesTableTable.$converteryoutubeClientEngine + .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, + data['${effectivePrefix}youtube_client_engine'])!), streamMusicCodec: $PreferencesTableTable.$converterstreamMusicCodec .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}stream_music_codec'])!), @@ -1069,6 +1086,9 @@ class $PreferencesTableTable extends PreferencesTable const EnumNameConverter(ThemeMode.values); static JsonTypeConverter2 $converteraudioSource = const EnumNameConverter(AudioSource.values); + static JsonTypeConverter2 + $converteryoutubeClientEngine = + const EnumNameConverter(YoutubeClientEngine.values); static JsonTypeConverter2 $converterstreamMusicCodec = const EnumNameConverter(SourceCodecs.values); @@ -1100,6 +1120,7 @@ class PreferencesTableData extends DataClass final String invidiousInstance; final ThemeMode themeMode; final AudioSource audioSource; + final YoutubeClientEngine youtubeClientEngine; final SourceCodecs streamMusicCodec; final SourceCodecs downloadMusicCodec; final bool discordPresence; @@ -1128,6 +1149,7 @@ class PreferencesTableData extends DataClass required this.invidiousInstance, required this.themeMode, required this.audioSource, + required this.youtubeClientEngine, required this.streamMusicCodec, required this.downloadMusicCodec, required this.discordPresence, @@ -1190,6 +1212,11 @@ class PreferencesTableData extends DataClass map['audio_source'] = Variable( $PreferencesTableTable.$converteraudioSource.toSql(audioSource)); } + { + map['youtube_client_engine'] = Variable($PreferencesTableTable + .$converteryoutubeClientEngine + .toSql(youtubeClientEngine)); + } { map['stream_music_codec'] = Variable($PreferencesTableTable .$converterstreamMusicCodec @@ -1230,6 +1257,7 @@ class PreferencesTableData extends DataClass invidiousInstance: Value(invidiousInstance), themeMode: Value(themeMode), audioSource: Value(audioSource), + youtubeClientEngine: Value(youtubeClientEngine), streamMusicCodec: Value(streamMusicCodec), downloadMusicCodec: Value(downloadMusicCodec), discordPresence: Value(discordPresence), @@ -1273,6 +1301,8 @@ class PreferencesTableData extends DataClass .fromJson(serializer.fromJson(json['themeMode'])), audioSource: $PreferencesTableTable.$converteraudioSource .fromJson(serializer.fromJson(json['audioSource'])), + youtubeClientEngine: $PreferencesTableTable.$converteryoutubeClientEngine + .fromJson(serializer.fromJson(json['youtubeClientEngine'])), streamMusicCodec: $PreferencesTableTable.$converterstreamMusicCodec .fromJson(serializer.fromJson(json['streamMusicCodec'])), downloadMusicCodec: $PreferencesTableTable.$converterdownloadMusicCodec @@ -1316,6 +1346,9 @@ class PreferencesTableData extends DataClass $PreferencesTableTable.$converterthemeMode.toJson(themeMode)), 'audioSource': serializer.toJson( $PreferencesTableTable.$converteraudioSource.toJson(audioSource)), + 'youtubeClientEngine': serializer.toJson($PreferencesTableTable + .$converteryoutubeClientEngine + .toJson(youtubeClientEngine)), 'streamMusicCodec': serializer.toJson($PreferencesTableTable .$converterstreamMusicCodec .toJson(streamMusicCodec)), @@ -1351,6 +1384,7 @@ class PreferencesTableData extends DataClass String? invidiousInstance, ThemeMode? themeMode, AudioSource? audioSource, + YoutubeClientEngine? youtubeClientEngine, SourceCodecs? streamMusicCodec, SourceCodecs? downloadMusicCodec, bool? discordPresence, @@ -1379,6 +1413,7 @@ class PreferencesTableData extends DataClass invidiousInstance: invidiousInstance ?? this.invidiousInstance, themeMode: themeMode ?? this.themeMode, audioSource: audioSource ?? this.audioSource, + youtubeClientEngine: youtubeClientEngine ?? this.youtubeClientEngine, streamMusicCodec: streamMusicCodec ?? this.streamMusicCodec, downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, discordPresence: discordPresence ?? this.discordPresence, @@ -1439,6 +1474,9 @@ class PreferencesTableData extends DataClass themeMode: data.themeMode.present ? data.themeMode.value : this.themeMode, audioSource: data.audioSource.present ? data.audioSource.value : this.audioSource, + youtubeClientEngine: data.youtubeClientEngine.present + ? data.youtubeClientEngine.value + : this.youtubeClientEngine, streamMusicCodec: data.streamMusicCodec.present ? data.streamMusicCodec.value : this.streamMusicCodec, @@ -1483,6 +1521,7 @@ class PreferencesTableData extends DataClass ..write('invidiousInstance: $invidiousInstance, ') ..write('themeMode: $themeMode, ') ..write('audioSource: $audioSource, ') + ..write('youtubeClientEngine: $youtubeClientEngine, ') ..write('streamMusicCodec: $streamMusicCodec, ') ..write('downloadMusicCodec: $downloadMusicCodec, ') ..write('discordPresence: $discordPresence, ') @@ -1516,6 +1555,7 @@ class PreferencesTableData extends DataClass invidiousInstance, themeMode, audioSource, + youtubeClientEngine, streamMusicCodec, downloadMusicCodec, discordPresence, @@ -1548,6 +1588,7 @@ class PreferencesTableData extends DataClass other.invidiousInstance == this.invidiousInstance && other.themeMode == this.themeMode && other.audioSource == this.audioSource && + other.youtubeClientEngine == this.youtubeClientEngine && other.streamMusicCodec == this.streamMusicCodec && other.downloadMusicCodec == this.downloadMusicCodec && other.discordPresence == this.discordPresence && @@ -1578,6 +1619,7 @@ class PreferencesTableCompanion extends UpdateCompanion { final Value invidiousInstance; final Value themeMode; final Value audioSource; + final Value youtubeClientEngine; final Value streamMusicCodec; final Value downloadMusicCodec; final Value discordPresence; @@ -1606,6 +1648,7 @@ class PreferencesTableCompanion extends UpdateCompanion { this.invidiousInstance = const Value.absent(), this.themeMode = const Value.absent(), this.audioSource = const Value.absent(), + this.youtubeClientEngine = const Value.absent(), this.streamMusicCodec = const Value.absent(), this.downloadMusicCodec = const Value.absent(), this.discordPresence = const Value.absent(), @@ -1635,6 +1678,7 @@ class PreferencesTableCompanion extends UpdateCompanion { this.invidiousInstance = const Value.absent(), this.themeMode = const Value.absent(), this.audioSource = const Value.absent(), + this.youtubeClientEngine = const Value.absent(), this.streamMusicCodec = const Value.absent(), this.downloadMusicCodec = const Value.absent(), this.discordPresence = const Value.absent(), @@ -1664,6 +1708,7 @@ class PreferencesTableCompanion extends UpdateCompanion { Expression? invidiousInstance, Expression? themeMode, Expression? audioSource, + Expression? youtubeClientEngine, Expression? streamMusicCodec, Expression? downloadMusicCodec, Expression? discordPresence, @@ -1695,6 +1740,8 @@ class PreferencesTableCompanion extends UpdateCompanion { if (invidiousInstance != null) 'invidious_instance': invidiousInstance, if (themeMode != null) 'theme_mode': themeMode, if (audioSource != null) 'audio_source': audioSource, + if (youtubeClientEngine != null) + 'youtube_client_engine': youtubeClientEngine, if (streamMusicCodec != null) 'stream_music_codec': streamMusicCodec, if (downloadMusicCodec != null) 'download_music_codec': downloadMusicCodec, @@ -1727,6 +1774,7 @@ class PreferencesTableCompanion extends UpdateCompanion { Value? invidiousInstance, Value? themeMode, Value? audioSource, + Value? youtubeClientEngine, Value? streamMusicCodec, Value? downloadMusicCodec, Value? discordPresence, @@ -1755,6 +1803,7 @@ class PreferencesTableCompanion extends UpdateCompanion { invidiousInstance: invidiousInstance ?? this.invidiousInstance, themeMode: themeMode ?? this.themeMode, audioSource: audioSource ?? this.audioSource, + youtubeClientEngine: youtubeClientEngine ?? this.youtubeClientEngine, streamMusicCodec: streamMusicCodec ?? this.streamMusicCodec, downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, discordPresence: discordPresence ?? this.discordPresence, @@ -1845,6 +1894,11 @@ class PreferencesTableCompanion extends UpdateCompanion { .$converteraudioSource .toSql(audioSource.value)); } + if (youtubeClientEngine.present) { + map['youtube_client_engine'] = Variable($PreferencesTableTable + .$converteryoutubeClientEngine + .toSql(youtubeClientEngine.value)); + } if (streamMusicCodec.present) { map['stream_music_codec'] = Variable($PreferencesTableTable .$converterstreamMusicCodec @@ -1894,6 +1948,7 @@ class PreferencesTableCompanion extends UpdateCompanion { ..write('invidiousInstance: $invidiousInstance, ') ..write('themeMode: $themeMode, ') ..write('audioSource: $audioSource, ') + ..write('youtubeClientEngine: $youtubeClientEngine, ') ..write('streamMusicCodec: $streamMusicCodec, ') ..write('downloadMusicCodec: $downloadMusicCodec, ') ..write('discordPresence: $discordPresence, ') @@ -4565,6 +4620,7 @@ typedef $$PreferencesTableTableCreateCompanionBuilder Value invidiousInstance, Value themeMode, Value audioSource, + Value youtubeClientEngine, Value streamMusicCodec, Value downloadMusicCodec, Value discordPresence, @@ -4595,6 +4651,7 @@ typedef $$PreferencesTableTableUpdateCompanionBuilder Value invidiousInstance, Value themeMode, Value audioSource, + Value youtubeClientEngine, Value streamMusicCodec, Value downloadMusicCodec, Value discordPresence, @@ -4702,6 +4759,12 @@ class $$PreferencesTableTableFilterComposer column: $table.audioSource, builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters + get youtubeClientEngine => $composableBuilder( + column: $table.youtubeClientEngine, + builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters get streamMusicCodec => $composableBuilder( column: $table.streamMusicCodec, @@ -4812,6 +4875,10 @@ class $$PreferencesTableTableOrderingComposer ColumnOrderings get audioSource => $composableBuilder( column: $table.audioSource, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get youtubeClientEngine => $composableBuilder( + column: $table.youtubeClientEngine, + builder: (column) => ColumnOrderings(column)); + ColumnOrderings get streamMusicCodec => $composableBuilder( column: $table.streamMusicCodec, builder: (column) => ColumnOrderings(column)); @@ -4915,6 +4982,10 @@ class $$PreferencesTableTableAnnotationComposer $composableBuilder( column: $table.audioSource, builder: (column) => column); + GeneratedColumnWithTypeConverter + get youtubeClientEngine => $composableBuilder( + column: $table.youtubeClientEngine, builder: (column) => column); + GeneratedColumnWithTypeConverter get streamMusicCodec => $composableBuilder( column: $table.streamMusicCodec, builder: (column) => column); @@ -4985,6 +5056,8 @@ class $$PreferencesTableTableTableManager extends RootTableManager< Value invidiousInstance = const Value.absent(), Value themeMode = const Value.absent(), Value audioSource = const Value.absent(), + Value youtubeClientEngine = + const Value.absent(), Value streamMusicCodec = const Value.absent(), Value downloadMusicCodec = const Value.absent(), Value discordPresence = const Value.absent(), @@ -5014,6 +5087,7 @@ class $$PreferencesTableTableTableManager extends RootTableManager< invidiousInstance: invidiousInstance, themeMode: themeMode, audioSource: audioSource, + youtubeClientEngine: youtubeClientEngine, streamMusicCodec: streamMusicCodec, downloadMusicCodec: downloadMusicCodec, discordPresence: discordPresence, @@ -5043,6 +5117,8 @@ class $$PreferencesTableTableTableManager extends RootTableManager< Value invidiousInstance = const Value.absent(), Value themeMode = const Value.absent(), Value audioSource = const Value.absent(), + Value youtubeClientEngine = + const Value.absent(), Value streamMusicCodec = const Value.absent(), Value downloadMusicCodec = const Value.absent(), Value discordPresence = const Value.absent(), @@ -5072,6 +5148,7 @@ class $$PreferencesTableTableTableManager extends RootTableManager< invidiousInstance: invidiousInstance, themeMode: themeMode, audioSource: audioSource, + youtubeClientEngine: youtubeClientEngine, streamMusicCodec: streamMusicCodec, downloadMusicCodec: downloadMusicCodec, discordPresence: discordPresence, diff --git a/lib/models/database/database.steps.dart b/lib/models/database/database.steps.dart index 40546bdb..8e0f8e3f 100644 --- a/lib/models/database/database.steps.dart +++ b/lib/models/database/database.steps.dart @@ -1,11 +1,11 @@ // dart format width=80 import 'package:drift/internal/versioned_schema.dart' as i0; import 'package:drift/drift.dart' as i1; -import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import -import 'package:flutter/material.dart'; +import 'package:drift/drift.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/services/sourced_track/enums.dart'; -import 'package:spotube/utils/migrations/adapters.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; // ignore_for_file: type=lint,unused_import // GENERATED BY drift_dev, DO NOT MODIFY. final class Schema2 extends i0.VersionedSchema { @@ -907,9 +907,291 @@ i1.GeneratedColumn _column_53(String aliasedName) => defaultConstraints: i1.GeneratedColumn.constraintIsAlways( 'CHECK ("cache_music" IN (0, 1))'), defaultValue: const Constant(true)); + +final class Schema4 extends i0.VersionedSchema { + Schema4({required super.database}) : super(version: 4); + @override + late final List entities = [ + authenticationTable, + blacklistTable, + preferencesTable, + scrobblerTable, + skipSegmentTable, + sourceMatchTable, + audioPlayerStateTable, + playlistTable, + playlistMediaTable, + historyTable, + lyricsTable, + uniqueBlacklist, + uniqTrackMatch, + ]; + late final Shape0 authenticationTable = Shape0( + source: i0.VersionedTable( + entityName: 'authentication_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape1 blacklistTable = Shape1( + source: i0.VersionedTable( + entityName: 'blacklist_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_4, + _column_5, + _column_6, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape12 preferencesTable = Shape12( + source: i0.VersionedTable( + entityName: 'preferences_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_7, + _column_8, + _column_9, + _column_10, + _column_11, + _column_12, + _column_13, + _column_14, + _column_15, + _column_16, + _column_17, + _column_18, + _column_19, + _column_20, + _column_21, + _column_22, + _column_23, + _column_24, + _column_25, + _column_26, + _column_54, + _column_27, + _column_28, + _column_29, + _column_30, + _column_31, + _column_53, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape3 scrobblerTable = Shape3( + source: i0.VersionedTable( + entityName: 'scrobbler_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_32, + _column_33, + _column_34, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape4 skipSegmentTable = Shape4( + source: i0.VersionedTable( + entityName: 'skip_segment_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_35, + _column_36, + _column_37, + _column_32, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape5 sourceMatchTable = Shape5( + source: i0.VersionedTable( + entityName: 'source_match_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_37, + _column_38, + _column_39, + _column_32, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape6 audioPlayerStateTable = Shape6( + source: i0.VersionedTable( + entityName: 'audio_player_state_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_40, + _column_41, + _column_42, + _column_43, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape7 playlistTable = Shape7( + source: i0.VersionedTable( + entityName: 'playlist_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_44, + _column_45, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape8 playlistMediaTable = Shape8( + source: i0.VersionedTable( + entityName: 'playlist_media_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_46, + _column_47, + _column_48, + _column_49, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape9 historyTable = Shape9( + source: i0.VersionedTable( + entityName: 'history_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_32, + _column_50, + _column_51, + _column_52, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape10 lyricsTable = Shape10( + source: i0.VersionedTable( + entityName: 'lyrics_table', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_0, + _column_37, + _column_52, + ], + attachedDatabase: database, + ), + alias: null); + final i1.Index uniqueBlacklist = i1.Index('unique_blacklist', + 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); + final i1.Index uniqTrackMatch = i1.Index('uniq_track_match', + 'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_id, source_type)'); +} + +class Shape12 extends i0.VersionedTable { + Shape12({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get audioQuality => + columnsByName['audio_quality']! as i1.GeneratedColumn; + i1.GeneratedColumn get albumColorSync => + columnsByName['album_color_sync']! as i1.GeneratedColumn; + i1.GeneratedColumn get amoledDarkTheme => + columnsByName['amoled_dark_theme']! as i1.GeneratedColumn; + i1.GeneratedColumn get checkUpdate => + columnsByName['check_update']! as i1.GeneratedColumn; + i1.GeneratedColumn get normalizeAudio => + columnsByName['normalize_audio']! as i1.GeneratedColumn; + i1.GeneratedColumn get showSystemTrayIcon => + columnsByName['show_system_tray_icon']! as i1.GeneratedColumn; + i1.GeneratedColumn get systemTitleBar => + columnsByName['system_title_bar']! as i1.GeneratedColumn; + i1.GeneratedColumn get skipNonMusic => + columnsByName['skip_non_music']! as i1.GeneratedColumn; + i1.GeneratedColumn get closeBehavior => + columnsByName['close_behavior']! as i1.GeneratedColumn; + i1.GeneratedColumn get accentColorScheme => + columnsByName['accent_color_scheme']! as i1.GeneratedColumn; + i1.GeneratedColumn get layoutMode => + columnsByName['layout_mode']! as i1.GeneratedColumn; + i1.GeneratedColumn get locale => + columnsByName['locale']! as i1.GeneratedColumn; + i1.GeneratedColumn get market => + columnsByName['market']! as i1.GeneratedColumn; + i1.GeneratedColumn get searchMode => + columnsByName['search_mode']! as i1.GeneratedColumn; + i1.GeneratedColumn get downloadLocation => + columnsByName['download_location']! as i1.GeneratedColumn; + i1.GeneratedColumn get localLibraryLocation => + columnsByName['local_library_location']! as i1.GeneratedColumn; + i1.GeneratedColumn get pipedInstance => + columnsByName['piped_instance']! as i1.GeneratedColumn; + i1.GeneratedColumn get invidiousInstance => + columnsByName['invidious_instance']! as i1.GeneratedColumn; + i1.GeneratedColumn get themeMode => + columnsByName['theme_mode']! as i1.GeneratedColumn; + i1.GeneratedColumn get audioSource => + columnsByName['audio_source']! as i1.GeneratedColumn; + i1.GeneratedColumn get youtubeClientEngine => + columnsByName['youtube_client_engine']! as i1.GeneratedColumn; + i1.GeneratedColumn get streamMusicCodec => + columnsByName['stream_music_codec']! as i1.GeneratedColumn; + i1.GeneratedColumn get downloadMusicCodec => + columnsByName['download_music_codec']! as i1.GeneratedColumn; + i1.GeneratedColumn get discordPresence => + columnsByName['discord_presence']! as i1.GeneratedColumn; + i1.GeneratedColumn get endlessPlayback => + columnsByName['endless_playback']! as i1.GeneratedColumn; + i1.GeneratedColumn get enableConnect => + columnsByName['enable_connect']! as i1.GeneratedColumn; + i1.GeneratedColumn get cacheMusic => + columnsByName['cache_music']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_54(String aliasedName) => + i1.GeneratedColumn('youtube_client_engine', aliasedName, false, + type: i1.DriftSqlType.string, + defaultValue: Constant(YoutubeClientEngine.youtubeExplode.name)); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, + required Future Function(i1.Migrator m, Schema4 schema) from3To4, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -923,6 +1205,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from2To3(migrator, schema); return 3; + case 3: + final schema = Schema4(database: database); + final migrator = i1.Migrator(database, schema); + await from3To4(migrator, schema); + return 4; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -932,9 +1219,11 @@ i0.MigrationStepWithVersion migrationSteps({ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, + required Future Function(i1.Migrator m, Schema4 schema) from3To4, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from1To2: from1To2, from2To3: from2To3, + from3To4: from3To4, )); diff --git a/lib/models/database/tables/preferences.dart b/lib/models/database/tables/preferences.dart index c3904c84..492ac1f9 100644 --- a/lib/models/database/tables/preferences.dart +++ b/lib/models/database/tables/preferences.dart @@ -20,6 +20,25 @@ enum AudioSource { String get label => name[0].toUpperCase() + name.substring(1); } +enum YoutubeClientEngine { + ytDlp("yt-dlp"), + youtubeExplode("YouTubeExplode"), + newPipe("NewPipe"); + + final String label; + + const YoutubeClientEngine(this.label); + + bool isAvailableForPlatform() { + return switch (this) { + YoutubeClientEngine.youtubeExplode => + YouTubeExplodeEngine.isAvailableForPlatform, + YoutubeClientEngine.ytDlp => YtDlpEngine.isAvailableForPlatform, + YoutubeClientEngine.newPipe => NewPipeEngine.isAvailableForPlatform, + }; + } +} + enum MusicCodec { m4a._("M4a (Best for downloaded music)"), weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"); @@ -84,6 +103,8 @@ class PreferencesTable extends Table { textEnum().withDefault(Constant(ThemeMode.system.name))(); TextColumn get audioSource => textEnum().withDefault(Constant(AudioSource.youtube.name))(); + TextColumn get youtubeClientEngine => textEnum() + .withDefault(Constant(YoutubeClientEngine.youtubeExplode.name))(); TextColumn get streamMusicCodec => textEnum().withDefault(Constant(SourceCodecs.weba.name))(); TextColumn get downloadMusicCodec => @@ -120,6 +141,7 @@ class PreferencesTable extends Table { invidiousInstance: "https://inv.nadeko.net", themeMode: ThemeMode.system, audioSource: AudioSource.youtube, + youtubeClientEngine: YoutubeClientEngine.youtubeExplode, streamMusicCodec: SourceCodecs.m4a, downloadMusicCodec: SourceCodecs.m4a, discordPresence: true, diff --git a/lib/models/spotify/home_feed.dart b/lib/models/spotify/home_feed.dart index e5c2f666..ad764304 100644 --- a/lib/models/spotify/home_feed.dart +++ b/lib/models/spotify/home_feed.dart @@ -29,7 +29,7 @@ class SpotifySectionPlaylist with _$SpotifySectionPlaylist { ..description = description ..collaborative = false ..images = images.map((e) => e.asImage).toList() - ..owner = (User()..displayName = "Spotify") + ..owner = (User()..displayName = owner) ..uri = uri ..type = "playlist"; } diff --git a/lib/models/spotify_spotube_credentials.dart b/lib/models/spotify_spotube_credentials.dart deleted file mode 100644 index 982ca64a..00000000 --- a/lib/models/spotify_spotube_credentials.dart +++ /dev/null @@ -1,30 +0,0 @@ -class SpotifySpotubeCredentials { - String clientId; - String accessToken; - DateTime expiration; - bool isAnonymous; - - SpotifySpotubeCredentials({ - required this.clientId, - required this.accessToken, - required this.expiration, - required this.isAnonymous, - }); - - SpotifySpotubeCredentials.fromJson(Map json) - : clientId = json['clientId'], - accessToken = json['accessToken'], - expiration = DateTime.fromMillisecondsSinceEpoch( - json['accessTokenExpirationTimestampMs'], - ), - isAnonymous = json['isAnonymous']; - - Map toJson() { - return { - 'clientId': clientId, - 'accessToken': accessToken, - 'accessTokenExpirationTimestampMs': expiration.millisecondsSinceEpoch, - 'isAnonymous': isAnonymous, - }; - } -} diff --git a/lib/modules/album/album_card.dart b/lib/modules/album/album_card.dart index dd914fad..84106594 100644 --- a/lib/modules/album/album_card.dart +++ b/lib/modules/album/album_card.dart @@ -1,22 +1,23 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/components/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/playbutton_card.dart'; +import 'package:spotube/components/playbutton_view/playbutton_card.dart'; +import 'package:spotube/components/playbutton_view/playbutton_tile.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/pages/album/album.dart'; import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/utils/service_utils.dart'; extension FormattedAlbumType on AlbumType { String get formatted => name.replaceFirst(name[0], name[0].toUpperCase()); @@ -24,10 +25,16 @@ extension FormattedAlbumType on AlbumType { class AlbumCard extends HookConsumerWidget { final AlbumSimple album; + final bool _isTile; const AlbumCard( this.album, { super.key, - }); + }) : _isTile = false; + + const AlbumCard.tile( + this.album, { + super.key, + }) : _isTile = true; @override Widget build(BuildContext context, ref) { @@ -45,8 +52,6 @@ class AlbumCard extends HookConsumerWidget { final updating = useState(false); - final scaffoldMessenger = ScaffoldMessenger.maybeOf(context); - Future> fetchAllTrack() async { if (album.tracks != null && album.tracks!.isNotEmpty) { return album.tracks!.map((track) => track.asTrack(album)).toList(); @@ -55,88 +60,110 @@ class AlbumCard extends HookConsumerWidget { return ref.read(albumTracksProvider(album).notifier).fetchAll(); } - return PlaybuttonCard( - imageUrl: album.images.asUrlString( - placeholder: ImagePlaceholder.collection, - ), - margin: const EdgeInsets.symmetric(horizontal: 10), - isPlaying: isPlaylistPlaying, - isLoading: - (isPlaylistPlaying && isFetchingActiveTrack) || updating.value, - title: album.name!, - description: - "${album.albumType?.formatted} • ${album.artists?.asString() ?? ""}", - onTap: () { - ServiceUtils.pushNamed( - context, - AlbumPage.name, - pathParameters: { - "id": album.id!, - }, - extra: album, + var imageUrl = album.images.asUrlString( + placeholder: ImagePlaceholder.collection, + ); + var isLoading = + (isPlaylistPlaying && isFetchingActiveTrack) || updating.value; + var description = + "${album.albumType?.formatted} • ${album.artists?.asString() ?? ""}"; + + void onTap() { + context.navigateTo(AlbumRoute(id: album.id!, album: album)); + } + + void onPlaybuttonPressed() async { + updating.value = true; + try { + if (isPlaylistPlaying) { + return playing ? audioPlayer.pause() : audioPlayer.resume(); + } + + final fetchedTracks = await fetchAllTrack(); + + if (fetchedTracks.isEmpty || !context.mounted) return; + + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice == null) return; + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + WebSocketLoadEventData.album( + tracks: fetchedTracks, + collection: album, + ), ); - }, - onPlaybuttonPressed: () async { - updating.value = true; - try { - if (isPlaylistPlaying) { - return playing ? audioPlayer.pause() : audioPlayer.resume(); - } + } else { + await playlistNotifier.load(fetchedTracks, autoPlay: true); + playlistNotifier.addCollection(album.id!); + historyNotifier.addAlbums([album]); + } + } finally { + updating.value = false; + } + } - final fetchedTracks = await fetchAllTrack(); + void onAddToQueuePressed() async { + if (isPlaylistPlaying) { + return; + } - if (fetchedTracks.isEmpty || !context.mounted) return; + updating.value = true; + try { + final fetchedTracks = await fetchAllTrack(); - final isRemoteDevice = await showSelectDeviceDialog(context, ref); - if (isRemoteDevice) { - final remotePlayback = ref.read(connectProvider.notifier); - await remotePlayback.load( - WebSocketLoadEventData.album( - tracks: fetchedTracks, - collection: album, + if (fetchedTracks.isEmpty) return; + playlistNotifier.addTracks(fetchedTracks); + playlistNotifier.addCollection(album.id!); + historyNotifier.addAlbums([album]); + if (context.mounted) { + showToast( + context: context, + builder: (context, overlay) { + return SurfaceCard( + child: Basic( + content: Text( + context.l10n.added_to_queue(fetchedTracks.length), + ), + trailing: Button.outline( + child: Text(context.l10n.undo), + onPressed: () { + playlistNotifier + .removeTracks(fetchedTracks.map((e) => e.id!)); + }, + ), ), ); - } else { - await playlistNotifier.load(fetchedTracks, autoPlay: true); - playlistNotifier.addCollection(album.id!); - historyNotifier.addAlbums([album]); - } - } finally { - updating.value = false; - } - }, - onAddToQueuePressed: () async { - if (isPlaylistPlaying) { - return; - } + }, + ); + } + } finally { + updating.value = false; + } + } - updating.value = true; - try { - final fetchedTracks = await fetchAllTrack(); + if (_isTile) { + return PlaybuttonTile( + imageUrl: imageUrl, + isPlaying: isPlaylistPlaying, + isLoading: isLoading, + title: album.name!, + description: description, + onTap: onTap, + onPlaybuttonPressed: onPlaybuttonPressed, + onAddToQueuePressed: onAddToQueuePressed, + ); + } - if (fetchedTracks.isEmpty) return; - playlistNotifier.addTracks(fetchedTracks); - playlistNotifier.addCollection(album.id!); - historyNotifier.addAlbums([album]); - if (context.mounted) { - final snackbar = SnackBar( - content: Text( - context.l10n.added_to_queue(fetchedTracks.length), - ), - action: SnackBarAction( - label: "Undo", - onPressed: () { - playlistNotifier - .removeTracks(fetchedTracks.map((e) => e.id!)); - }, - ), - ); - - scaffoldMessenger?.showSnackBar(snackbar); - } - } finally { - updating.value = false; - } - }); + return PlaybuttonCard( + imageUrl: imageUrl, + isPlaying: isPlaylistPlaying, + isLoading: isLoading, + title: album.name!, + description: description, + onTap: onTap, + onPlaybuttonPressed: onPlaybuttonPressed, + onAddToQueuePressed: onAddToQueuePressed, + ); } } diff --git a/lib/modules/artist/artist_album_list.dart b/lib/modules/artist/artist_album_list.dart index a2dd8006..7131aa3b 100644 --- a/lib/modules/artist/artist_album_list.dart +++ b/lib/modules/artist/artist_album_list.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart' hide Page; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; @@ -30,7 +30,7 @@ class ArtistAlbumList extends HookConsumerWidget { onFetchMore: albumsQueryNotifier.fetchMore, title: Text( context.l10n.albums, - style: theme.textTheme.headlineSmall, + style: theme.typography.h4, ), ); } diff --git a/lib/modules/artist/artist_card.dart b/lib/modules/artist/artist_card.dart index add2608d..e53070ef 100644 --- a/lib/modules/artist/artist_card.dart +++ b/lib/modules/artist/artist_card.dart @@ -1,17 +1,16 @@ +import 'package:auto_route/auto_route.dart'; 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:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; -import 'package:spotube/hooks/utils/use_brightness_value.dart'; -import 'package:spotube/pages/artist/artist.dart'; + import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/utils/service_utils.dart'; class ArtistCard extends HookConsumerWidget { final Artist artist; @@ -33,98 +32,44 @@ class ArtistCard extends HookConsumerWidget { ), ); - final radius = BorderRadius.circular(15); - - final double size = useBreakpointValue( - xs: 130, - sm: 130, - md: 150, - others: 170, - ); - - return Container( - width: size, - margin: const EdgeInsets.symmetric(vertical: 5), - child: Material( - shadowColor: theme.colorScheme.surface, - color: Color.lerp( - theme.colorScheme.surfaceContainerHighest, - theme.colorScheme.surface, - useBrightnessValue(.9, .7), - ), - elevation: 3, - shape: RoundedRectangleBorder( - borderRadius: radius, - side: isBlackListed == true - ? const BorderSide( - color: Colors.red, - width: 2, - ) - : BorderSide.none, - ), - child: InkWell( - onTap: () { - ServiceUtils.pushNamed( - context, - ArtistPage.name, - pathParameters: { - "id": artist.id!, - }, - ); - }, - borderRadius: radius, - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - children: [ - Stack( - children: [ - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: size, - ), - child: CircleAvatar( - backgroundImage: backgroundImage, - radius: size / 2, - ), - ), - Positioned( - right: 0, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 5, - ), - decoration: BoxDecoration( - color: Colors.blue, - borderRadius: BorderRadius.circular(50)), - child: Skeleton.ignore( - child: Text( - context.l10n.artist, - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - ], - ), - const SizedBox(height: 10), - AutoSizeText( - artist.name!, - maxLines: 1, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + return SizedBox( + width: 180, + child: Button.card( + onPressed: () { + context.navigateTo(ArtistRoute(artistId: artist.id!)); + }, + child: Column( + children: [ + Avatar( + initials: artist.name!.trim()[0].toUpperCase(), + provider: backgroundImage, + size: 130, + ), + const Gap(10), + AutoSizeText( + artist.name!, + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: theme.typography.bold, + ), + const Spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (isBlackListed == true) ...[ + DestructiveBadge( + child: Text(context.l10n.blacklisted.toUpperCase()), ), + const Gap(5), ], - ), - )), + SecondaryBadge( + child: Text(context.l10n.artist.toUpperCase()), + ) + ], + ) + ], + ), ), ); } diff --git a/lib/modules/connect/connect_device.dart b/lib/modules/connect/connect_device.dart index f4888534..2c8d612b 100644 --- a/lib/modules/connect/connect_device.dart +++ b/lib/modules/connect/connect_device.dart @@ -1,11 +1,11 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; +import 'package:auto_route/auto_route.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/connect/connect.dart'; import 'package:spotube/provider/connect/clients.dart'; -import 'package:spotube/utils/service_utils.dart'; class ConnectDeviceButton extends HookConsumerWidget { final bool _sidebar; @@ -14,110 +14,66 @@ class ConnectDeviceButton extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final ThemeData(:colorScheme) = Theme.of(context); - final pixelRatio = MediaQuery.of(context).devicePixelRatio; final connectClients = ref.watch(connectClientsProvider); + final hasServices = + connectClients.asData?.value.services.isNotEmpty == true; + if (_sidebar) { + final mediaQuery = MediaQuery.sizeOf(context); + + if (mediaQuery.mdAndDown) { + return IconButton.ghost( + icon: const Icon(SpotubeIcons.speaker), + onPressed: () { + context.navigateTo(const ConnectRoute()); + }, + ); + } + return SizedBox( width: double.infinity, - child: TextButton( + child: Button.primary( onPressed: () { - ServiceUtils.pushNamed(context, ConnectPage.name); + context.navigateTo(const ConnectRoute()); }, - style: FilledButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.all(5), - ), - child: Row( - children: [ - Text(context.l10n.devices), - if (connectClients.asData?.value.services.isNotEmpty == true) - Text( - " (${connectClients.asData?.value.services.length})", - ), - const Spacer(), - const Icon(SpotubeIcons.speaker), - const Gap(5), - ], + trailing: const Icon(SpotubeIcons.speaker), + child: Text( + "${context.l10n.devices}" + "${hasServices ? " (${connectClients.asData?.value.services.length})" : ""}", ), ), ); } - return SizedBox( - height: 40 * pixelRatio, - child: Stack( - alignment: Alignment.centerRight, - fit: StackFit.loose, - children: [ - Material( - type: MaterialType.transparency, - child: Center( - child: ClipRect( - clipBehavior: Clip.hardEdge, - child: InkWell( - onTap: () { - ServiceUtils.pushNamed(context, ConnectPage.name); - }, - borderRadius: BorderRadius.circular(50), - child: Ink( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(50), - color: colorScheme.primaryContainer, - ), - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (connectClients.asData?.value.resolvedService != - null) ...[ - Container( - width: 7, - height: 7, - decoration: BoxDecoration( - color: Colors.greenAccent, - borderRadius: BorderRadius.circular(50), - ), - ), - const Gap(5), - ], - Text(context.l10n.devices), - if (connectClients.asData?.value.services.isNotEmpty == - true) - Text( - " (${connectClients.asData?.value.services.length})", - style: TextStyle( - color: colorScheme.onPrimaryContainer - .withOpacity(0.5), - ), - ), - const Gap(35), - ], - ), + return Row( + children: [ + SecondaryBadge( + onPressed: () { + context.navigateTo(const ConnectRoute()); + }, + style: const ButtonStyle.secondary(size: ButtonSize(.8)), + leading: connectClients.asData?.value.resolvedService != null + ? const Center( + child: DotItem( + size: 6, + borderRadius: 10, + color: Colors.green, ), - ), - ), - ), + ) + : null, + child: Text( + "${context.l10n.devices}" + "${hasServices ? " (${connectClients.asData?.value.services.length})" : ""}", ), - Positioned( - right: -3, - child: IconButton.filled( - icon: const Icon(SpotubeIcons.speaker), - style: IconButton.styleFrom( - visualDensity: VisualDensity.standard, - foregroundColor: colorScheme.onPrimary, - ), - onPressed: () { - ServiceUtils.pushNamed(context, ConnectPage.name); - }, - ), - ), - ], - ), + ), + IconButton.primary( + icon: const Icon(SpotubeIcons.speaker), + onPressed: () { + context.navigateTo(const ConnectRoute()); + }, + ) + ], ); } } diff --git a/lib/modules/connect/local_devices.dart b/lib/modules/connect/local_devices.dart index dd7db971..dc192e44 100644 --- a/lib/modules/connect/local_devices.dart +++ b/lib/modules/connect/local_devices.dart @@ -1,7 +1,7 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -10,7 +10,7 @@ class ConnectPageLocalDevices extends HookWidget { @override Widget build(BuildContext context) { - final ThemeData(:textTheme) = Theme.of(context); + final ThemeData(:typography) = Theme.of(context); final devicesFuture = useFuture(audioPlayer.devices); final devicesStream = useStream(audioPlayer.devicesStream); final selectedDeviceFuture = useFuture(audioPlayer.selectedDevice); @@ -32,7 +32,7 @@ class ConnectPageLocalDevices extends HookWidget { sliver: SliverToBoxAdapter( child: Text( context.l10n.this_device, - style: textTheme.titleMedium, + style: typography.bold, ), ), ), @@ -43,17 +43,16 @@ class ConnectPageLocalDevices extends HookWidget { itemBuilder: (context, index) { final device = devices[index]; - return Card( - child: ListTile( - leading: const Icon(SpotubeIcons.speaker), - title: Text(device.description), - subtitle: Text(device.name), - selected: selectedDevice == device, - onTap: () => audioPlayer.setAudioDevice(device), - ), + return ButtonTile( + selected: selectedDevice == device, + onPressed: () => audioPlayer.setAudioDevice(device), + leading: const Icon(SpotubeIcons.speaker), + title: Text(device.description), + subtitle: Text(device.name), ); }, ), + const SliverGap(200) ], ); } diff --git a/lib/modules/getting_started/blur_card.dart b/lib/modules/getting_started/blur_card.dart index db887013..6434c0a3 100644 --- a/lib/modules/getting_started/blur_card.dart +++ b/lib/modules/getting_started/blur_card.dart @@ -1,7 +1,5 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; class BlurCard extends HookConsumerWidget { final Widget child; @@ -18,8 +16,7 @@ class BlurCard extends HookConsumerWidget { clipBehavior: Clip.antiAlias, child: SizedBox( width: double.infinity, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: SurfaceCard( child: Padding( padding: const EdgeInsets.all(16.0), child: child, diff --git a/lib/modules/home/sections/featured.dart b/lib/modules/home/sections/featured.dart index 4f30c342..a339bd43 100644 --- a/lib/modules/home/sections/featured.dart +++ b/lib/modules/home/sections/featured.dart @@ -1,5 +1,7 @@ -import 'package:flutter/material.dart' hide Page; +import 'package:flutter_undraw/flutter_undraw.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; @@ -15,6 +17,21 @@ class HomeFeaturedSection extends HookConsumerWidget { final featuredPlaylistsNotifier = ref.watch(featuredPlaylistsProvider.notifier); + if (featuredPlaylists.hasError) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Undraw( + illustration: UndrawIllustration.fixingBugs, + height: 200 * context.theme.scaling, + color: context.theme.colorScheme.primary, + ), + Text(context.l10n.something_went_wrong).small().muted(), + const Gap(8), + ], + ); + } + return Skeletonizer( enabled: featuredPlaylists.isLoading, child: HorizontalPlaybuttonCardView( diff --git a/lib/modules/home/sections/feed.dart b/lib/modules/home/sections/feed.dart index 8685fe19..d3e363cc 100644 --- a/lib/modules/home/sections/feed.dart +++ b/lib/modules/home/sections/feed.dart @@ -1,11 +1,10 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/spotube_icons.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/home/feed/feed_section.dart'; import 'package:spotube/provider/spotify/views/home.dart'; -import 'package:spotube/utils/service_utils.dart'; class HomePageFeedSection extends HookConsumerWidget { const HomePageFeedSection({super.key}); @@ -38,19 +37,11 @@ class HomePageFeedSection extends HookConsumerWidget { hasNextPage: false, isLoadingNextPage: false, onFetchMore: () {}, - titleTrailing: Directionality( - textDirection: TextDirection.rtl, - child: TextButton.icon( - label: Text(context.l10n.browse_more), - icon: const Icon(SpotubeIcons.angleRight), - onPressed: () => ServiceUtils.pushNamed( - context, - HomeFeedSectionPage.name, - pathParameters: { - "feedId": section.uri, - }, - ), - ), + titleTrailing: Button.text( + child: Text(context.l10n.browse_all), + onPressed: () { + context.navigateTo(HomeFeedSectionRoute(sectionUri: section.uri)); + }, ), ); }, diff --git a/lib/modules/home/sections/friends.dart b/lib/modules/home/sections/friends.dart index 6f59c209..5c9c2178 100644 --- a/lib/modules/home/sections/friends.dart +++ b/lib/modules/home/sections/friends.dart @@ -1,14 +1,12 @@ import 'dart:ui'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/modules/home/sections/friends/friend_item.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; -import 'package:spotube/models/spotify_friends.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -22,90 +20,46 @@ class HomePageFriendsSection extends HookConsumerWidget { final friends = friendsQuery.asData?.value.friends ?? FakeData.friends.friends; - final groupCount = useBreakpointValue( - sm: 3, - xs: 2, - md: 4, - lg: 5, - xl: 6, - xxl: 7, - ); - - final friendGroup = useMemoized( - () => friends.fold>>( - [], - (previousValue, element) { - if (previousValue.isEmpty) { - return [ - [element] - ]; - } - - final lastGroup = previousValue.last; - if (lastGroup.length < groupCount) { - return [ - ...previousValue.sublist(0, previousValue.length - 1), - [...lastGroup, element] - ]; - } - - return [ - ...previousValue, - [element] - ]; - }, - ), - [friends, groupCount], - ); - if (friendsQuery.isLoading || friendsQuery.asData?.value.friends.isEmpty == true || auth.asData?.value == null) { - return const SliverToBoxAdapter( - child: SizedBox.shrink(), - ); + return const SizedBox.shrink(); } - return Skeletonizer.sliver( - enabled: friendsQuery.isLoading, - child: SliverMainAxisGroup( - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.l10n.friends, - style: Theme.of(context).textTheme.titleMedium, - ), - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + context.l10n.friends, + style: context.theme.typography.h4, ), - SliverToBoxAdapter( - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: PointerDeviceKind.values.toSet(), - ), - child: SingleChildScrollView( + ), + SizedBox( + height: 80 * context.theme.scaling, + width: double.infinity, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: PointerDeviceKind.values.toSet(), + scrollbars: false, + ), + child: Skeletonizer( + enabled: friendsQuery.isLoading, + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 8), scrollDirection: Axis.horizontal, - 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), - ), - ], - ), - ], - ), + itemCount: friends.length, + separatorBuilder: (context, index) => const Gap(8), + itemBuilder: (context, index) { + return FriendItem(friend: friends[index]); + }, ), ), ), - ], - ), + ), + ], ); } } diff --git a/lib/modules/home/sections/friends/friend_item.dart b/lib/modules/home/sections/friends/friend_item.dart index 773a4a8c..8e91ab66 100644 --- a/lib/modules/home/sections/friends/friend_item.dart +++ b/lib/modules/home/sections/friends/friend_item.dart @@ -1,14 +1,13 @@ +import 'package:auto_route/auto_route.dart'; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; -import 'package:go_router/go_router.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/models/spotify_friends.dart'; -import 'package:spotube/pages/album/album.dart'; -import 'package:spotube/pages/artist/artist.dart'; -import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/spotify_provider.dart'; class FriendItem extends HookConsumerWidget { @@ -20,27 +19,15 @@ class FriendItem extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final ThemeData( - textTheme: textTheme, - colorScheme: colorScheme, - ) = Theme.of(context); - final spotify = ref.watch(spotifyProvider); - return Container( + return Card( padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withOpacity(0.3), - borderRadius: BorderRadius.circular(15), - ), - constraints: const BoxConstraints( - minWidth: 300, - ), - height: 80, child: Row( children: [ - CircleAvatar( - backgroundImage: UniversalImage.imageProvider( + Avatar( + initials: Avatar.getInitials(friend.user.name), + provider: UniversalImage.imageProvider( friend.user.imageUrl, ), ), @@ -50,19 +37,20 @@ class FriendItem extends HookConsumerWidget { children: [ Text( friend.user.name, - style: textTheme.bodyLarge, + style: context.theme.typography.bold, ), RichText( text: TextSpan( - style: textTheme.bodySmall, + style: context.theme.typography.normal.copyWith( + color: context.theme.colorScheme.foreground, + ), children: [ TextSpan( text: friend.track.name, recognizer: TapGestureRecognizer() ..onTap = () { - context.pushNamed(TrackPage.name, pathParameters: { - "id": friend.track.id, - }); + context + .navigateTo(TrackRoute(trackId: friend.track.id)); }, ), const TextSpan(text: " • "), @@ -76,12 +64,8 @@ class FriendItem extends HookConsumerWidget { text: " ${friend.track.artist.name}", recognizer: TapGestureRecognizer() ..onTap = () { - context.pushNamed( - ArtistPage.name, - pathParameters: { - "id": friend.track.artist.id, - }, - extra: friend.track.artist, + context.navigateTo( + ArtistRoute(artistId: friend.track.artist.id), ); }, ), @@ -90,13 +74,13 @@ class FriendItem extends HookConsumerWidget { text: friend.track.context.name, recognizer: TapGestureRecognizer() ..onTap = () async { - context.push( + context.router.navigateNamed( "/${friend.track.context.path}", - extra: - !friend.track.context.path.startsWith("album") - ? null - : await spotify.albums - .get(friend.track.context.id), + // extra: + // !friend.track.context.path.startsWith("album") + // ? null + // : await spotify.albums + // .get(friend.track.context.id), ); }, ), @@ -114,12 +98,8 @@ class FriendItem extends HookConsumerWidget { final album = await spotify.albums.get(friend.track.album.id); if (context.mounted) { - context.pushNamed( - AlbumPage.name, - pathParameters: { - "id": friend.track.album.id, - }, - extra: album, + context.navigateTo( + AlbumRoute(id: album.id!, album: album), ); } }, diff --git a/lib/modules/home/sections/genres.dart b/lib/modules/home/sections/genres.dart deleted file mode 100644 index 5f2dfa5e..00000000 --- a/lib/modules/home/sections/genres.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/collections/gradients.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/home/genres/genre_playlists.dart'; -import 'package:spotube/pages/home/genres/genres.dart'; -import 'package:spotube/provider/spotify/spotify.dart'; - -class HomeGenresSection extends HookConsumerWidget { - const HomeGenresSection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final ThemeData(:textTheme, :colorScheme) = Theme.of(context); - final mediaQuery = MediaQuery.of(context); - - final categoriesQuery = ref.watch(categoriesProvider); - final categories = useMemoized( - () => - categoriesQuery.asData?.value - .where((c) => (c.icons?.length ?? 0) > 0) - .take(mediaQuery.mdAndDown ? 6 : 10) - .toList() ?? - [], - [mediaQuery.mdAndDown, categoriesQuery.asData?.value], - ); - - 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.pushNamed(GenrePage.name); - }, - icon: const Icon(SpotubeIcons.angleRight), - label: Text( - context.l10n.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.pushNamed( - GenrePlaylistsPage.name, - pathParameters: { - "categoryId": 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.surfaceContainerHighest, - 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/modules/home/sections/genres/genre_card.dart b/lib/modules/home/sections/genres/genre_card.dart new file mode 100644 index 00000000..8133f0db --- /dev/null +++ b/lib/modules/home/sections/genres/genre_card.dart @@ -0,0 +1,116 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:auto_route/auto_route.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotify/spotify.dart' hide Offset; +import 'package:spotube/collections/fake.dart'; +import 'package:spotube/collections/gradients.dart'; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/modules/home/sections/genres/genre_card_playlist_card.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +final random = Random(); +final gradientState = StateProvider.family( + (ref, String id) => gradients[random.nextInt(gradients.length)], +); + +class GenreSectionCard extends HookConsumerWidget { + final Category category; + const GenreSectionCard({ + super.key, + required this.category, + }); + + @override + Widget build(BuildContext context, ref) { + final theme = Theme.of(context); + final playlists = category == FakeData.category + ? null + : ref.watch(categoryPlaylistsProvider(category.id!)); + final playlistsData = playlists?.asData?.value.items.take(8) ?? + List.generate(5, (index) => FakeData.playlistSimple); + + final randomGradient = ref.watch(gradientState(category.id!)); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + borderRadius: theme.borderRadiusXxl, + boxShadow: [ + BoxShadow( + color: theme.colorScheme.foreground, + offset: const Offset(0, 5), + blurRadius: 7, + spreadRadius: -5, + ), + ], + image: DecorationImage( + image: UniversalImage.imageProvider( + category.icons!.first.url!, + ), + fit: BoxFit.cover, + ), + ), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: theme.borderRadiusXxl, + gradient: randomGradient + .withOpacity(theme.brightness == Brightness.dark ? 0.2 : 0.7), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + category.name!, + style: const TextStyle(color: Colors.white), + ).h3(), + Button.link( + onPressed: () { + context.navigateTo( + GenrePlaylistsRoute( + id: category.id!, + category: category, + ), + ); + }, + child: Text( + context.l10n.view_all, + style: const TextStyle(color: Colors.white), + ).muted(), + ), + ], + ), + if (playlists?.hasError != true) + Expanded( + child: Skeleton.ignore( + child: Skeletonizer( + enabled: playlists?.isLoading ?? false, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: playlistsData.length, + separatorBuilder: (context, index) => const Gap(12), + itemBuilder: (context, index) { + final playlist = playlistsData.elementAt(index); + + return GenreSectionCardPlaylistCard(playlist: playlist); + }, + ), + ), + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/modules/home/sections/genres/genre_card_playlist_card.dart b/lib/modules/home/sections/genres/genre_card_playlist_card.dart new file mode 100644 index 00000000..1e1b3b76 --- /dev/null +++ b/lib/modules/home/sections/genres/genre_card_playlist_card.dart @@ -0,0 +1,130 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotify/spotify.dart' hide Image; +import 'package:spotube/collections/env.dart'; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/extensions/string.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:stroke_text/stroke_text.dart'; + +class GenreSectionCardPlaylistCard extends HookConsumerWidget { + final PlaylistSimple playlist; + const GenreSectionCardPlaylistCard({ + super.key, + required this.playlist, + }); + + @override + Widget build(BuildContext context, ref) { + final theme = Theme.of(context); + + return Container( + width: 115 * theme.scaling, + decoration: BoxDecoration( + color: theme.colorScheme.background.withAlpha(75), + borderRadius: theme.borderRadiusMd, + ), + child: SurfaceBlur( + borderRadius: theme.borderRadiusMd, + surfaceBlur: theme.surfaceBlur, + child: Button( + style: ButtonVariance.secondary.copyWith( + padding: (context, states, value) => const EdgeInsets.all(8), + decoration: (context, states, value) { + final decoration = ButtonVariance.secondary + .decoration(context, states) as BoxDecoration; + + if (states.isNotEmpty) { + return decoration; + } + + return decoration.copyWith( + color: decoration.color?.withAlpha(180), + ); + }, + ), + onPressed: () { + context.navigateTo( + PlaylistRoute(id: playlist.id!, playlist: playlist), + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 5, + children: [ + ClipRRect( + borderRadius: theme.borderRadiusSm, + child: playlist.owner?.displayName == "Spotify" && + Env.disableSpotifyImages + ? Consumer( + builder: (context, ref, _) { + final (:src, :color, :colorBlendMode, :placement) = + ref.watch(playlistImageProvider(playlist.id!)); + return SizedBox( + height: 100 * theme.scaling, + width: 100 * theme.scaling, + child: Stack( + children: [ + Positioned.fill( + child: Image.asset( + src, + color: color, + colorBlendMode: colorBlendMode, + fit: BoxFit.cover, + ), + ), + Positioned.fill( + top: placement == Alignment.topLeft + ? 10 + : null, + left: 10, + bottom: placement == Alignment.bottomLeft + ? 10 + : null, + child: StrokeText( + text: playlist.name!, + strokeColor: Colors.white, + strokeWidth: 3, + textColor: Colors.black, + textStyle: const TextStyle( + fontSize: 16, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ); + }, + ) + : UniversalImage( + path: (playlist.images)!.asUrlString( + placeholder: ImagePlaceholder.collection, + index: 1, + ), + fit: BoxFit.cover, + height: 100 * theme.scaling, + width: 100 * theme.scaling, + ), + ), + Text( + playlist.name!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ).semiBold().small(), + if (playlist.description != null) + Text( + playlist.description?.unescapeHtml().cleanHtml() ?? "", + maxLines: 2, + overflow: TextOverflow.ellipsis, + ).xSmall().muted(), + ], + ), + ), + ), + ); + } +} diff --git a/lib/modules/home/sections/genres/genres.dart b/lib/modules/home/sections/genres/genres.dart new file mode 100644 index 00000000..c9f3f9b2 --- /dev/null +++ b/lib/modules/home/sections/genres/genres.dart @@ -0,0 +1,139 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotube/collections/fake.dart'; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/modules/home/sections/genres/genre_card.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +class HomeGenresSection extends HookConsumerWidget { + const HomeGenresSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final theme = context.theme; + final mediaQuery = MediaQuery.sizeOf(context); + + final categoriesQuery = ref.watch(categoriesProvider); + final categories = useMemoized( + () => + categoriesQuery.asData?.value + .where((c) => (c.icons?.length ?? 0) > 0) + .take(6) + .toList() ?? + [ + FakeData.category, + ], + [categoriesQuery.asData?.value], + ); + final controller = useMemoized(() => CarouselController(), []); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.l10n.genres, + style: context.theme.typography.h4, + ), + Button.link( + onPressed: () { + context.navigateTo(const GenreRoute()); + }, + child: Text( + context.l10n.browse_all, + ).muted(), + ), + ], + ), + ), + const Gap(8), + Stack( + children: [ + SizedBox( + height: 280 * theme.scaling, + child: Carousel( + controller: controller, + transition: const CarouselTransition.sliding(gap: 24), + sizeConstraint: CarouselSizeConstraint.fixed( + mediaQuery.mdAndUp + ? mediaQuery.width * .6 + : mediaQuery.width * .95, + ), + itemCount: categories.length, + pauseOnHover: true, + direction: Axis.horizontal, + itemBuilder: (context, index) { + final category = categories[index]; + + return Skeletonizer( + enabled: categoriesQuery.isLoading, + child: GenreSectionCard(category: category), + ); + }, + ), + ), + Positioned( + left: 0, + child: Container( + height: 280 * theme.scaling, + width: (mediaQuery.mdAndUp ? 60 : 40) * theme.scaling, + alignment: Alignment.center, + child: IconButton.secondary( + shape: ButtonShape.circle, + size: mediaQuery.mdAndUp + ? const ButtonSize(1.3) + : ButtonSize.normal, + icon: const Icon(SpotubeIcons.angleLeft), + onPressed: () { + controller.animatePrevious( + const Duration(seconds: 1), + ); + }, + ), + ), + ), + Positioned( + right: 0, + child: Container( + height: 280 * theme.scaling, + width: (mediaQuery.mdAndUp ? 60 : 40) * theme.scaling, + alignment: Alignment.center, + child: IconButton.secondary( + shape: ButtonShape.circle, + size: mediaQuery.mdAndUp + ? const ButtonSize(1.3) + : ButtonSize.normal, + icon: const Icon(SpotubeIcons.angleRight), + onPressed: () { + controller.animateNext( + const Duration(seconds: 1), + ); + }, + ), + ), + ), + ], + ), + const Gap(8), + Center( + child: CarouselDotIndicator( + itemCount: categories.length, + controller: controller, + ), + ), + ], + ); + } +} diff --git a/lib/modules/home/sections/made_for_user.dart b/lib/modules/home/sections/made_for_user.dart index 1b9854d3..4fd025d5 100644 --- a/lib/modules/home/sections/made_for_user.dart +++ b/lib/modules/home/sections/made_for_user.dart @@ -1,5 +1,5 @@ -import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/provider/spotify/spotify.dart'; diff --git a/lib/modules/home/sections/new_releases.dart b/lib/modules/home/sections/new_releases.dart index e2b32741..2ebbbee0 100644 --- a/lib/modules/home/sections/new_releases.dart +++ b/lib/modules/home/sections/new_releases.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart' hide Page; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/modules/home/sections/recent.dart b/lib/modules/home/sections/recent.dart index 43c0459d..5420ad55 100644 --- a/lib/modules/home/sections/recent.dart +++ b/lib/modules/home/sections/recent.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; diff --git a/lib/modules/library/local_folder/cache_export_dialog.dart b/lib/modules/library/local_folder/cache_export_dialog.dart index 1d1421be..0f10defc 100644 --- a/lib/modules/library/local_folder/cache_export_dialog.dart +++ b/lib/modules/library/local_folder/cache_export_dialog.dart @@ -1,10 +1,9 @@ import 'dart:io'; -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:path/path.dart'; +import 'package:path/path.dart' as path; import 'package:spotube/extensions/context.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/sourced_track/enums.dart'; @@ -22,7 +21,7 @@ class LocalFolderCacheExportDialog extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme, :colorScheme) = Theme.of(context); + final ThemeData(:typography, :colorScheme) = Theme.of(context); final files = useState>([]); final filesExported = useState(0); @@ -31,7 +30,7 @@ class LocalFolderCacheExportDialog extends HookConsumerWidget { final stream = cacheDir.list().where( (event) => event is File && - codecs.contains(extension(event.path).replaceAll(".", "")), + codecs.contains(path.extension(event.path).replaceAll(".", "")), ); stream.listen( @@ -76,8 +75,8 @@ class LocalFolderCacheExportDialog extends HookConsumerWidget { ), TextSpan( text: "\n${exportDir.path}?", - style: textTheme.labelMedium!.copyWith( - color: colorScheme.secondary, + style: typography.small.copyWith( + color: colorScheme.mutedForeground, ), ), ], @@ -102,7 +101,7 @@ class LocalFolderCacheExportDialog extends HookConsumerWidget { ), ), actions: [ - TextButton( + Button.outline( onPressed: isExportInProgress ? null : () { @@ -110,14 +109,14 @@ class LocalFolderCacheExportDialog extends HookConsumerWidget { }, child: Text(context.l10n.cancel), ), - TextButton( + Button.primary( onPressed: isExportInProgress ? null : () async { for (final file in files.value) { try { final destinationFile = File( - join(exportDir.path, basename(file.path)), + path.join(exportDir.path, path.basename(file.path)), ); if (await destinationFile.exists()) { diff --git a/lib/modules/library/local_folder/local_folder_item.dart b/lib/modules/library/local_folder/local_folder_item.dart index a965a42d..78f1aa14 100644 --- a/lib/modules/library/local_folder/local_folder_item.dart +++ b/lib/modules/library/local_folder/local_folder_item.dart @@ -1,19 +1,18 @@ import 'dart:math'; -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.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:path/path.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/string.dart'; -import 'package:spotube/hooks/utils/use_brightness_value.dart'; -import 'package:spotube/pages/library/local_folder.dart'; import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; @@ -26,8 +25,6 @@ class LocalFolderItem extends HookConsumerWidget { final ThemeData(:colorScheme) = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final lerpValue = useBrightnessValue(.9, .7); - final downloadFolder = ref.watch(userPreferencesProvider.select((s) => s.downloadLocation)); final cacheFolder = useFuture(UserPreferencesNotifier.getMusicCacheDir()); @@ -60,69 +57,62 @@ class LocalFolderItem extends HookConsumerWidget { final tracks = trackSnapshot.value ?? []; - return InkWell( - onTap: () { - context.goNamed( - LocalLibraryPage.name, - queryParameters: { - if (isDownloadFolder) "downloads": "true", - if (isCacheFolder) "cache": "true", - }, - extra: folder, + return Button( + onPressed: () { + context.navigateTo( + LocalLibraryRoute( + location: folder, + isCache: isCacheFolder, + isDownloads: isDownloadFolder, + ), ); }, - borderRadius: BorderRadius.circular(8), - child: Ink( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Color.lerp( - colorScheme.surfaceContainerHighest, - colorScheme.surface, - lerpValue, - ), - ), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (tracks.isEmpty) - Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Icon( - SpotubeIcons.folder, - size: mediaQuery.smAndDown - ? 95 - : mediaQuery.mdAndDown - ? 100 - : 142, - ), - ), - ) - else - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: max((tracks.length / 2).ceil(), 2), - ), - itemCount: tracks.length, - itemBuilder: (context, index) { - final track = tracks[index]; - return UniversalImage( - path: (track.album?.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - fit: BoxFit.cover, - ); - }, - ), + style: ButtonVariance.card.copyWith( + padding: (context, states, value) { + return const EdgeInsets.all(8); + }, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (tracks.isEmpty) + Padding( + padding: const EdgeInsets.all(8.0), + child: Icon( + SpotubeIcons.folder, + size: mediaQuery.smAndDown + ? 95 + : mediaQuery.mdAndDown + ? 100 + : 142, + ), + ) + else + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: max((tracks.length / 2).ceil(), 2), ), - const Gap(8), - Stack( + itemCount: tracks.length, + itemBuilder: (context, index) { + final track = tracks[index]; + return UniversalImage( + path: (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + fit: BoxFit.cover, + ); + }, + ), + ), + const Gap(8), + Stack( + children: [ + Column( + mainAxisSize: MainAxisSize.min, children: [ Center( child: Text( @@ -133,25 +123,47 @@ class LocalFolderItem extends HookConsumerWidget { : basename(folder), style: const TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), - if (!isDownloadFolder) - Align( - alignment: Alignment.topRight, - child: PopupMenuButton( - child: const Padding( - padding: EdgeInsets.all(3), - child: Icon(Icons.more_vert), - ), - itemBuilder: (context) { - return [ - PopupMenuItem( - child: ListTile( - leading: const Icon(SpotubeIcons.folderRemove), - iconColor: colorScheme.error, - title: + Wrap( + spacing: 2, + runSpacing: 2, + children: [ + for (final MapEntry(key: index, value: segment) + in segments.asMap().entries) + Text.rich( + TextSpan( + children: [ + if (index != 0) const TextSpan(text: "/ "), + TextSpan(text: segment), + ], + ), + maxLines: 2, + ).xSmall().muted(), + ], + ), + ], + ), + if (!isDownloadFolder && !isCacheFolder) + Align( + alignment: Alignment.topRight, + child: IconButton.ghost( + icon: const Icon(Icons.more_vert), + size: ButtonSize.small, + onPressed: () { + showDropdown( + context: context, + builder: (context) { + return DropdownMenu( + children: [ + MenuButton( + leading: Icon(SpotubeIcons.folderRemove, + color: colorScheme.destructive), + child: Text(context.l10n.remove_library_location), - onTap: () { + onPressed: (context) { final libraryLocations = ref .read(userPreferencesProvider) .localLibraryLocation; @@ -163,43 +175,18 @@ class LocalFolderItem extends HookConsumerWidget { .toList(), ); }, - ), - ) - ]; + ) + ], + ); }, - ), - ), - ], - ), - const Spacer(), - Wrap( - spacing: 2, - runSpacing: 2, - children: [ - for (final MapEntry(key: index, value: segment) - in segments.asMap().entries) - Text.rich( - TextSpan( - children: [ - if (index != 0) - TextSpan( - text: "/ ", - style: TextStyle(color: colorScheme.primary), - ), - TextSpan(text: segment), - ], - ), - style: TextStyle( - fontSize: 10, - color: colorScheme.tertiary, - ), - ), - ], - ), - const Spacer(), + ); + }, + ), + ), ], ), - ), + const Spacer(), + ], ), ); } diff --git a/lib/modules/library/playlist_generate/recommendation_attribute_dials.dart b/lib/modules/library/playlist_generate/recommendation_attribute_dials.dart index d7f51ffb..564bfb55 100644 --- a/lib/modules/library/playlist_generate/recommendation_attribute_dials.dart +++ b/lib/modules/library/playlist_generate/recommendation_attribute_dials.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; @@ -29,23 +29,21 @@ class RecommendationAttributeDials extends HookWidget { @override Widget build(BuildContext context) { - final animation = useAnimationController( - duration: const Duration(milliseconds: 300), - ); - final labelStyle = Theme.of(context).textTheme.labelSmall?.copyWith( + final labelStyle = Theme.of(context).typography.small.copyWith( fontWeight: FontWeight.w500, ); final minSlider = Row( + spacing: 5, children: [ Text(context.l10n.min, style: labelStyle), Expanded( child: Slider( - value: values.min / base, + value: SliderValue.single(values.min / base), min: 0, max: 1, onChanged: (value) => onChanged(( - min: value * base, + min: value.value * base, target: values.target, max: values.max, )), @@ -55,16 +53,17 @@ class RecommendationAttributeDials extends HookWidget { ); final targetSlider = Row( + spacing: 5, children: [ Text(context.l10n.target, style: labelStyle), Expanded( child: Slider( - value: values.target / base, + value: SliderValue.single(values.target / base), min: 0, max: 1, onChanged: (value) => onChanged(( min: values.min, - target: value * base, + target: value.value * base, max: values.max, )), ), @@ -73,109 +72,111 @@ class RecommendationAttributeDials extends HookWidget { ); final maxSlider = Row( + spacing: 5, children: [ Text(context.l10n.max, style: labelStyle), Expanded( child: Slider( - value: values.max / base, + value: SliderValue.single(values.max / base), min: 0, max: 1, onChanged: (value) => onChanged(( min: values.min, target: values.target, - max: value * base, + max: value.value * base, )), ), ), ], ); - return LayoutBuilder(builder: (context, constrain) { - return Card( - child: ExpansionTile( - title: DefaultTextStyle( - style: Theme.of(context).textTheme.titleSmall!, - child: title, - ), - shape: const Border(), - leading: AnimatedBuilder( - animation: animation, - builder: (context, child) { - return Transform.rotate( - angle: (animation.value * 3.14) / 2, - child: child, - ); - }, - child: const Icon(Icons.chevron_right), - ), - trailing: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: ToggleButtons( - borderRadius: BorderRadius.circular(8), - textStyle: labelStyle, - isSelected: [ - values == lowValues(base), - values == moderateValues(base), - values == highValues(base), - ], - onPressed: (index) { - RecommendationAttribute newValues = zeroValues; - switch (index) { - case 0: - newValues = lowValues(base); - break; - case 1: - newValues = moderateValues(base); - break; - case 2: - newValues = highValues(base); - break; - } + void onSelected(int index) { + RecommendationAttribute newValues = zeroValues; + switch (index) { + case 0: + newValues = lowValues(base); + break; + case 1: + newValues = moderateValues(base); + break; + case 2: + newValues = highValues(base); + break; + } - if (newValues == values) { - onChanged(zeroValues); - } else { - onChanged(newValues); - } - }, + if (newValues == values) { + onChanged(zeroValues); + } else { + onChanged(newValues); + } + } + + return LayoutBuilder(builder: (context, constrain) { + return Accordion( + items: [ + AccordionItem( + trigger: AccordionTrigger( + child: SizedBox( + width: double.infinity, + child: Basic( + title: title.semiBold(), + trailing: Row( + spacing: 5, + children: [ + Toggle( + value: values == lowValues(base), + onChanged: (value) => onSelected(0), + style: + const ButtonStyle.outline(size: ButtonSize.small), + child: Text(context.l10n.low), + ), + Toggle( + value: values == moderateValues(base), + onChanged: (value) => onSelected(1), + style: + const ButtonStyle.outline(size: ButtonSize.small), + child: Text(context.l10n.moderate), + ), + Toggle( + value: values == highValues(base), + onChanged: (value) => onSelected(2), + style: + const ButtonStyle.outline(size: ButtonSize.small), + child: Text(context.l10n.high), + ), + ], + ), + ), + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, children: [ - Text(context.l10n.low), - Text(" ${context.l10n.moderate} "), - Text(context.l10n.high), + if (constrain.mdAndUp) + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const SizedBox(width: 16), + Expanded(child: minSlider), + Expanded(child: targetSlider), + Expanded(child: maxSlider), + ], + ) + else + Padding( + padding: const EdgeInsets.only(left: 16), + child: Column( + children: [ + minSlider, + targetSlider, + maxSlider, + ], + ), + ), ], ), ), - onExpansionChanged: (value) { - if (value) { - animation.forward(); - } else { - animation.reverse(); - } - }, - children: [ - if (constrain.mdAndUp) - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - const SizedBox(width: 16), - Expanded(child: minSlider), - Expanded(child: targetSlider), - Expanded(child: maxSlider), - ], - ) - else - Padding( - padding: const EdgeInsets.only(left: 16), - child: Column( - children: [ - minSlider, - targetSlider, - maxSlider, - ], - ), - ), - ], - ), + ], ); }); } diff --git a/lib/modules/library/playlist_generate/recommendation_attribute_fields.dart b/lib/modules/library/playlist_generate/recommendation_attribute_fields.dart index 7feff03a..b616b080 100644 --- a/lib/modules/library/playlist_generate/recommendation_attribute_fields.dart +++ b/lib/modules/library/playlist_generate/recommendation_attribute_fields.dart @@ -1,5 +1,6 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; import 'package:spotube/modules/library/playlist_generate/recommendation_attribute_dials.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; @@ -21,17 +22,12 @@ class RecommendationAttributeFields extends HookWidget { @override Widget build(BuildContext context) { - final animation = useAnimationController( - duration: const Duration(milliseconds: 300), - ); - final labelStyle = Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.w500, - ); - - final minController = useTextEditingController(text: values.min.toString()); + final minController = + useShadcnTextEditingController(text: values.min.toString()); final targetController = - useTextEditingController(text: values.target.toString()); - final maxController = useTextEditingController(text: values.max.toString()); + useShadcnTextEditingController(text: values.target.toString()); + final maxController = + useShadcnTextEditingController(text: values.max.toString()); useEffect(() { listener() { @@ -53,126 +49,133 @@ class RecommendationAttributeFields extends HookWidget { }; }, [values]); - final minField = TextField( - controller: minController, - decoration: InputDecoration( - labelText: context.l10n.min, - isDense: true, - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: false, - signed: true, - ), + final minField = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 5, + children: [ + Text(context.l10n.min).semiBold(), + NumberInput( + controller: minController, + allowDecimals: false, + ), + ], ); - final targetField = TextField( - controller: targetController, - decoration: InputDecoration( - labelText: context.l10n.target, - isDense: true, - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: false, - signed: true, - ), + final targetField = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 5, + children: [ + Text(context.l10n.target).semiBold(), + NumberInput( + controller: targetController, + allowDecimals: false, + ), + ], ); - final maxField = TextField( - controller: maxController, - decoration: InputDecoration( - labelText: context.l10n.max, - isDense: true, - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: false, - signed: true, - ), + final maxField = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 5, + children: [ + Text(context.l10n.max).semiBold(), + NumberInput( + controller: maxController, + allowDecimals: false, + ), + ], ); - return LayoutBuilder(builder: (context, constrain) { - return Card( - child: ExpansionTile( - title: DefaultTextStyle( - style: Theme.of(context).textTheme.titleSmall!, - child: title, - ), - shape: const Border(), - leading: AnimatedBuilder( - animation: animation, - builder: (context, child) { - return Transform.rotate( - angle: (animation.value * 3.14) / 2, - child: child, - ); - }, - child: const Icon(Icons.chevron_right), - ), - trailing: presets == null - ? const SizedBox.shrink() - : Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: ToggleButtons( - borderRadius: BorderRadius.circular(8), - textStyle: labelStyle, - isSelected: presets!.values - .map((value) => value == values) - .toList(), - onPressed: (index) { - RecommendationAttribute newValues = - presets!.values.elementAt(index); - if (newValues == values) { - onChanged(zeroValues); - minController.text = zeroValues.min.toString(); - targetController.text = zeroValues.target.toString(); - maxController.text = zeroValues.max.toString(); - } else { - onChanged(newValues); - minController.text = newValues.min.toString(); - targetController.text = newValues.target.toString(); - maxController.text = newValues.max.toString(); - } - }, - children: presets!.keys.map((key) => Text(key)).toList(), - ), - ), - onExpansionChanged: (value) { - if (value) { - animation.forward(); - } else { - animation.reverse(); - } - }, - children: [ - const SizedBox(height: 8), - if (constrain.mdAndUp) - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - const SizedBox(width: 16), - Expanded(child: minField), - const SizedBox(width: 16), - Expanded(child: targetField), - const SizedBox(width: 16), - Expanded(child: maxField), - const SizedBox(width: 16), - ], - ) - else - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - children: [ - minField, - const SizedBox(height: 16), - targetField, - const SizedBox(height: 16), - maxField, - ], + void onSelected(int index) { + RecommendationAttribute newValues = presets!.values.elementAt(index); + if (newValues == values) { + onChanged(zeroValues); + minController.text = zeroValues.min.toString(); + targetController.text = zeroValues.target.toString(); + maxController.text = zeroValues.max.toString(); + } else { + onChanged(newValues); + minController.text = newValues.min.toString(); + targetController.text = newValues.target.toString(); + maxController.text = newValues.max.toString(); + } + } + + return LayoutBuilder(builder: (context, constraints) { + return Accordion( + items: [ + AccordionItem( + trigger: AccordionTrigger( + child: SizedBox( + width: double.infinity, + child: Basic( + title: title.semiBold(), + trailing: presets == null + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + spacing: 5, + children: [ + for (final presetEntry in presets?.entries + .toList() ?? + >[]) + Toggle( + value: presetEntry.value == values, + style: const ButtonStyle.outline( + size: ButtonSize.small, + ), + onChanged: (value) { + onSelected( + presets!.entries.toList().indexWhere( + (s) => s.key == presetEntry.key), + ); + }, + child: Text(presetEntry.key), + ), + ], + ), + ), ), ), - const SizedBox(height: 8), - ], - ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + if (constraints.mdAndUp) + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const SizedBox(width: 16), + Expanded(child: minField), + const SizedBox(width: 16), + Expanded(child: targetField), + const SizedBox(width: 16), + Expanded(child: maxField), + const SizedBox(width: 16), + ], + ) + else + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + minField, + const SizedBox(height: 16), + targetField, + const SizedBox(height: 16), + maxField, + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ], ); }); } diff --git a/lib/modules/library/playlist_generate/seeds_multi_autocomplete.dart b/lib/modules/library/playlist_generate/seeds_multi_autocomplete.dart index 73c58deb..da1288f5 100644 --- a/lib/modules/library/playlist_generate/seeds_multi_autocomplete.dart +++ b/lib/modules/library/playlist_generate/seeds_multi_autocomplete.dart @@ -1,9 +1,11 @@ import 'dart:async'; import 'dart:math'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show Autocomplete; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; enum SelectedItemDisplayType { wrap, @@ -20,10 +22,13 @@ class SeedsMultiAutocomplete extends HookWidget { final Widget Function(T option) selectedSeedBuilder; final String Function(T option) displayStringForOption; - final InputDecoration? inputDecoration; final bool enabled; final SelectedItemDisplayType selectedItemDisplayType; + final Widget? placeholder; + final Widget? leading; + final Widget? trailing; + final Widget? label; const SeedsMultiAutocomplete({ super.key, @@ -32,9 +37,12 @@ class SeedsMultiAutocomplete extends HookWidget { required this.autocompleteOptionBuilder, required this.displayStringForOption, required this.selectedSeedBuilder, - this.inputDecoration, this.enabled = true, this.selectedItemDisplayType = SelectedItemDisplayType.wrap, + this.placeholder, + this.leading, + this.trailing, + this.label, }); @override @@ -42,7 +50,7 @@ class SeedsMultiAutocomplete extends HookWidget { useValueListenable(seeds); final theme = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final seedController = useTextEditingController(); + final seedController = useShadcnTextEditingController(); final containerKey = useRef(GlobalKey()); @@ -61,6 +69,10 @@ class SeedsMultiAutocomplete extends HookWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (label != null) ...[ + label!.semiBold(), + const Gap(8), + ], LayoutBuilder(builder: (context, constrains) { return Container( key: containerKey.value, @@ -101,13 +113,15 @@ class SeedsMultiAutocomplete extends HookWidget { focusNode, onFieldSubmitted, ) { - return TextFormField( + return TextField( controller: seedController, onChanged: (value) => textEditingController.text = value, focusNode: focusNode, - onFieldSubmitted: (_) => onFieldSubmitted(), + onSubmitted: (_) => onFieldSubmitted(), enabled: enabled, - decoration: inputDecoration, + leading: leading, + trailing: trailing, + placeholder: placeholder, ); }, ), @@ -120,22 +134,27 @@ class SeedsMultiAutocomplete extends HookWidget { runSpacing: 4, children: seeds.value.map(selectedSeedBuilder).toList(), ), - SelectedItemDisplayType.list => Card( - margin: EdgeInsets.zero, - child: Column( - children: [ - for (final seed in seeds.value) ...[ - selectedSeedBuilder(seed), - if (seeds.value.length > 1 && seed != seeds.value.last) - Divider( - color: theme.colorScheme.primaryContainer, - height: 1, - indent: 12, - endIndent: 12, + SelectedItemDisplayType.list => AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: seeds.value.isEmpty + ? const SizedBox.shrink() + : Card( + child: Column( + children: [ + for (final seed in seeds.value) ...[ + selectedSeedBuilder(seed), + if (seeds.value.length > 1 && + seed != seeds.value.last) + Divider( + color: theme.colorScheme.secondary, + height: 1, + indent: 12, + endIndent: 12, + ), + ], + ], ), - ], - ], - ), + ), ), }, ], diff --git a/lib/modules/library/playlist_generate/simple_track_tile.dart b/lib/modules/library/playlist_generate/simple_track_tile.dart index e6cc281f..afa723f3 100644 --- a/lib/modules/library/playlist_generate/simple_track_tile.dart +++ b/lib/modules/library/playlist_generate/simple_track_tile.dart @@ -1,9 +1,10 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/extensions/image.dart'; class SimpleTrackTile extends HookWidget { @@ -17,7 +18,7 @@ class SimpleTrackTile extends HookWidget { @override Widget build(BuildContext context) { - return ListTile( + return ButtonTile( leading: ClipRRect( borderRadius: BorderRadius.circular(8), child: UniversalImage( @@ -28,18 +29,17 @@ class SimpleTrackTile extends HookWidget { width: 40, ), ), - horizontalTitleGap: 10, - contentPadding: const EdgeInsets.symmetric(horizontal: 8), title: Text(track.name!), trailing: onDelete == null ? null - : IconButton( + : IconButton.ghost( icon: const Icon(SpotubeIcons.close), onPressed: onDelete, ), subtitle: Text( track.artists?.map((e) => e.name).join(", ") ?? track.album?.name ?? "", ), + style: ButtonVariance.ghost, ); } } diff --git a/lib/modules/library/user_albums.dart b/lib/modules/library/user_albums.dart deleted file mode 100644 index 37fca7c0..00000000 --- a/lib/modules/library/user_albums.dart +++ /dev/null @@ -1,118 +0,0 @@ -import 'package:flutter/material.dart' hide Image; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:collection/collection.dart'; -import 'package:fuzzywuzzy/fuzzywuzzy.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/fake.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/modules/album/album_card.dart'; -import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/components/waypoint.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication/authentication.dart'; -import 'package:spotube/provider/spotify/spotify.dart'; - -class UserAlbums extends HookConsumerWidget { - const UserAlbums({super.key}); - - @override - Widget build(BuildContext context, ref) { - final auth = ref.watch(authenticationProvider); - final albumsQuery = ref.watch(favoriteAlbumsProvider); - final albumsQueryNotifier = ref.watch(favoriteAlbumsProvider.notifier); - - final controller = useScrollController(); - - final searchText = useState(''); - - final albums = useMemoized(() { - if (searchText.value.isEmpty) { - return albumsQuery.asData?.value.items ?? []; - } - return albumsQuery.asData?.value.items - .map((e) => ( - weightedRatio(e.name!, searchText.value), - e, - )) - .sorted((a, b) => b.$1.compareTo(a.$1)) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList() ?? - []; - }, [albumsQuery.asData?.value, searchText.value]); - - if (auth.asData?.value == null) { - return const AnonymousFallback(); - } - - return SafeArea( - child: Scaffold( - body: RefreshIndicator( - onRefresh: () async { - ref.invalidate(favoriteAlbumsProvider); - }, - child: InterScrollbar( - controller: controller, - child: CustomScrollView( - controller: controller, - slivers: [ - SliverAppBar( - floating: true, - flexibleSpace: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: SearchBar( - onChanged: (value) => searchText.value = value, - leading: const Icon(SpotubeIcons.filter), - hintText: context.l10n.filter_albums, - ), - ), - ), - const SliverGap(10), - SliverLayoutBuilder(builder: (context, constrains) { - return SliverGrid.builder( - itemCount: albums.isEmpty ? 6 : albums.length + 1, - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - mainAxisExtent: constrains.smAndDown ? 225 : 250, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - itemBuilder: (context, index) { - if (albums.isNotEmpty && index == albums.length) { - if (albumsQuery.asData?.value.hasMore != true) { - return const SizedBox.shrink(); - } - - return Waypoint( - controller: controller, - isGrid: true, - onTouchEdge: albumsQueryNotifier.fetchMore, - child: Skeletonizer( - enabled: true, - child: AlbumCard(FakeData.albumSimple), - ), - ); - } - - return Skeletonizer( - enabled: albumsQuery.isLoading, - child: AlbumCard( - albums.elementAtOrNull(index) ?? FakeData.albumSimple, - ), - ); - }, - ); - }), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/modules/library/user_artists.dart b/lib/modules/library/user_artists.dart deleted file mode 100644 index 7968d91c..00000000 --- a/lib/modules/library/user_artists.dart +++ /dev/null @@ -1,124 +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:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/fake.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/modules/artist/artist_card.dart'; -import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/waypoint.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication/authentication.dart'; -import 'package:spotube/provider/spotify/spotify.dart'; - -class UserArtists extends HookConsumerWidget { - const UserArtists({super.key}); - - @override - Widget build(BuildContext context, ref) { - final auth = ref.watch(authenticationProvider); - - final artistQuery = ref.watch(followedArtistsProvider); - final artistQueryNotifier = ref.watch(followedArtistsProvider.notifier); - - final searchText = useState(''); - - final filteredArtists = useMemoized(() { - final artists = artistQuery.asData?.value.items ?? []; - - if (searchText.value.isEmpty) { - return artists.toList(); - } - return artists - .map((e) => ( - weightedRatio(e.name!, searchText.value), - e, - )) - .sorted((a, b) => b.$1.compareTo(a.$1)) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList(); - }, [artistQuery.asData?.value.items, searchText.value]); - - final controller = useScrollController(); - - if (auth.asData?.value == null) { - return const AnonymousFallback(); - } - - return SafeArea( - child: Scaffold( - body: RefreshIndicator( - onRefresh: () async { - ref.invalidate(followedArtistsProvider); - }, - child: InterScrollbar( - controller: controller, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: CustomScrollView( - controller: controller, - slivers: [ - SliverAppBar( - floating: true, - flexibleSpace: SearchBar( - onChanged: (value) => searchText.value = value, - leading: const Icon(SpotubeIcons.filter), - hintText: context.l10n.filter_artist, - ), - ), - const SliverGap(10), - SliverLayoutBuilder(builder: (context, constrains) { - return SliverGrid.builder( - itemCount: filteredArtists.isEmpty - ? 6 - : filteredArtists.length + 1, - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - mainAxisExtent: constrains.smAndDown ? 225 : 250, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - itemBuilder: (context, index) { - if (filteredArtists.isNotEmpty && - index == filteredArtists.length) { - if (artistQuery.asData?.value.hasMore != true) { - return const SizedBox.shrink(); - } - - return Waypoint( - controller: controller, - isGrid: true, - onTouchEdge: artistQueryNotifier.fetchMore, - child: Skeletonizer( - enabled: true, - child: ArtistCard(FakeData.artist), - ), - ); - } - - return Skeletonizer( - enabled: artistQuery.isLoading, - child: ArtistCard( - filteredArtists.elementAtOrNull(index) ?? - FakeData.artist, - ), - ); - }, - ); - }), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/modules/library/user_downloads/download_item.dart b/lib/modules/library/user_downloads/download_item.dart index c4bd7bce..2c0a96a5 100644 --- a/lib/modules/library/user_downloads/download_item.dart +++ b/lib/modules/library/user_downloads/download_item.dart @@ -1,17 +1,18 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/links/artist_link.dart'; +import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/services/download_manager/download_status.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/service_utils.dart'; class DownloadItem extends HookConsumerWidget { final Track track; @@ -46,7 +47,8 @@ class DownloadItem extends HookConsumerWidget { final isQueryingSourceInfo = taskStatus.value == null || track is! SourcedTrack; - return ListTile( + return ButtonTile( + style: ButtonVariance.ghost, leading: Padding( padding: const EdgeInsets.symmetric(horizontal: 5), child: ClipRRect( @@ -64,19 +66,12 @@ class DownloadItem extends HookConsumerWidget { subtitle: ArtistLink( artists: track.artists ?? [], mainAxisAlignment: WrapAlignment.start, - onOverflowArtistClick: () => ServiceUtils.pushNamed( - context, - TrackPage.name, - pathParameters: { - "id": track.id!, - }, - ), + onOverflowArtistClick: () { + context.navigateTo(TrackRoute(trackId: track.id!)); + }, ), trailing: isQueryingSourceInfo - ? Text( - context.l10n.querying_info, - style: Theme.of(context).textTheme.labelMedium, - ) + ? Text(context.l10n.querying_info).small() : switch (taskStatus.value!) { DownloadStatus.downloading => HookBuilder(builder: (context) { final taskProgress = useListenable(useMemoized( @@ -84,39 +79,36 @@ class DownloadItem extends HookConsumerWidget { .getProgressNotifier(track as SourcedTrack), [track], )); - return SizedBox( - width: 140, - child: Row( - children: [ - CircularProgressIndicator( - value: taskProgress?.value ?? 0, - ), - const SizedBox(width: 10), - IconButton( - icon: const Icon(SpotubeIcons.pause), - onPressed: () { - downloadManager.pause(track as SourcedTrack); - }), - const SizedBox(width: 10), - IconButton( - icon: const Icon(SpotubeIcons.close), - onPressed: () { - downloadManager.cancel(track as SourcedTrack); - }), - ], - ), + return Row( + children: [ + CircularProgressIndicator( + value: taskProgress?.value ?? 0, + ), + const SizedBox(width: 10), + IconButton.ghost( + icon: const Icon(SpotubeIcons.pause), + onPressed: () { + downloadManager.pause(track as SourcedTrack); + }), + const SizedBox(width: 10), + IconButton.ghost( + icon: const Icon(SpotubeIcons.close), + onPressed: () { + downloadManager.cancel(track as SourcedTrack); + }), + ], ); }), DownloadStatus.paused => Row( mainAxisSize: MainAxisSize.min, children: [ - IconButton( + IconButton.ghost( icon: const Icon(SpotubeIcons.play), onPressed: () { downloadManager.resume(track as SourcedTrack); }), const SizedBox(width: 10), - IconButton( + IconButton.ghost( icon: const Icon(SpotubeIcons.close), onPressed: () { downloadManager.cancel(track as SourcedTrack); @@ -132,7 +124,7 @@ class DownloadItem extends HookConsumerWidget { color: Colors.red[400], ), const SizedBox(width: 10), - IconButton( + IconButton.ghost( icon: const Icon(SpotubeIcons.refresh), onPressed: () { downloadManager.retry(track as SourcedTrack); @@ -143,7 +135,7 @@ class DownloadItem extends HookConsumerWidget { ), DownloadStatus.completed => Icon(SpotubeIcons.done, color: Colors.green[400]), - DownloadStatus.queued => IconButton( + DownloadStatus.queued => IconButton.ghost( icon: const Icon(SpotubeIcons.close), onPressed: () { downloadManager.removeFromQueue(track as SourcedTrack); diff --git a/lib/modules/lyrics/zoom_controls.dart b/lib/modules/lyrics/zoom_controls.dart index 73beb4ae..b4eeb9d6 100644 --- a/lib/modules/lyrics/zoom_controls.dart +++ b/lib/modules/lyrics/zoom_controls.dart @@ -1,5 +1,6 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -32,7 +33,7 @@ class ZoomControls extends HookWidget { @override Widget build(BuildContext context) { final actions = [ - IconButton( + IconButton.ghost( icon: decreaseIcon, onPressed: () { if (value == min) return; @@ -40,7 +41,7 @@ class ZoomControls extends HookWidget { }, ), Text("$value$unit"), - IconButton( + IconButton.ghost( icon: increaseIcon, onPressed: () { if (value == max) return; @@ -50,27 +51,28 @@ class ZoomControls extends HookWidget { ]; return Container( - decoration: BoxDecoration( - color: Theme.of(context).cardColor.withOpacity(0.7), - borderRadius: BorderRadius.circular(10), - ), constraints: BoxConstraints( maxHeight: direction == Axis.horizontal ? 50 : 200, maxWidth: direction == Axis.vertical ? 50 : double.infinity, ), margin: const EdgeInsets.all(8), - child: direction == Axis.horizontal - ? Row( - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: actions, - ) - : Column( - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - verticalDirection: VerticalDirection.up, - children: actions, - ), + child: SurfaceCard( + surfaceBlur: context.theme.surfaceBlur, + surfaceOpacity: context.theme.surfaceOpacity, + padding: EdgeInsets.zero, + child: direction == Axis.horizontal + ? Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: actions, + ) + : Column( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + verticalDirection: VerticalDirection.up, + children: actions, + ), + ), ); } } diff --git a/lib/modules/player/player.dart b/lib/modules/player/player.dart index 925afadc..4cf37b85 100644 --- a/lib/modules/player/player.dart +++ b/lib/modules/player/player.dart @@ -1,37 +1,32 @@ +import 'package:auto_route/auto_route.dart'; 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:shadcn_flutter/shadcn_flutter.dart'; +import 'package:sliding_up_panel/sliding_up_panel.dart'; import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/framework/app_pop_scope.dart'; import 'package:spotube/modules/player/player_actions.dart'; import 'package:spotube/modules/player/player_controls.dart'; -import 'package:spotube/modules/player/player_queue.dart'; import 'package:spotube/modules/player/volume_slider.dart'; -import 'package:spotube/components/animated_gradient.dart'; import 'package:spotube/components/dialogs/track_details_dialog.dart'; import 'package:spotube/components/links/artist_link.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/panels/sliding_up_panel.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart'; -import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/models/local_track.dart'; -import 'package:spotube/pages/lyrics/lyrics.dart'; -import 'package:spotube/pages/track/track.dart'; +import 'package:spotube/modules/root/spotube_navigation_bar.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/volume_provider.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart'; -import 'package:spotube/utils/service_utils.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -55,6 +50,16 @@ class PlayerView extends HookConsumerWidget { final isLocalTrack = currentTrack is LocalTrack; final mediaQuery = MediaQuery.of(context); + final shouldHide = useState(true); + + ref.listen(navigationPanelHeight, (_, height) { + shouldHide.value = height.ceil() == 50; + }); + + if (shouldHide.value) { + return const SizedBox(); + } + useEffect(() { if (mediaQuery.lgAndUp) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -71,15 +76,6 @@ class PlayerView extends HookConsumerWidget { [currentTrack?.album?.images], ); - final palette = usePaletteGenerator(albumArt); - final titleTextColor = palette.dominantColor?.titleTextColor; - final bodyTextColor = palette.dominantColor?.bodyTextColor; - - final bgColor = palette.dominantColor?.color ?? theme.colorScheme.primary; - - final GlobalKey scaffoldKey = - useMemoized(() => GlobalKey(), []); - useEffect(() { for (final renderView in WidgetsBinding.instance.renderViews) { renderView.automaticSystemUiAdjustment = false; @@ -90,329 +86,184 @@ class PlayerView extends HookConsumerWidget { renderView.automaticSystemUiAdjustment = true; } }; - }, [panelController.isPanelOpen]); - - useCustomStatusBarColor( - bgColor, - panelController.isPanelOpen, - noSetBGColor: true, - automaticSystemUiAdjustment: false, - ); - - final topPadding = MediaQueryData.fromView(View.of(context)).padding.top; + }, [panelController.isAttached && panelController.isPanelOpen]); return AppPopScope( - canPop: context.canPop(), + canPop: false, onPopInvoked: (didPop) async { await 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, - ], - secondaryColors: [ - (palette.darkVibrantColor ?? palette.lightVibrantColor)?.color ?? - theme.colorScheme.primaryContainer, - (palette.darkMutedColor ?? palette.lightMutedColor)?.color ?? - theme.colorScheme.secondaryContainer, - ], - child: Scaffold( - key: scaffoldKey, - backgroundColor: Colors.transparent, - appBar: PreferredSize( - preferredSize: Size.fromHeight( - kToolbarHeight + topPadding, - ), - child: ForceDraggableWidget( - child: Padding( - padding: EdgeInsets.only(top: topPadding), - child: PageWindowTitleBar( - backgroundColor: Colors.transparent, - foregroundColor: titleTextColor, - toolbarOpacity: 1, - leading: IconButton( - icon: const Icon(SpotubeIcons.angleDown, size: 18), - onPressed: panelController.close, - ), - actions: [ - if (currentTrack is YoutubeSourcedTrack) - TextButton.icon( - icon: Assets.logos.songlinkTransparent.image( - width: 20, - height: 20, - color: bodyTextColor, - ), - label: Text(context.l10n.song_link), - style: TextButton.styleFrom( - foregroundColor: bodyTextColor, - padding: const EdgeInsets.symmetric(horizontal: 10), - ), - onPressed: () { - final url = - "https://song.link/s/${currentTrack.id}"; + child: SurfaceCard( + borderWidth: 0, + surfaceOpacity: 0.9, + padding: EdgeInsets.zero, + child: Scaffold( + backgroundColor: Colors.transparent, + headers: [ + SafeArea( + child: TitleBar( + surfaceOpacity: 0, + surfaceBlur: 0, + leading: [ + IconButton.ghost( + icon: const Icon(SpotubeIcons.angleDown, size: 18), + onPressed: panelController.close, + ) + ], + trailing: [ + if (currentTrack is YoutubeSourcedTrack) + TextButton( + leading: Assets.logos.songlinkTransparent.image( + width: 20, + height: 20, + color: theme.colorScheme.foreground, + ), + onPressed: () { + final url = "https://song.link/s/${currentTrack.id}"; - launchUrlString(url); - }, - ), - 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, - ); - }); - }, - ) - ], - ), - ), + launchUrlString(url); + }, + child: Text(context.l10n.song_link), + ), + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.details), + ), + child: IconButton.ghost( + icon: const Icon(SpotubeIcons.info, size: 18), + onPressed: currentTrack == null + ? null + : () { + showDialog( + context: context, + builder: (context) { + return TrackDetailsDialog( + track: currentTrack, + ); + }); + }, + ), + ) + ], ), ), - extendBodyBehindAppBar: true, - body: SingleChildScrollView( - controller: scrollController, - child: Container( - alignment: Alignment.center, - width: double.infinity, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 580), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: [ - ForceDraggableWidget( - child: Container( - margin: const EdgeInsets.all(8), - constraints: const BoxConstraints( - maxHeight: 300, maxWidth: 300), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - spreadRadius: 2, - blurRadius: 10, - offset: Offset(0, 0), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: UniversalImage( - path: albumArt, - placeholder: Assets.albumPlaceholder.path, - fit: BoxFit.cover, - ), - ), - ), - ), - const SizedBox(height: 60), - Container( - padding: const EdgeInsets.symmetric(horizontal: 16), - alignment: Alignment.centerLeft, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AutoSizeText( - currentTrack?.name ?? - context.l10n.not_playing, - style: TextStyle( - color: titleTextColor, - fontSize: 22, - ), - maxFontSize: 22, - maxLines: 1, - textAlign: TextAlign.start, - ), - if (isLocalTrack) - Text( - currentTrack.artists?.asString() ?? "", - style: theme.textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.bold, - color: bodyTextColor, - ), - ) - else - ArtistLink( - artists: currentTrack?.artists ?? [], - textStyle: - theme.textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.bold, - color: bodyTextColor, - ), - onRouteChange: (route) { - panelController.close(); - GoRouter.of(context).push(route); - }, - onOverflowArtistClick: () => - ServiceUtils.pushNamed( - context, - TrackPage.name, - pathParameters: { - "id": currentTrack!.id!, - }, - ), - ), - ], - ), - ), - const SizedBox(height: 10), - PlayerControls(palette: palette), - const SizedBox(height: 25), - const PlayerActions( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - showQueue: false, - ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - const SizedBox(width: 10), - Expanded( - child: OutlinedButton.icon( - icon: const Icon(SpotubeIcons.queue), - label: Text(context.l10n.queue), - style: OutlinedButton.styleFrom( - foregroundColor: bodyTextColor, - side: BorderSide( - color: bodyTextColor ?? Colors.white, - ), - ), - onPressed: currentTrack != null - ? () { - showModalBottomSheet( - context: context, - isDismissible: true, - enableDrag: true, - isScrollControlled: true, - backgroundColor: Colors.black12, - barrierColor: Colors.black12, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(10), - ), - constraints: BoxConstraints( - maxHeight: - MediaQuery.of(context) - .size - .height * - .7, - ), - builder: (context) => Consumer( - builder: (context, ref, _) { - final playlist = ref.watch( - audioPlayerProvider, - ); - final playlistNotifier = ref - .read(audioPlayerProvider - .notifier); - return PlayerQueue - .fromAudioPlayerNotifier( - floating: false, - playlist: playlist, - notifier: playlistNotifier, - ); - }, - ), - ); - } - : null), - ), - if (auth.asData?.value != null) - const SizedBox(width: 10), - if (auth.asData?.value != null) - Expanded( - child: OutlinedButton.icon( - label: Text(context.l10n.lyrics), - icon: const Icon(SpotubeIcons.music), - style: OutlinedButton.styleFrom( - foregroundColor: bodyTextColor, - side: BorderSide( - color: bodyTextColor ?? Colors.white, - ), - ), - onPressed: () { - showModalBottomSheet( - context: context, - isDismissible: true, - enableDrag: true, - isScrollControlled: true, - backgroundColor: Colors.black38, - barrierColor: Colors.black12, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(20), - ), - ), - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context) - .size - .height * - 0.8, - ), - builder: (context) => - const LyricsPage(isModal: true), - ); - }, - ), - ), - const SizedBox(width: 10), - ], - ), - const SizedBox(height: 25), - SliderTheme( - data: theme.sliderTheme.copyWith( - activeTrackColor: titleTextColor, - inactiveTrackColor: bodyTextColor, - thumbColor: titleTextColor, - overlayColor: titleTextColor?.withOpacity(0.2), - trackHeight: 2, - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 8, - ), - ), - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16), - child: Consumer(builder: (context, ref, _) { - final volume = ref.watch(volumeProvider); - return VolumeSlider( - fullWidth: true, - value: volume, - onChanged: (value) { - ref - .read(volumeProvider.notifier) - .setVolume(value); - }, - ); - }), - ), - ), - ], + ], + child: SingleChildScrollView( + controller: scrollController, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Container( + margin: const EdgeInsets.all(8), + constraints: + const BoxConstraints(maxHeight: 300, maxWidth: 300), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(100), + spreadRadius: 2, + blurRadius: 10, + offset: Offset.zero, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: UniversalImage( + path: albumArt, + placeholder: Assets.albumPlaceholder.path, + fit: BoxFit.cover, ), ), ), - ), + const SizedBox(height: 60), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + alignment: Alignment.centerLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AutoSizeText( + currentTrack?.name ?? context.l10n.not_playing, + style: const TextStyle(fontSize: 22), + maxFontSize: 22, + maxLines: 1, + textAlign: TextAlign.start, + ), + if (isLocalTrack) + Text( + currentTrack.artists?.asString() ?? "", + style: theme.typography.normal + .copyWith(fontWeight: FontWeight.bold), + ) + else + ArtistLink( + artists: currentTrack?.artists ?? [], + textStyle: theme.typography.normal + .copyWith(fontWeight: FontWeight.bold), + onRouteChange: (route) { + panelController.close(); + context.router.navigateNamed(route); + }, + onOverflowArtistClick: () => context.navigateTo( + TrackRoute(trackId: currentTrack!.id!), + ), + ), + ], + ), + ), + const SizedBox(height: 10), + const PlayerControls(), + const SizedBox(height: 25), + const PlayerActions( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + showQueue: false, + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const SizedBox(width: 10), + Expanded( + child: OutlineButton( + leading: const Icon(SpotubeIcons.queue), + child: Text(context.l10n.queue), + onPressed: () { + context.pushRoute(const PlayerQueueRoute()); + }, + ), + ), + if (auth.asData?.value != null) const SizedBox(width: 10), + if (auth.asData?.value != null) + Expanded( + child: OutlineButton( + leading: const Icon(SpotubeIcons.music), + child: Text(context.l10n.lyrics), + onPressed: () { + context.pushRoute(const PlayerLyricsRoute()); + }, + ), + ), + const SizedBox(width: 10), + ], + ), + const SizedBox(height: 25), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Consumer(builder: (context, ref, _) { + final volume = ref.watch(volumeProvider); + return VolumeSlider( + fullWidth: true, + value: volume, + onChanged: (value) { + ref.read(volumeProvider.notifier).setVolume(value); + }, + ); + }), + ), + ], ), ), ), diff --git a/lib/modules/player/player_actions.dart b/lib/modules/player/player_actions.dart index a47c992d..d4d8a239 100644 --- a/lib/modules/player/player_actions.dart +++ b/lib/modules/player/player_actions.dart @@ -1,9 +1,14 @@ +import 'package:auto_route/auto_route.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/modules/player/player_queue.dart'; import 'package:spotube/modules/player/sibling_tracks_sheet.dart'; import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/heart_button/heart_button.dart'; @@ -76,38 +81,72 @@ class PlayerActions extends HookConsumerWidget { mainAxisAlignment: mainAxisAlignment, children: [ if (showQueue) - IconButton( - icon: const Icon(SpotubeIcons.queue), - tooltip: context.l10n.queue, - onPressed: playlist.activeTrack != null - ? () { - Scaffold.of(context).openEndDrawer(); - } - : null, + Tooltip( + tooltip: TooltipContainer(child: Text(context.l10n.queue)), + child: IconButton.ghost( + icon: const Icon(SpotubeIcons.queue), + enabled: playlist.activeTrack != null, + onPressed: () { + openDrawer( + context: context, + position: OverlayPosition.right, + transformBackdrop: false, + draggable: false, + surfaceBlur: context.theme.surfaceBlur, + surfaceOpacity: 0.7, + builder: (context) { + return Container( + constraints: const BoxConstraints(maxWidth: 800), + child: Consumer( + builder: (context, ref, _) { + final playlist = ref.watch(audioPlayerProvider); + final playlistNotifier = + ref.read(audioPlayerProvider.notifier); + + return PlayerQueue.fromAudioPlayerNotifier( + floating: true, + playlist: playlist, + notifier: playlistNotifier, + ); + }, + ), + ); + }, + ); + }, + ), ), if (!isLocalTrack) - IconButton( - icon: const Icon(SpotubeIcons.alternativeRoute), - tooltip: context.l10n.alternative_track_sources, - onPressed: playlist.activeTrack != null - ? () { - showModalBottomSheet( - context: context, - isDismissible: true, - enableDrag: true, - isScrollControlled: true, - backgroundColor: Colors.black12, - barrierColor: Colors.black12, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - builder: (context) { - return SiblingTracksSheet(floating: floatingQueue); - }, - ); - } - : null, + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.alternative_track_sources)), + child: IconButton.ghost( + enabled: playlist.activeTrack != null, + icon: const Icon(SpotubeIcons.alternativeRoute), + onPressed: () { + final screenSize = MediaQuery.sizeOf(context); + if (screenSize.mdAndUp) { + showPopover( + alignment: Alignment.bottomCenter, + context: context, + builder: (context) { + return SurfaceCard( + padding: EdgeInsets.zero, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 600, + maxWidth: 500, + ), + child: SiblingTracksSheet(floating: floatingQueue), + ), + ); + }, + ); + } else { + context.pushRoute(const PlayerTrackSourcesRoute()); + } + }, + ), ), if (!kIsWeb && !isLocalTrack) if (isInQueue) @@ -115,24 +154,28 @@ class PlayerActions extends HookConsumerWidget { height: 20, width: 20, child: CircularProgressIndicator( - strokeWidth: 2, + size: 2, ), ) else - IconButton( - tooltip: context.l10n.download_track, - icon: Icon( - isDownloaded ? SpotubeIcons.done : SpotubeIcons.download, + Tooltip( + tooltip: + TooltipContainer(child: Text(context.l10n.download_track)), + child: IconButton.ghost( + icon: Icon( + isDownloaded ? SpotubeIcons.done : SpotubeIcons.download, + ), + onPressed: playlist.activeTrack != null + ? () => downloader.addToQueue(playlist.activeTrack!) + : null, ), - onPressed: playlist.activeTrack != null - ? () => downloader.addToQueue(playlist.activeTrack!) - : null, ), if (playlist.activeTrack != null && !isLocalTrack && auth.asData?.value != null) TrackHeartButton(track: playlist.activeTrack!), - AdaptivePopSheetList( + AdaptivePopSheetList( + tooltip: context.l10n.sleep_timer, offset: Offset(0, -50 * (sleepTimerEntries.values.length + 2)), headings: [ Text(context.l10n.sleep_timer), @@ -150,24 +193,47 @@ class PlayerActions extends HookConsumerWidget { }, children: [ for (final entry in sleepTimerEntries.entries) - PopSheetEntry( + AdaptiveMenuButton( value: entry.value, enabled: sleepTimer != entry.value, - title: Text(entry.key), + child: Text(entry.key), ), - PopSheetEntry( - title: Text( - customHoursEnabled - ? context.l10n.custom_hours - : sleepTimer.format(abbreviated: true), - ), - // only enabled when there's no preset timers selected + AdaptiveMenuButton( enabled: customHoursEnabled, - onTap: () async { + onPressed: (context) async { final currentTime = TimeOfDay.now(); - final time = await showTimePicker( + final time = await showDialog( context: context, - initialTime: currentTime, + builder: (context) => HookBuilder(builder: (context) { + final timeRef = useRef(null); + return AlertDialog( + trailing: IconButton.ghost( + size: ButtonSize.xSmall, + icon: const Icon(SpotubeIcons.close), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + ShadcnLocalizations.of(context).placeholderTimePicker, + ), + content: TimePickerDialog( + use24HourFormat: false, + initialValue: TimeOfDay.fromDateTime( + DateTime.now().add(sleepTimer ?? Duration.zero), + ), + onChanged: (value) => timeRef.value = value, + ), + actions: [ + Button.primary( + onPressed: () { + Navigator.of(context).pop(timeRef.value); + }, + child: Text(context.l10n.save), + ), + ], + ); + }), ); if (time != null) { @@ -179,12 +245,19 @@ class PlayerActions extends HookConsumerWidget { ); } }, + child: Text( + customHoursEnabled + ? context.l10n.custom_hours + : sleepTimer.format(abbreviated: true), + ), ), - PopSheetEntry( + AdaptiveMenuButton( value: Duration.zero, enabled: sleepTimer != Duration.zero && sleepTimer != null, - textColor: Colors.green, - title: Text(context.l10n.cancel), + child: Text( + context.l10n.cancel, + style: const TextStyle(color: Colors.green), + ), ), ], ), diff --git a/lib/modules/player/player_controls.dart b/lib/modules/player/player_controls.dart index 12288a3d..4d5d6deb 100644 --- a/lib/modules/player/player_controls.dart +++ b/lib/modules/player/player_controls.dart @@ -1,12 +1,13 @@ -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:media_kit/media_kit.dart'; import 'package:palette_generator/palette_generator.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/intents.dart'; +import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/modules/player/use_progress.dart'; @@ -47,44 +48,6 @@ class PlayerControls extends HookConsumerWidget { useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; final theme = Theme.of(context); - final isDominantColorDark = ThemeData.estimateBrightnessForColor( - palette?.dominantColor?.color ?? theme.colorScheme.primary, - ) == - Brightness.dark; - - final dominantColor = isDominantColorDark - ? palette?.mutedColor ?? palette?.dominantColor - : palette?.dominantColor; - - final sliderColor = - palette?.dominantColor?.titleTextColor ?? theme.colorScheme.primary; - - final buttonStyle = IconButton.styleFrom( - backgroundColor: dominantColor?.color.withOpacity(0.2) ?? - theme.colorScheme.surface.withOpacity(0.4), - minimumSize: const Size(28, 28), - ); - - final activeButtonStyle = IconButton.styleFrom( - backgroundColor: - dominantColor?.titleTextColor ?? theme.colorScheme.primaryContainer, - foregroundColor: - dominantColor?.color ?? theme.colorScheme.onPrimaryContainer, - minimumSize: const Size(28, 28), - ); - - final accentColor = palette?.lightVibrantColor ?? - palette?.darkVibrantColor ?? - dominantColor; - - final resumePauseStyle = IconButton.styleFrom( - backgroundColor: accentColor?.color ?? theme.colorScheme.primary, - foregroundColor: - accentColor?.titleTextColor ?? theme.colorScheme.onPrimary, - padding: EdgeInsets.all(compact ? 10 : 12), - iconSize: compact ? 18 : 24, - ); - return GestureDetector( behavior: HitTestBehavior.translucent, onTap: () { @@ -103,6 +66,8 @@ class PlayerControls extends HookConsumerWidget { if (!compact) HookBuilder( builder: (context) { + final mediaQuery = MediaQuery.sizeOf(context); + final ( :bufferProgress, :duration, @@ -122,45 +87,47 @@ class PlayerControls extends HookConsumerWidget { return Column( children: [ Tooltip( - message: context.l10n.slide_to_seek, - child: Slider( - // cannot divide by zero - // there's an edge case for value being bigger - // than total duration. Keeping it resolved - value: progress.value.toDouble(), - secondaryTrackValue: bufferProgress, - onChanged: isFetchingActiveTrack - ? null - : (v) { - progress.value = v; - }, - onChangeEnd: (value) async { - await audioPlayer.seek( - Duration( - seconds: (value * duration.inSeconds).toInt(), - ), - ); - }, - activeColor: sliderColor, - secondaryActiveColor: sliderColor.withOpacity(0.2), - inactiveColor: sliderColor.withOpacity(0.15), + tooltip: TooltipContainer( + child: Text(context.l10n.slide_to_seek), + ), + child: SizedBox( + width: mediaQuery.xlAndUp ? 600 : 500, + child: Slider( + hintValue: SliderValue.single(bufferProgress), + value: + SliderValue.single(progress.value.toDouble()), + onChanged: isFetchingActiveTrack + ? null + : (v) { + progress.value = v.value; + }, + onChangeEnd: (value) async { + await audioPlayer.seek( + Duration( + seconds: (value.value * duration.inSeconds) + .toInt(), + ), + ); + }, + ), ), ), Padding( padding: const EdgeInsets.symmetric( horizontal: 8.0, ), - child: DefaultTextStyle( - style: theme.textTheme.bodySmall!.copyWith( - color: palette?.dominantColor?.bodyTextColor, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(position.toHumanReadableString()), - Text(duration.toHumanReadableString()), - ], - ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + position.toHumanReadableString(), + style: theme.typography.xSmall, + ), + Text( + duration.toHumanReadableString(), + style: theme.typography.xSmall, + ), + ], ), ), ], @@ -173,92 +140,118 @@ class PlayerControls extends HookConsumerWidget { Consumer(builder: (context, ref, _) { final shuffled = ref .watch(audioPlayerProvider.select((s) => s.shuffled)); - return IconButton( - tooltip: shuffled - ? context.l10n.unshuffle_playlist - : context.l10n.shuffle_playlist, - icon: const Icon(SpotubeIcons.shuffle), - style: shuffled ? activeButtonStyle : buttonStyle, - onPressed: isFetchingActiveTrack - ? null - : () { - if (shuffled) { - audioPlayer.setShuffle(false); - } else { - audioPlayer.setShuffle(true); - } - }, + return Tooltip( + tooltip: TooltipContainer( + child: Text( + shuffled + ? context.l10n.unshuffle_playlist + : context.l10n.shuffle_playlist, + ), + ), + child: IconButton( + icon: Icon( + SpotubeIcons.shuffle, + color: shuffled ? theme.colorScheme.primary : null, + ), + variance: shuffled + ? ButtonVariance.secondary + : ButtonVariance.ghost, + onPressed: isFetchingActiveTrack + ? null + : () { + if (shuffled) { + audioPlayer.setShuffle(false); + } else { + audioPlayer.setShuffle(true); + } + }, + ), ); }), - IconButton( - tooltip: context.l10n.previous_track, - icon: const Icon(SpotubeIcons.skipBack), - style: buttonStyle, - onPressed: isFetchingActiveTrack - ? null - : audioPlayer.skipToPrevious, + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.previous_track)), + child: IconButton.ghost( + enabled: !isFetchingActiveTrack, + icon: const Icon(SpotubeIcons.skipBack), + onPressed: audioPlayer.skipToPrevious, + ), ), - IconButton( - tooltip: playing - ? context.l10n.pause_playback - : context.l10n.resume_playback, - icon: isFetchingActiveTrack - ? SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - color: accentColor?.titleTextColor ?? - theme.colorScheme.onPrimary, + Tooltip( + tooltip: TooltipContainer( + child: Text( + playing + ? context.l10n.pause_playback + : context.l10n.resume_playback, + ), + ), + child: IconButton.primary( + shape: ButtonShape.circle, + icon: isFetchingActiveTrack + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(), + ) + : Icon( + playing ? SpotubeIcons.pause : SpotubeIcons.play, ), - ) - : Icon( - playing ? SpotubeIcons.pause : SpotubeIcons.play, - ), - style: resumePauseStyle, - onPressed: isFetchingActiveTrack - ? null - : Actions.handler( - context, - PlayPauseIntent(ref), - ), + onPressed: isFetchingActiveTrack + ? null + : Actions.handler( + context, + PlayPauseIntent(ref), + ), + ), ), - IconButton( - tooltip: context.l10n.next_track, - icon: const Icon(SpotubeIcons.skipForward), - style: buttonStyle, - onPressed: - isFetchingActiveTrack ? null : audioPlayer.skipToNext, + Tooltip( + tooltip: + TooltipContainer(child: Text(context.l10n.next_track)), + child: IconButton.ghost( + icon: const Icon(SpotubeIcons.skipForward), + onPressed: + isFetchingActiveTrack ? null : audioPlayer.skipToNext, + ), ), Consumer(builder: (context, ref, _) { final loopMode = ref .watch(audioPlayerProvider.select((s) => s.loopMode)); - return IconButton( - tooltip: loopMode == PlaylistMode.single - ? context.l10n.loop_track - : loopMode == PlaylistMode.loop - ? context.l10n.repeat_playlist - : null, - icon: Icon( - loopMode == PlaylistMode.single - ? SpotubeIcons.repeatOne - : SpotubeIcons.repeat, + return Tooltip( + tooltip: TooltipContainer( + child: Text( + loopMode == PlaylistMode.single + ? context.l10n.loop_track + : loopMode == PlaylistMode.loop + ? context.l10n.repeat_playlist + : "", + ), + ), + child: IconButton( + icon: Icon( + loopMode == PlaylistMode.single + ? SpotubeIcons.repeatOne + : SpotubeIcons.repeat, + color: loopMode != PlaylistMode.none + ? theme.colorScheme.primary + : null, + ), + variance: loopMode == PlaylistMode.single || + loopMode == PlaylistMode.loop + ? ButtonVariance.secondary + : ButtonVariance.ghost, + onPressed: isFetchingActiveTrack + ? null + : () async { + await audioPlayer.setLoopMode( + switch (loopMode) { + PlaylistMode.loop => PlaylistMode.single, + PlaylistMode.single => PlaylistMode.none, + PlaylistMode.none => PlaylistMode.loop, + }, + ); + }, ), - style: loopMode == PlaylistMode.single || - loopMode == PlaylistMode.loop - ? activeButtonStyle - : buttonStyle, - onPressed: isFetchingActiveTrack - ? null - : () async { - await audioPlayer.setLoopMode( - switch (loopMode) { - PlaylistMode.loop => PlaylistMode.single, - PlaylistMode.single => PlaylistMode.none, - PlaylistMode.none => PlaylistMode.loop, - }, - ); - }, ); }), ], diff --git a/lib/modules/player/player_overlay.dart b/lib/modules/player/player_overlay.dart index 2322bcba..3c3ff373 100644 --- a/lib/modules/player/player_overlay.dart +++ b/lib/modules/player/player_overlay.dart @@ -1,19 +1,15 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:sliding_up_panel/sliding_up_panel.dart'; +import 'package:spotube/modules/player/player_overlay_collapsed.dart'; -import 'package:spotube/modules/player/player_track_details.dart'; import 'package:spotube/modules/root/spotube_navigation_bar.dart'; -import 'package:spotube/components/panels/sliding_up_panel.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/collections/intents.dart'; -import 'package:spotube/modules/player/use_progress.dart'; import 'package:spotube/modules/player/player.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/audio_player/querying_track_info.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; + +final playerOverlayControllerProvider = StateProvider((ref) { + return PanelController(); +}); class PlayerOverlay extends HookConsumerWidget { final String albumArt; @@ -25,180 +21,34 @@ class PlayerOverlay extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); final playlist = ref.watch(audioPlayerProvider); final canShow = playlist.activeTrack != null; - final playing = - useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; + final screenSize = MediaQuery.sizeOf(context); - final theme = Theme.of(context); - final textColor = theme.colorScheme.primary; - - const radius = BorderRadius.only( - topLeft: Radius.circular(10), - topRight: Radius.circular(10), - ); - - final mediaQuery = MediaQuery.of(context); - - final panelController = useMemoized(() => PanelController(), []); - final scrollController = useScrollController(); - - useEffect(() { - return () { - panelController.dispose(); - }; - }, []); + final panelController = ref.watch(playerOverlayControllerProvider); return SlidingUpPanel( - maxHeight: mediaQuery.size.height, + maxHeight: screenSize.height, backdropEnabled: false, - minHeight: canShow ? 53 : 0, + minHeight: canShow ? 63 : 0, onPanelSlide: (position) { final invertedPosition = 1 - position; ref.read(navigationPanelHeight.notifier).state = 50 * invertedPosition; }, controller: panelController, - collapsed: ClipRRect( - borderRadius: radius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: AnimatedContainer( - duration: const Duration(milliseconds: 250), - width: mediaQuery.size.width, - decoration: BoxDecoration( - color: theme.colorScheme.secondaryContainer.withOpacity(.8), - borderRadius: radius, - ), - child: AnimatedOpacity( - duration: const Duration(milliseconds: 250), - opacity: canShow ? 1 : 0, - child: Material( - type: MaterialType.transparency, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - HookBuilder( - builder: (context) { - final progress = useProgress(ref); - // animated - return TweenAnimationBuilder( - duration: const Duration(milliseconds: 250), - tween: Tween( - begin: 0, - end: progress.progressStatic, - ), - builder: (context, value, child) { - return LinearProgressIndicator( - value: value, - minHeight: 2, - backgroundColor: Colors.transparent, - valueColor: AlwaysStoppedAnimation( - theme.colorScheme.primary, - ), - ); - }, - ); - }, - ), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: GestureDetector( - onTap: () { - panelController.open(); - }, - child: Container( - width: double.infinity, - color: Colors.transparent, - child: PlayerTrackDetails( - track: playlist.activeTrack, - color: textColor, - ), - ), - ), - ), - Row( - children: [ - IconButton( - icon: Icon( - SpotubeIcons.skipBack, - color: textColor, - ), - onPressed: isFetchingActiveTrack - ? null - : audioPlayer.skipToPrevious, - ), - Consumer( - builder: (context, ref, _) { - return IconButton( - icon: isFetchingActiveTrack - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(), - ) - : Icon( - playing - ? SpotubeIcons.pause - : SpotubeIcons.play, - color: textColor, - ), - onPressed: Actions.handler( - context, - PlayPauseIntent(ref), - ), - ); - }, - ), - IconButton( - icon: Icon( - SpotubeIcons.skipForward, - color: textColor, - ), - onPressed: isFetchingActiveTrack - ? null - : audioPlayer.skipToNext, - ), - ], - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), + color: Colors.transparent, + parallaxEnabled: true, + renderPanelSheet: false, + header: SizedBox( + height: 63, + width: screenSize.width, + child: PlayerOverlayCollapsedSection(panelController: panelController), + ), + panelBuilder: (scrollController) => PlayerView( + panelController: panelController, + scrollController: scrollController, ), - 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/modules/player/player_overlay_collapsed.dart b/lib/modules/player/player_overlay_collapsed.dart new file mode 100644 index 00000000..d0961ade --- /dev/null +++ b/lib/modules/player/player_overlay_collapsed.dart @@ -0,0 +1,117 @@ +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:sliding_up_panel/sliding_up_panel.dart'; +import 'package:spotube/collections/intents.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/modules/player/player_track_details.dart'; +import 'package:spotube/modules/root/spotube_navigation_bar.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/audio_player/querying_track_info.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; + +class PlayerOverlayCollapsedSection extends HookConsumerWidget { + final PanelController panelController; + const PlayerOverlayCollapsedSection({ + super.key, + required this.panelController, + }); + + @override + Widget build(BuildContext context, ref) { + final playlist = ref.watch(audioPlayerProvider); + final canShow = playlist.activeTrack != null; + + final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); + final playing = + useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; + + final theme = Theme.of(context); + + final shouldShow = useState(true); + + ref.listen(navigationPanelHeight, (_, height) { + shouldShow.value = height.ceil() == 50; + }); + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: canShow && shouldShow.value + ? Padding( + padding: const EdgeInsets.all(5), + child: SurfaceCard( + surfaceBlur: theme.surfaceBlur, + surfaceOpacity: theme.surfaceOpacity, + padding: EdgeInsets.zero, + borderRadius: theme.borderRadiusLg, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: GestureDetector( + onTap: () { + panelController.open(); + }, + child: Container( + width: double.infinity, + color: Colors.transparent, + child: PlayerTrackDetails( + track: playlist.activeTrack, + color: theme.colorScheme.foreground, + ), + ), + ), + ), + Row( + children: [ + IconButton.ghost( + icon: const Icon(SpotubeIcons.skipBack), + onPressed: isFetchingActiveTrack + ? null + : audioPlayer.skipToPrevious, + ), + Consumer( + builder: (context, ref, _) { + return IconButton.ghost( + icon: isFetchingActiveTrack + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(), + ) + : Icon( + playing + ? SpotubeIcons.pause + : SpotubeIcons.play, + ), + onPressed: Actions.handler( + context, + PlayPauseIntent(ref), + ), + ); + }, + ), + IconButton.ghost( + icon: const Icon(SpotubeIcons.skipForward), + onPressed: isFetchingActiveTrack + ? null + : audioPlayer.skipToNext, + ), + const Gap(5), + ], + ), + ], + ), + ), + ], + ), + ), + ) + : const SizedBox.shrink(), + ); + } +} diff --git a/lib/modules/player/player_queue.dart b/lib/modules/player/player_queue.dart index 369b95d2..0ef86111 100644 --- a/lib/modules/player/player_queue.dart +++ b/lib/modules/player/player_queue.dart @@ -1,16 +1,15 @@ -import 'dart:ui'; - +import 'package:auto_size_text/auto_size_text.dart'; import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/button/back_button.dart'; import 'package:spotube/components/fallbacks/not_found.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/track_tile/track_tile.dart'; @@ -52,7 +51,7 @@ class PlayerQueue extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final mediaQuery = MediaQuery.of(context); + final mediaQuery = MediaQuery.sizeOf(context); final controller = useAutoScrollController(); final searchText = useState(''); @@ -60,16 +59,6 @@ class PlayerQueue extends HookConsumerWidget { final isSearching = useState(false); final tracks = playlist.tracks; - final borderRadius = floating - ? const BorderRadius.only( - topLeft: Radius.circular(10), - ) - : const BorderRadius.only( - topLeft: Radius.circular(10), - topRight: Radius.circular(10), - ); - final theme = Theme.of(context); - final headlineColor = theme.textTheme.headlineSmall?.color; final filteredTracks = useMemoized( () { @@ -92,217 +81,176 @@ class PlayerQueue extends HookConsumerWidget { [tracks, searchText.value], ); - useEffect(() { - if (playlist.activeTrack == null) return null; - - controller.scrollToIndex( - playlist.playlist.index, - preferPosition: AutoScrollPosition.middle, - ); - return null; - }, []); - if (tracks.isEmpty) { - return const NotFound(vertical: true); + return const NotFound(); } - return LayoutBuilder( - builder: (context, constrains) { - return ClipRRect( - borderRadius: borderRadius, - clipBehavior: Clip.hardEdge, - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 15, - sigmaY: 15, - ), - child: Container( - padding: const EdgeInsets.only( - top: 5.0, + return Stack( + children: [ + LayoutBuilder( + builder: (context, constrains) { + final searchBar = ConstrainedBox( + constraints: BoxConstraints( + maxHeight: 40, + maxWidth: mediaQuery.smAndDown ? mediaQuery.width - 40 : 300, ), - decoration: BoxDecoration( - color: - theme.colorScheme.surfaceContainerHighest.withOpacity(0.5), - borderRadius: borderRadius, - ), - child: CallbackShortcuts( - bindings: { - LogicalKeySet(LogicalKeyboardKey.escape): () { - if (!isSearching.value) { - Navigator.of(context).pop(); - } - isSearching.value = false; - searchText.value = ''; - } + child: TextField( + onChanged: (value) { + searchText.value = value; }, - child: InterScrollbar( - controller: controller, - child: CustomScrollView( - controller: controller, - slivers: [ - if (!floating) - SliverToBoxAdapter( - child: Center( - child: Container( - height: 5, - width: 100, - margin: const EdgeInsets.only(bottom: 5, top: 2), - decoration: BoxDecoration( - color: headlineColor, - borderRadius: BorderRadius.circular(20), - ), + placeholder: Text(context.l10n.search), + ), + ); + return CallbackShortcuts( + bindings: { + LogicalKeySet(LogicalKeyboardKey.escape): () { + if (!isSearching.value) { + Navigator.of(context).pop(); + } + isSearching.value = false; + searchText.value = ''; + } + }, + child: Column( + children: [ + if (isSearching.value && mediaQuery.smAndDown) + AppBar( + backgroundColor: Colors.transparent, + leading: [ + if (mediaQuery.smAndDown) + IconButton.ghost( + icon: const Icon( + Icons.arrow_back_ios_new_outlined, ), - ), - ), - SliverAppBar( - floating: true, - pinned: false, - snap: false, - backgroundColor: Colors.transparent, - elevation: 0, - automaticallyImplyLeading: false, - title: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 10, - sigmaY: 10, - ), - child: SizedBox( - height: kToolbarHeight, - child: mediaQuery.mdAndUp || !isSearching.value - ? Align( - alignment: Alignment.centerLeft, - child: Text( - context.l10n - .tracks_in_queue(tracks.length), - style: TextStyle( - color: headlineColor, - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - ) - : null, - ), - ), - actions: [ - if (mediaQuery.mdAndUp || isSearching.value) - TextField( - onChanged: (value) { - searchText.value = value; - }, - decoration: InputDecoration( - hintText: context.l10n.search, - isDense: true, - prefixIcon: mediaQuery.smAndDown - ? IconButton( - icon: const Icon( - Icons.arrow_back_ios_new_outlined, - ), - onPressed: () { - isSearching.value = false; - searchText.value = ''; - }, - style: IconButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: const Size.square(20), - ), - ) - : const Icon(SpotubeIcons.filter), - constraints: BoxConstraints( - maxHeight: 40, - maxWidth: mediaQuery.smAndDown - ? mediaQuery.size.width - 40 - : 300, - ), + onPressed: () { + isSearching.value = false; + searchText.value = ''; + }, + ) + ], + surfaceBlur: 0, + surfaceOpacity: 0, + child: searchBar, + ) + else + AppBar( + trailingGap: 0, + backgroundColor: Colors.transparent, + surfaceBlur: 0, + surfaceOpacity: 0, + title: mediaQuery.mdAndUp || !isSearching.value + ? SizedBox( + height: 30, + child: AutoSizeText( + context.l10n.tracks_in_queue(tracks.length), + maxLines: 1, ), ) - 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), - ], - ), + : null, + trailing: [ + if (mediaQuery.mdAndUp) + searchBar + else + IconButton.ghost( + icon: const Icon(SpotubeIcons.filter), + onPressed: () { + isSearching.value = !isSearching.value; + }, + ), + if (!isSearching.value) ...[ + const SizedBox(width: 10), + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.clear_all)), + child: IconButton.outline( + icon: const Icon(SpotubeIcons.playlistRemove), onPressed: () { onStop(); - Navigator.of(context).pop(); + closeDrawer(context); }, ), - const SizedBox(width: 10), - ], + ), + const Gap(5), + if (mediaQuery.smAndDown) + const BackButton(icon: SpotubeIcons.angleDown), ], - ), - const SliverGap(10), - SliverReorderableList( - onReorder: onReorder, - itemCount: filteredTracks.length, - onReorderStart: (index) { - HapticFeedback.selectionClick(); - }, - onReorderEnd: (index) { - HapticFeedback.selectionClick(); - }, - itemBuilder: (context, i) { - final track = filteredTracks.elementAt(i); - return AutoScrollTag( - key: ValueKey(i), - controller: controller, - index: i, - child: Material( - color: Colors.transparent, - child: TrackTile( - playlist: playlist, + ], + ), + const Divider(), + Expanded( + child: InterScrollbar( + controller: controller, + child: CustomScrollView( + controller: controller, + slivers: [ + const SliverGap(10), + SliverReorderableList( + onReorder: onReorder, + itemCount: filteredTracks.length, + onReorderStart: (index) { + HapticFeedback.selectionClick(); + }, + onReorderEnd: (index) { + HapticFeedback.selectionClick(); + }, + itemBuilder: (context, i) { + final track = filteredTracks.elementAt(i); + return AutoScrollTag( + key: ValueKey(i), + controller: controller, index: i, - track: track, - onTap: () async { - if (playlist.activeTrack?.id == track.id) { - return; - } - await onJump(track); - }, - leadingActions: [ - if (!isSearching.value && - searchText.value.isEmpty) - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: ReorderableDragStartListener( - index: i, - child: const Icon( - SpotubeIcons.dragHandle, + child: TrackTile( + playlist: playlist, + index: i, + track: track, + onTap: () async { + if (playlist.activeTrack?.id == track.id) { + return; + } + await onJump(track); + }, + leadingActions: [ + if (!isSearching.value && + searchText.value.isEmpty) + Padding( + padding: + const EdgeInsets.only(left: 8.0), + child: ReorderableDragStartListener( + index: i, + child: const Icon( + SpotubeIcons.dragHandle, + ), ), ), - ), - ], - ), - ), - ); - }, + ], + ), + ); + }, + ), + const SliverSafeArea(sliver: SliverGap(100)), + ], ), - const SliverGap(100), - ], + ), ), - ), + ], ), - ), + ); + }, + ), + Positioned( + right: 20, + bottom: 20, + child: IconButton.secondary( + icon: const Icon(SpotubeIcons.angleDown), + onPressed: () { + controller.scrollToIndex( + playlist.playlist.index, + preferPosition: AutoScrollPosition.middle, + ); + }, ), - ); - }, + ) + ], ); } } diff --git a/lib/modules/player/player_track_details.dart b/lib/modules/player/player_track_details.dart index 8d3b99fa..2e38bf37 100644 --- a/lib/modules/player/player_track_details.dart +++ b/lib/modules/player/player_track_details.dart @@ -1,17 +1,18 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; + +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/links/artist_link.dart'; import 'package:spotube/components/links/link_text.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/utils/service_utils.dart'; class PlayerTrackDetails extends HookConsumerWidget { final Color? color; @@ -50,17 +51,17 @@ class PlayerTrackDetails extends HookConsumerWidget { const SizedBox(height: 4), LinkText( playback.activeTrack?.name ?? "", - "/track/${playback.activeTrack?.id}", + TrackRoute(trackId: playback.activeTrack?.id ?? ""), push: true, overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium!.copyWith( + style: theme.typography.normal.copyWith( color: color, ), ), Text( playback.activeTrack?.artists?.asString() ?? "", overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall!.copyWith(color: color), + style: theme.typography.small.copyWith(color: color), ) ], ), @@ -72,7 +73,7 @@ class PlayerTrackDetails extends HookConsumerWidget { children: [ LinkText( playback.activeTrack?.name ?? "", - "/track/${playback.activeTrack?.id}", + TrackRoute(trackId: playback.activeTrack?.id ?? ""), push: true, overflow: TextOverflow.ellipsis, style: TextStyle(fontWeight: FontWeight.bold, color: color), @@ -80,15 +81,10 @@ class PlayerTrackDetails extends HookConsumerWidget { ArtistLink( artists: playback.activeTrack?.artists ?? [], onRouteChange: (route) { - ServiceUtils.push(context, route); + context.router.navigateNamed(route); }, - onOverflowArtistClick: () => ServiceUtils.pushNamed( - context, - TrackPage.name, - pathParameters: { - "id": track!.id!, - }, - ), + onOverflowArtistClick: () => + context.navigateTo(TrackRoute(trackId: track!.id!)), ) ], ), diff --git a/lib/modules/player/sibling_tracks_sheet.dart b/lib/modules/player/sibling_tracks_sheet.dart index 3a31d88e..d026cea9 100644 --- a/lib/modules/player/sibling_tracks_sheet.dart +++ b/lib/modules/player/sibling_tracks_sheet.dart @@ -1,25 +1,27 @@ -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:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; - +import 'package:spotube/components/button/back_button.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; +import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; import 'package:spotube/hooks/utils/use_debounce.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - +import 'package:spotube/provider/youtube_engine/youtube_engine.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart'; import 'package:spotube/services/sourced_track/models/video_info.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; @@ -69,6 +71,7 @@ class SiblingTracksSheet extends HookConsumerWidget { final playlist = ref.watch(audioPlayerProvider); final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); final preferences = ref.watch(userPreferencesProvider); + final youtubeEngine = ref.watch(youtubeEngineProvider); final isSearching = useState(false); final searchMode = useState(preferences.searchMode); @@ -84,7 +87,7 @@ class SiblingTracksSheet extends HookConsumerWidget { final defaultSearchTerm = "$title - ${activeTrack?.artists?.asString() ?? ""}"; - final searchController = useTextEditingController( + final searchController = useShadcnTextEditingController( text: defaultSearchTerm, ); @@ -116,14 +119,14 @@ class SiblingTracksSheet extends HookConsumerWidget { activeSourceInfo, ); } else { - final resultsYt = await youtubeClient.search.search(searchTerm.trim()); + final resultsYt = await youtubeEngine.searchVideos(searchTerm.trim()); final searchResults = await Future.wait( resultsYt .map(YoutubeVideoInfo.fromVideo) .mapIndexed((i, video) async { final siblingType = - await YoutubeSourcedTrack.toSiblingType(i, video); + await YoutubeSourcedTrack.toSiblingType(i, video, ref); return siblingType.info; }), ); @@ -140,6 +143,7 @@ class SiblingTracksSheet extends HookConsumerWidget { searchMode.value, activeTrack, preferences.audioSource, + youtubeEngine, ]); final siblings = useMemoized( @@ -152,52 +156,57 @@ class SiblingTracksSheet extends HookConsumerWidget { [activeTrack, isFetchingActiveTrack], ); - final borderRadius = floating - ? BorderRadius.circular(10) - : const BorderRadius.only( - topLeft: Radius.circular(10), - topRight: Radius.circular(10), - ); - + final previousActiveTrack = usePrevious(activeTrack); useEffect(() { + /// Populate sibling when active track changes + if (previousActiveTrack?.id == activeTrack?.id) return; if (activeTrack is SourcedTrack && activeTrack.siblings.isEmpty) { activeTrackNotifier.populateSibling(); } return null; - }, [activeTrack]); + }, [activeTrack, previousActiveTrack]); 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, - ), + return ButtonTile( + style: ButtonVariance.ghost, + padding: const EdgeInsets.symmetric(horizontal: 8), + title: Text( + sourceInfo.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), + leading: UniversalImage( + path: sourceInfo.thumbnail, + height: 60, + width: 60, ), trailing: Text(sourceInfo.duration.toHumanReadableString()), subtitle: Row( children: [ if (icon != null) icon, - Text(" • ${sourceInfo.artist}"), + Flexible( + child: Text( + " • ${sourceInfo.artist}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), ], ), enabled: !isFetchingActiveTrack, selected: !isFetchingActiveTrack && sourceInfo.id == (activeTrack as SourcedTrack).sourceInfo.id, - selectedTileColor: theme.popupMenuTheme.color, - onTap: () { + onPressed: () { if (!isFetchingActiveTrack && sourceInfo.id != (activeTrack as SourcedTrack).sourceInfo.id) { activeTrackNotifier.swapSibling(sourceInfo); - Navigator.of(context).pop(); + if (MediaQuery.sizeOf(context).mdAndUp) { + closeOverlay(context); + } else { + closeDrawer(context); + } } }, ); @@ -205,131 +214,124 @@ class SiblingTracksSheet extends HookConsumerWidget { [activeTrack, siblings], ); - final mediaQuery = MediaQuery.of(context); + final scale = context.theme.scaling; + 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.surfaceContainerHighest.withOpacity(.5), - ), - child: Scaffold( - backgroundColor: Colors.transparent, - appBar: AppBar( - centerTitle: true, - title: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: !isSearching.value - ? Text( - context.l10n.alternative_track_sources, - style: theme.textTheme.headlineSmall, - ) - : TextField( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16), + child: Row( + spacing: 5, + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: !isSearching.value + ? Text( + context.l10n.alternative_track_sources, + ).bold() + : ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 320 * scale, + maxHeight: 38 * scale, + ), + child: TextField( autofocus: true, controller: searchController, - decoration: InputDecoration( - hintText: context.l10n.search, - hintStyle: theme.textTheme.headlineSmall, - border: InputBorder.none, - ), - style: theme.textTheme.headlineSmall, + placeholder: Text(context.l10n.search), + style: theme.typography.bold, ), - ), - automaticallyImplyLeading: false, - backgroundColor: Colors.transparent, - actions: [ - if (!isSearching.value) - IconButton( - icon: const Icon(SpotubeIcons.search, size: 18), - onPressed: () { - isSearching.value = true; - }, - ) - else ...[ - if (preferences.audioSource == AudioSource.piped) - PopupMenuButton( - icon: const Icon(SpotubeIcons.filter, size: 18), - onSelected: (SearchMode mode) { - searchMode.value = mode; - }, - initialValue: searchMode.value, - itemBuilder: (context) => SearchMode.values - .map( - (e) => PopupMenuItem( - value: e, - child: Text(e.label), - ), - ) - .toList(), ), - IconButton( - icon: const Icon(SpotubeIcons.close, size: 18), - onPressed: () { - isSearching.value = false; - }, - ), - ] - ], ), - body: Padding( - padding: const EdgeInsets.all(8.0), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - transitionBuilder: (child, animation) => - FadeTransition(opacity: animation, child: child), - child: InterScrollbar( - controller: controller, - child: switch (isSearching.value) { - false => ListView.builder( - controller: controller, - itemCount: siblings.length, - itemBuilder: (context, index) => - itemBuilder(siblings[index]), - ), - true => FutureBuilder( - future: searchRequest, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Center( - child: Text(snapshot.error.toString()), - ); - } else if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator()); - } - - return InterScrollbar( - controller: controller, - child: ListView.builder( - controller: controller, - itemCount: snapshot.data!.length, - itemBuilder: (context, index) => - itemBuilder(snapshot.data![index]), - ), - ); - }, - ), + const Spacer(), + if (!isSearching.value) ...[ + IconButton.outline( + icon: const Icon(SpotubeIcons.search, size: 18), + onPressed: () { + isSearching.value = true; + }, + ), + if (!floating) const BackButton(icon: SpotubeIcons.angleDown) + ] else ...[ + if (preferences.audioSource == AudioSource.piped) + IconButton.outline( + icon: const Icon(SpotubeIcons.filter, size: 18), + onPressed: () { + showPopover( + context: context, + alignment: Alignment.bottomRight, + builder: (context) { + return DropdownMenu( + children: SearchMode.values + .map( + (e) => MenuButton( + onPressed: (context) { + searchMode.value = e; + }, + enabled: searchMode.value != e, + child: Text(e.label), + ), + ) + .toList(), + ); + }, + ); }, ), + IconButton.outline( + icon: const Icon(SpotubeIcons.close, size: 18), + onPressed: () { + isSearching.value = false; + }, ), - ), + ] + ], + ), + ), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: InterScrollbar( + controller: controller, + child: switch (isSearching.value) { + false => ListView.separated( + padding: const EdgeInsets.all(8.0), + controller: controller, + itemCount: siblings.length, + separatorBuilder: (context, index) => const Gap(8), + itemBuilder: (context, index) => + itemBuilder(siblings[index]), + ), + true => FutureBuilder( + future: searchRequest, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text(snapshot.error.toString()), + ); + } else if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator()); + } + + return ListView.separated( + padding: const EdgeInsets.all(8.0), + controller: controller, + itemCount: snapshot.data!.length, + separatorBuilder: (context, index) => const Gap(8), + itemBuilder: (context, index) => + itemBuilder(snapshot.data![index]), + ); + }, + ), + }, ), ), ), - ), + ], ), ); } diff --git a/lib/modules/player/volume_slider.dart b/lib/modules/player/volume_slider.dart index 8483143b..ee4ac9c5 100644 --- a/lib/modules/player/volume_slider.dart +++ b/lib/modules/player/volume_slider.dart @@ -1,6 +1,7 @@ import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/spotube_icons.dart'; class VolumeSlider extends HookConsumerWidget { @@ -30,24 +31,24 @@ class VolumeSlider extends HookConsumerWidget { } } }, - child: SliderTheme( - data: const SliderThemeData( - showValueIndicator: ShowValueIndicator.always, - ), + child: SizedBox( + height: 20, + width: 100, child: Slider( min: 0, max: 1, - label: (value * 100).toStringAsFixed(0), - value: value, - onChanged: onChanged, + value: SliderValue.single(value), + onChanged: (v) => onChanged(v.value), ), ), ); + return Row( mainAxisAlignment: !fullWidth ? MainAxisAlignment.center : MainAxisAlignment.start, children: [ IconButton( + variance: ButtonVariance.ghost, icon: Icon( value == 0 ? SpotubeIcons.volumeMute diff --git a/lib/modules/playlist/playlist_card.dart b/lib/modules/playlist/playlist_card.dart index df683a80..1e2ba1bf 100644 --- a/lib/modules/playlist/playlist_card.dart +++ b/lib/modules/playlist/playlist_card.dart @@ -1,27 +1,38 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotify/spotify.dart' hide Offset, Image; +import 'package:spotube/collections/env.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/components/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/playbutton_card.dart'; +import 'package:spotube/components/playbutton_view/playbutton_card.dart'; +import 'package:spotube/components/playbutton_view/playbutton_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/utils/service_utils.dart'; +import 'package:stroke_text/stroke_text.dart'; class PlaylistCard extends HookConsumerWidget { final PlaylistSimple playlist; + final bool _isTile; + const PlaylistCard( this.playlist, { super.key, - }); + }) : _isTile = false; + + const PlaylistCard.tile( + this.playlist, { + super.key, + }) : _isTile = true; + @override Widget build(BuildContext context, ref) { final playlistQueue = ref.watch(audioPlayerProvider); @@ -60,96 +71,162 @@ class PlaylistCard extends HookConsumerWidget { return ref.read(playlistTracksProvider(playlist.id!).notifier).fetchAll(); } - return PlaybuttonCard( - margin: const EdgeInsets.symmetric(horizontal: 10), - title: playlist.name!, - description: playlist.description, - imageUrl: playlist.images.asUrlString( - placeholder: ImagePlaceholder.collection, - ), - isPlaying: isPlaylistPlaying, - isLoading: (isPlaylistPlaying && isFetchingActiveTrack) || updating.value, - isOwner: playlist.owner?.id == me.asData?.value.id && - me.asData?.value.id != null, - onTap: () { - ServiceUtils.pushNamed( - context, - PlaylistPage.name, - pathParameters: { - "id": playlist.id!, - }, - extra: playlist, - ); - }, - onPlaybuttonPressed: () async { - try { - updating.value = true; - if (isPlaylistPlaying && playing) { - return audioPlayer.pause(); - } else if (isPlaylistPlaying && !playing) { - return audioPlayer.resume(); - } + void onTap() { + context.navigateTo(PlaylistRoute(id: playlist.id!, playlist: playlist)); + } - final fetchedInitialTracks = await fetchInitialTracks(); - - if (fetchedInitialTracks.isEmpty || !context.mounted) return; - - final isRemoteDevice = await showSelectDeviceDialog(context, ref); - if (isRemoteDevice) { - final remotePlayback = ref.read(connectProvider.notifier); - final allTracks = await fetchAllTracks(); - await remotePlayback.load( - WebSocketLoadEventData.playlist( - tracks: allTracks, - collection: playlist, - ), - ); - } else { - await playlistNotifier.load(fetchedInitialTracks, autoPlay: true); - playlistNotifier.addCollection(playlist.id!); - historyNotifier.addPlaylists([playlist]); - - final allTracks = await fetchAllTracks(); - - await playlistNotifier - .addTracks(allTracks.sublist(fetchedInitialTracks.length)); - } - } finally { - if (context.mounted) { - updating.value = false; - } - } - }, - onAddToQueuePressed: () async { + void onPlaybuttonPressed() async { + try { updating.value = true; - try { - if (isPlaylistPlaying) return; + if (isPlaylistPlaying && playing) { + return audioPlayer.pause(); + } else if (isPlaylistPlaying && !playing) { + return audioPlayer.resume(); + } - final fetchedInitialTracks = await fetchAllTracks(); + final fetchedInitialTracks = await fetchInitialTracks(); - if (fetchedInitialTracks.isEmpty) return; + if (fetchedInitialTracks.isEmpty || !context.mounted) return; - playlistNotifier.addTracks(fetchedInitialTracks); + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice == null) return; + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final allTracks = await fetchAllTracks(); + await remotePlayback.load( + WebSocketLoadEventData.playlist( + tracks: allTracks, + collection: playlist, + ), + ); + } else { + await playlistNotifier.load(fetchedInitialTracks, autoPlay: true); playlistNotifier.addCollection(playlist.id!); historyNotifier.addPlaylists([playlist]); - if (context.mounted) { - final snackbar = SnackBar( - content: Text(context.l10n - .added_num_tracks_to_queue(fetchedInitialTracks.length)), - action: SnackBarAction( - label: "Undo", - onPressed: () { - playlistNotifier - .removeTracks(fetchedInitialTracks.map((e) => e.id!)); - }, - ), - ); - ScaffoldMessenger.maybeOf(context)?.showSnackBar(snackbar); - } - } finally { + + final allTracks = await fetchAllTracks(); + + await playlistNotifier + .addTracks(allTracks.sublist(fetchedInitialTracks.length)); + } + } finally { + if (context.mounted) { updating.value = false; } - }, + } + } + + void onAddToQueuePressed() async { + updating.value = true; + try { + if (isPlaylistPlaying) return; + + final fetchedInitialTracks = await fetchAllTracks(); + + if (fetchedInitialTracks.isEmpty) return; + + playlistNotifier.addTracks(fetchedInitialTracks); + playlistNotifier.addCollection(playlist.id!); + historyNotifier.addPlaylists([playlist]); + if (context.mounted) { + showToast( + context: context, + builder: (context, overlay) { + return SurfaceCard( + child: Basic( + content: Text( + context.l10n + .added_num_tracks_to_queue(fetchedInitialTracks.length), + ), + trailing: Button.outline( + child: Text(context.l10n.undo), + onPressed: () { + playlistNotifier + .removeTracks(fetchedInitialTracks.map((e) => e.id!)); + }, + ), + ), + ); + }, + ); + } + } finally { + updating.value = false; + } + } + + final imageUrl = playlist.images.asUrlString( + placeholder: ImagePlaceholder.collection, + ); + final isLoading = + (isPlaylistPlaying && isFetchingActiveTrack) || updating.value; + final isOwner = playlist.owner?.id == me.asData?.value.id && + me.asData?.value.id != null; + + final image = + playlist.owner?.displayName == "Spotify" && Env.disableSpotifyImages + ? Consumer( + builder: (context, ref, child) { + final (:color, :colorBlendMode, :src, :placement) = + ref.watch(playlistImageProvider(playlist.id!)); + + return Stack( + children: [ + Positioned.fill( + child: Image.asset( + src, + color: color, + colorBlendMode: colorBlendMode, + fit: BoxFit.cover, + ), + ), + Positioned.fill( + top: placement == Alignment.topLeft ? 10 : null, + left: 10, + bottom: placement == Alignment.bottomLeft ? 10 : null, + child: StrokeText( + text: playlist.name!, + strokeColor: Colors.white, + strokeWidth: 3, + textColor: Colors.black, + textStyle: const TextStyle( + fontSize: 16, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ); + }, + ) + : null; + + if (_isTile) { + return PlaybuttonTile( + title: playlist.name!, + description: playlist.description, + image: image, + imageUrl: image == null ? imageUrl : null, + isPlaying: isPlaylistPlaying, + isLoading: isLoading, + isOwner: isOwner, + onTap: onTap, + onPlaybuttonPressed: onPlaybuttonPressed, + onAddToQueuePressed: onAddToQueuePressed, + ); + } + + return PlaybuttonCard( + title: playlist.name!, + description: playlist.description, + image: image, + imageUrl: image == null ? imageUrl : null, + isPlaying: isPlaylistPlaying, + isLoading: isLoading, + isOwner: isOwner, + onTap: onTap, + onPlaybuttonPressed: onPlaybuttonPressed, + onAddToQueuePressed: onAddToQueuePressed, ); } } diff --git a/lib/modules/playlist/playlist_create_dialog.dart b/lib/modules/playlist/playlist_create_dialog.dart index 78680a1c..9619b2ee 100644 --- a/lib/modules/playlist/playlist_create_dialog.dart +++ b/lib/modules/playlist/playlist_create_dialog.dart @@ -1,21 +1,23 @@ import 'dart:convert'; import 'dart:io'; +import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:form_validator/form_validator.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:path/path.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/form/checkbox_form_field.dart'; +import 'package:spotube/components/form/text_form_field.dart'; import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/extensions/string.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; @@ -23,241 +25,227 @@ class PlaylistCreateDialog extends HookConsumerWidget { /// Track ids to add to the playlist final List trackIds; final String? playlistId; - PlaylistCreateDialog({ + const PlaylistCreateDialog({ super.key, this.trackIds = const [], this.playlistId, }); - final formKey = GlobalKey(); - @override Widget build(BuildContext context, ref) { - return ScaffoldMessenger( - child: Scaffold( - backgroundColor: Colors.transparent, - body: HookBuilder(builder: (context) { - final userPlaylists = ref.watch(favoritePlaylistsProvider); - final playlist = ref.watch(playlistProvider(playlistId ?? "")); - final playlistNotifier = - ref.watch(playlistProvider(playlistId ?? "").notifier); + final userPlaylists = ref.watch(favoritePlaylistsProvider); + final playlist = ref.watch(playlistProvider(playlistId ?? "")); + final playlistNotifier = + ref.watch(playlistProvider(playlistId ?? "").notifier); - final updatingPlaylist = useMemoized( - () => userPlaylists.asData?.value.items - .firstWhereOrNull((playlist) => playlist.id == playlistId), - [ - userPlaylists.asData?.value.items, - playlistId, - ], - ); + final isSubmitting = useState(false); - final playlistName = useTextEditingController( - text: updatingPlaylist?.name, - ); - final description = useTextEditingController( - text: updatingPlaylist?.description?.unescapeHtml(), - ); - final public = useState( - updatingPlaylist?.public ?? false, - ); - final collaborative = useState( - updatingPlaylist?.collaborative ?? false, - ); - final image = useState(null); + final formKey = useMemoized(() => GlobalKey(), []); - final isUpdatingPlaylist = playlistId != null; + final updatingPlaylist = useMemoized( + () => userPlaylists.asData?.value.items + .firstWhereOrNull((playlist) => playlist.id == playlistId), + [ + userPlaylists.asData?.value.items, + playlistId, + ], + ); - final l10n = context.l10n; - final theme = Theme.of(context); - final scaffold = ScaffoldMessenger.of(context); + final isUpdatingPlaylist = playlistId != null; - final onError = useCallback((error) { - if (error is SpotifyError || error is SpotifyException) { - scaffold.showSnackBar( - SnackBar( - content: Text( - l10n.error(error.message ?? context.l10n.epic_failure), - style: theme.textTheme.bodyMedium!.copyWith( - color: theme.colorScheme.onError, - ), + final l10n = context.l10n; + final theme = Theme.of(context); + + final onError = useCallback((error) { + if (error is SpotifyError || error is SpotifyException) { + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + child: Basic( + title: Text( + l10n.error(error.message ?? l10n.epic_failure), + style: theme.typography.normal.copyWith( + color: theme.colorScheme.destructive, ), - backgroundColor: theme.colorScheme.error, ), - ); - } - }, [scaffold, l10n, theme]); - - Future onCreate() async { - if (!formKey.currentState!.validate()) return; - - final PlaylistInput 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, + ), ); + }, + ); + } + }, [l10n, theme]); - if (isUpdatingPlaylist) { - await playlistNotifier.modify(payload, onError); - } else { - await playlistNotifier.create(payload, onError); - } + Future onCreate() async { + if (!formKey.currentState!.saveAndValidate()) return; - if (context.mounted && - !ref.read(playlistProvider(playlistId ?? "")).hasError) { - context.pop(); - } - } + try { + isSubmitting.value = true; + final values = formKey.currentState!.value; - 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); + final PlaylistInput payload = ( + playlistName: values['playlistName'], + collaborative: values['collaborative'], + public: values['public'], + description: values['description'], + base64Image: (values['image'] as XFile?)?.path != null + ? await (values['image'] as XFile) + .readAsBytes() + .then((bytes) => base64Encode(bytes)) + : null, + ); + + if (isUpdatingPlaylist) { + await playlistNotifier.modify(payload, onError); + } else { + await playlistNotifier.create(payload, onError); + } + } finally { + isSubmitting.value = false; + if (context.mounted && + !ref.read(playlistProvider(playlistId ?? "")).hasError) { + context.router.maybePop(); + } + } + } + + return AlertDialog( + title: Text( + isUpdatingPlaylist + ? context.l10n.update_playlist + : context.l10n.create_a_playlist, + ), + actions: [ + Button.outline( + child: Text(context.l10n.cancel), + onPressed: () { + Navigator.pop(context); + }, + ), + Button.primary( + onPressed: onCreate, + enabled: !playlist.isLoading & !isSubmitting.value, + child: Text( + isUpdatingPlaylist ? context.l10n.update : context.l10n.create, + ), + ), + ], + content: Container( + width: MediaQuery.of(context).size.width, + constraints: const BoxConstraints(maxWidth: 500), + child: FormBuilder( + key: formKey, + initialValue: { + 'playlistName': updatingPlaylist?.name, + 'description': updatingPlaylist?.description, + 'public': updatingPlaylist?.public ?? false, + 'collaborative': updatingPlaylist?.collaborative ?? false, + }, + child: ListView( + shrinkWrap: true, + children: [ + FormBuilderField( + name: 'image', + validator: (value) { + if (value == null) return null; + final file = File(value.path); + + if (file.lengthSync() > 256000) { + return "Image size should be less than 256kb"; + } + + if (extension(file.path) != ".png") { + return "Image should be in PNG format"; + } + return null; + }, + builder: (field) { + return Column( + spacing: 10, + children: [ + UniversalImage( + path: field.value?.path ?? + (updatingPlaylist?.images).asUrlString( + placeholder: ImagePlaceholder.collection, + ), + height: 200, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Button.secondary( + leading: const Icon(SpotubeIcons.edit), + child: Text( + field.value?.path != null || + updatingPlaylist?.images != null + ? context.l10n.change_cover + : context.l10n.add_cover, + ), + onPressed: () async { + final imageFile = await ImagePicker().pickImage( + source: ImageSource.gallery, + ); + + if (imageFile != null) { + field.didChange(imageFile); + field.validate(); + field.save(); + } + }, + ), + const SizedBox(width: 10), + IconButton.destructive( + icon: const Icon(SpotubeIcons.trash), + enabled: field.value != null, + onPressed: () { + field.didChange(null); + field.validate(); + field.save(); + }, + ), + ], + ), + if (field.hasError) + Text( + field.errorText ?? "", + style: theme.typography.normal.copyWith( + color: theme.colorScheme.destructive, + ), + ) + ], + ); }, ), - FilledButton( - onPressed: playlist.isLoading ? null : onCreate, - child: Text( - isUpdatingPlaylist - ? context.l10n.update - : context.l10n.create, - ), + const Gap(20), + TextFormBuilderField( + name: 'playlistName', + label: Text(context.l10n.playlist_name), + placeholder: Text(context.l10n.name_of_playlist), + validator: FormBuilderValidators.required(), + ), + const Gap(20), + TextFormBuilderField( + name: 'description', + label: Text(context.l10n.description), + validator: FormBuilderValidators.required(), + placeholder: Text(context.l10n.description), + keyboardType: TextInputType.multiline, + maxLines: 5, + ), + const Gap(20), + CheckboxFormBuilderField( + name: 'public', + trailing: Text(context.l10n.public), + ), + const Gap(10), + CheckboxFormBuilderField( + name: 'collaborative', + trailing: Text(context.l10n.collaborative), ), ], - 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 ?? - (updatingPlaylist?.images).asUrlString( - 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, - ), - ], - ), - ), - ), - ); - }), + ), + ), ), ); } @@ -269,31 +257,20 @@ class PlaylistCreateDialogButton extends HookConsumerWidget { showPlaylistDialog(BuildContext context, SpotifyApi spotify) { showDialog( context: context, - builder: (context) => PlaylistCreateDialog(), + alignment: Alignment.center, + builder: (context) => const ToastLayer( + child: PlaylistCreateDialog(), + ), ); } @override Widget build(BuildContext context, ref) { - final mediaQuery = MediaQuery.of(context); final spotify = ref.watch(spotifyProvider); - if (mediaQuery.smAndDown) { - return ElevatedButton( - style: FilledButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.primary, - ), - child: const Icon(SpotubeIcons.addFilled), - onPressed: () => showPlaylistDialog(context, spotify), - ); - } - - return FilledButton.tonalIcon( - style: FilledButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.primary, - ), - icon: const Icon(SpotubeIcons.addFilled), - label: Text(context.l10n.create_playlist), + return Button.secondary( + leading: const Icon(SpotubeIcons.addFilled), + child: Text(context.l10n.playlist), onPressed: () => showPlaylistDialog(context, spotify), ); } diff --git a/lib/modules/root/bottom_player.dart b/lib/modules/root/bottom_player.dart index a2f45449..18b4c221 100644 --- a/lib/modules/root/bottom_player.dart +++ b/lib/modules/root/bottom_player.dart @@ -1,10 +1,11 @@ -import 'dart:ui'; - +import 'package:auto_route/auto_route.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/modules/player/player_actions.dart'; @@ -15,8 +16,6 @@ import 'package:spotube/modules/player/volume_slider.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/hooks/utils/use_brightness_value.dart'; -import 'package:flutter/material.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; @@ -45,14 +44,6 @@ class BottomPlayer extends HookConsumerWidget { [playlist.activeTrack?.album?.images], ); - final theme = Theme.of(context); - final bg = theme.colorScheme.surfaceContainerHighest; - - final bgColor = useBrightnessValue( - Color.lerp(bg, Colors.white, 0.7), - Color.lerp(bg, Colors.black, 0.45)!, - ); - // returning an empty non spacious Container as the overlay will take // place in the global overlay stack aka [_entries] if (layoutMode == LayoutMode.compact || @@ -60,84 +51,81 @@ class BottomPlayer extends HookConsumerWidget { return PlayerOverlay(albumArt: albumArt); } - return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: DecoratedBox( - decoration: BoxDecoration(color: bgColor?.withOpacity(0.8)), - child: Material( - type: MaterialType.transparency, - textStyle: theme.textTheme.bodyMedium!, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: PlayerTrackDetails(track: playlist.activeTrack), - ), - // controls - const Flexible( - flex: 3, - child: Padding( - padding: EdgeInsets.only(top: 5), - child: PlayerControls(), - ), - ), - // add to saved tracks - Column( - children: [ - PlayerActions( - extraActions: [ - IconButton( - tooltip: context.l10n.mini_player, - icon: const Icon(SpotubeIcons.miniPlayer), - onPressed: () async { - if (!kIsDesktop) return; - - final prevSize = await windowManager.getSize(); - await windowManager.setMinimumSize( - const Size(300, 300), - ); - await windowManager.setAlwaysOnTop(true); - if (!kIsLinux) { - await windowManager.setHasShadow(false); - } - await windowManager - .setAlignment(Alignment.topRight); - await windowManager.setSize(const Size(400, 500)); - await Future.delayed( - const Duration(milliseconds: 100), - () async { - GoRouter.of(context).go( - '/mini-player', - extra: prevSize, - ); - }, - ); - }, - ), - ], - ), - Container( - height: 40, - constraints: const BoxConstraints(maxWidth: 250), - padding: const EdgeInsets.only(right: 10), - child: Consumer(builder: (context, ref, _) { - final volume = ref.watch(volumeProvider); - return VolumeSlider( - fullWidth: true, - value: volume, - onChanged: (value) { - ref.read(volumeProvider.notifier).setVolume(value); - }, - ); - }), - ) - ], - ), - ], + return SurfaceCard( + borderRadius: BorderRadius.zero, + surfaceBlur: context.theme.surfaceBlur, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: PlayerTrackDetails(track: playlist.activeTrack), + ), + // controls + const Flexible( + flex: 3, + child: Padding( + padding: EdgeInsets.only(top: 5), + child: PlayerControls(), ), ), - ), + // add to saved tracks + Column( + mainAxisSize: MainAxisSize.min, + children: [ + PlayerActions( + extraActions: [ + Tooltip( + tooltip: + TooltipContainer(child: Text(context.l10n.mini_player)), + child: IconButton( + variance: ButtonVariance.ghost, + icon: const Icon(SpotubeIcons.miniPlayer), + onPressed: () async { + if (!kIsDesktop) return; + + final prevSize = await windowManager.getSize(); + await windowManager.setMinimumSize( + const Size(300, 300), + ); + await windowManager.setAlwaysOnTop(true); + if (!kIsLinux) { + await windowManager.setHasShadow(false); + } + await windowManager.setAlignment(Alignment.topRight); + await windowManager.setSize(const Size(400, 500)); + await Future.delayed( + const Duration(milliseconds: 100), + () async { + if (context.mounted) { + context.navigateTo( + MiniLyricsRoute(prevSize: prevSize), + ); + } + }, + ); + }, + ), + ), + ], + ), + Container( + height: 40, + constraints: const BoxConstraints(maxWidth: 250), + padding: const EdgeInsets.only(right: 10), + child: Consumer(builder: (context, ref, _) { + final volume = ref.watch(volumeProvider); + return VolumeSlider( + fullWidth: true, + value: volume, + onChanged: (value) { + ref.read(volumeProvider.notifier).setVolume(value); + }, + ); + }), + ) + ], + ), + ], ), ); } diff --git a/lib/modules/root/sidebar.dart b/lib/modules/root/sidebar.dart deleted file mode 100644 index f29644fb..00000000 --- a/lib/modules/root/sidebar.dart +++ /dev/null @@ -1,319 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:flutter/material.dart'; -import 'package:sidebarx/sidebarx.dart'; - -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/collections/side_bar_tiles.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/modules/connect/connect_device.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/image.dart'; -import 'package:spotube/hooks/utils/use_brightness_value.dart'; -import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart'; -import 'package:spotube/pages/profile/profile.dart'; -import 'package:spotube/pages/settings/settings.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/authentication/authentication.dart'; -import 'package:spotube/provider/spotify/spotify.dart'; - -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:window_manager/window_manager.dart'; - -class Sidebar extends HookConsumerWidget { - final Widget child; - - const Sidebar({ - required this.child, - super.key, - }); - - static Widget brandLogo() { - return Container( - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(50), - ), - child: Assets.spotubeLogoPng.image(height: 50), - ); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final routerState = GoRouterState.of(context); - final mediaQuery = MediaQuery.of(context); - - final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; - - final layoutMode = - ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); - - final sidebarTileList = useMemoized( - () => getSidebarTileList(context.l10n), - [context.l10n], - ); - - final selectedIndex = sidebarTileList.indexWhere( - (e) => routerState.namedLocation(e.name) == routerState.matchedLocation, - ); - - final controller = useSidebarXController( - selectedIndex: selectedIndex, - extended: mediaQuery.lgAndUp, - ); - - final theme = Theme.of(context); - final bg = theme.colorScheme.surfaceContainerHighest; - - final bgColor = useBrightnessValue( - Color.lerp(bg, Colors.white, 0.6), - Color.lerp(bg, Colors.black, 0.45)!, - ); - - useEffect(() { - if (!context.mounted) return; - if (mediaQuery.lgAndUp && !controller.extended) { - controller.setExtended(true); - } else if (mediaQuery.mdAndDown && controller.extended) { - controller.setExtended(false); - } - return null; - }, [mediaQuery, controller]); - - useEffect(() { - if (controller.selectedIndex != selectedIndex) { - controller.selectIndex(selectedIndex); - } - return null; - }, [selectedIndex]); - - if (layoutMode == LayoutMode.compact || - (mediaQuery.smAndDown && layoutMode == LayoutMode.adaptive)) { - return Scaffold(body: child); - } - - return Row( - children: [ - SafeArea( - child: SidebarX( - controller: controller, - items: sidebarTileList.mapIndexed( - (index, e) { - return SidebarXItem( - onTap: () { - context.goNamed(e.name); - }, - iconBuilder: (selected, hovered) { - return Badge( - backgroundColor: theme.colorScheme.primary, - isLabelVisible: e.title == "Library" && downloadCount > 0, - label: Text( - downloadCount.toString(), - style: const TextStyle( - color: Colors.white, - fontSize: 10, - ), - ), - child: Icon( - e.icon, - color: selected || hovered - ? theme.colorScheme.primary - : null, - ), - ); - }, - label: e.title, - ); - }, - ).toList(), - headerBuilder: (_, __) => const SidebarHeader(), - footerBuilder: (_, __) => const Padding( - padding: EdgeInsets.only(bottom: 5), - child: SidebarFooter(), - ), - showToggleButton: false, - theme: SidebarXTheme( - width: 50, - margin: EdgeInsets.only(bottom: 10, top: kIsMacOS ? 35 : 5), - selectedItemDecoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: theme.colorScheme.primary.withOpacity(0.1), - ), - selectedIconTheme: IconThemeData( - color: theme.colorScheme.primary, - ), - ), - extendedTheme: SidebarXTheme( - width: 250, - margin: EdgeInsets.only( - bottom: 10, - left: 0, - top: kIsMacOS ? 0 : 5, - ), - padding: const EdgeInsets.symmetric(horizontal: 6), - decoration: BoxDecoration( - color: bgColor?.withOpacity(0.8), - borderRadius: const BorderRadius.only( - topRight: Radius.circular(10), - bottomRight: Radius.circular(10), - ), - ), - selectedItemDecoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: theme.colorScheme.primary.withOpacity(0.1), - ), - selectedIconTheme: IconThemeData( - color: theme.colorScheme.primary, - ), - selectedTextStyle: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w600, - ), - itemTextPadding: const EdgeInsets.only(left: 10), - selectedItemTextPadding: const EdgeInsets.only(left: 10), - hoverTextStyle: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.primary, - ), - ), - ), - ), - Expanded(child: child) - ], - ); - } -} - -class SidebarHeader extends HookWidget { - const SidebarHeader({super.key}); - - @override - Widget build(BuildContext context) { - final mediaQuery = MediaQuery.of(context); - final theme = Theme.of(context); - - if (mediaQuery.mdAndDown) { - return Container( - height: 40, - width: 40, - margin: const EdgeInsets.only(bottom: 5), - child: Sidebar.brandLogo(), - ); - } - - return DragToMoveArea( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: [ - if (kIsMacOS) const SizedBox(height: 25), - Row( - children: [ - Sidebar.brandLogo(), - const SizedBox(width: 10), - Text( - "Spotube", - style: theme.textTheme.titleLarge, - ), - ], - ), - ], - ), - ), - ); - } -} - -class SidebarFooter extends HookConsumerWidget { - const SidebarFooter({ - super.key, - }); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final mediaQuery = MediaQuery.of(context); - final me = ref.watch(meProvider); - final data = me.asData?.value; - - final avatarImg = (data?.images).asUrlString( - index: (data?.images?.length ?? 1) - 1, - placeholder: ImagePlaceholder.artist, - ); - - final auth = ref.watch(authenticationProvider); - - if (mediaQuery.mdAndDown) { - return IconButton( - icon: const Icon(SpotubeIcons.settings), - onPressed: () => ServiceUtils.navigateNamed(context, SettingsPage.name), - ); - } - - return Container( - padding: const EdgeInsets.only(left: 12), - width: 250, - child: Column( - children: [ - const ConnectDeviceButton.sidebar(), - const Gap(10), - Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (auth.asData?.value != null && data == null) - const CircularProgressIndicator() - else if (data != null) - Flexible( - child: InkWell( - onTap: () { - ServiceUtils.pushNamed(context, ProfilePage.name); - }, - borderRadius: BorderRadius.circular(30), - child: Row( - children: [ - CircleAvatar( - backgroundImage: - UniversalImage.imageProvider(avatarImg), - onBackgroundImageError: (exception, stackTrace) => - Assets.userPlaceholder.image( - height: 16, - width: 16, - ), - ), - const SizedBox(width: 10), - Flexible( - child: Text( - data.displayName ?? context.l10n.guest, - maxLines: 1, - softWrap: false, - overflow: TextOverflow.fade, - style: theme.textTheme.bodyMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - ), - ], - ), - ), - ), - IconButton( - icon: const Icon(SpotubeIcons.settings), - onPressed: () { - ServiceUtils.pushNamed(context, SettingsPage.name); - }, - ), - ], - ), - ], - ), - ); - } -} diff --git a/lib/modules/root/sidebar/sidebar.dart b/lib/modules/root/sidebar/sidebar.dart new file mode 100644 index 00000000..743b339b --- /dev/null +++ b/lib/modules/root/sidebar/sidebar.dart @@ -0,0 +1,128 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/collections/side_bar_tiles.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/modules/root/sidebar/sidebar_footer.dart'; + +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; + +class Sidebar extends HookConsumerWidget { + final Widget child; + + const Sidebar({ + required this.child, + super.key, + }); + + static Widget brandLogo() { + return Container( + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(50), + ), + child: Assets.spotubeLogoPng.image(height: 50), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mediaQuery = MediaQuery.of(context); + + final layoutMode = + ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); + + final sidebarTileList = useMemoized( + () => getSidebarTileList(context.l10n), + [context.l10n], + ); + + final sidebarLibraryTileList = useMemoized( + () => getSidebarLibraryTileList(context.l10n), + [context.l10n], + ); + + final tileList = [...sidebarTileList, ...sidebarLibraryTileList]; + + final router = context.watchRouter; + + final selectedIndex = tileList.indexWhere( + (e) => router.currentPath.startsWith(e.pathPrefix), + ); + + if (layoutMode == LayoutMode.compact || + (mediaQuery.smAndDown && layoutMode == LayoutMode.adaptive)) { + return child; + } + + final navigationButtons = [ + NavigationLabel( + child: mediaQuery.lgAndUp ? const Text("Spotube") : const Text(""), + ), + for (final tile in sidebarTileList) + NavigationButton( + label: mediaQuery.lgAndUp ? Text(tile.title) : null, + child: Tooltip( + tooltip: TooltipContainer(child: Text(tile.title)), + child: Icon(tile.icon), + ), + onPressed: () { + context.navigateTo(tile.route); + }, + ), + const NavigationDivider(), + if (mediaQuery.lgAndUp) + NavigationLabel(child: Text(context.l10n.library)), + for (final tile in sidebarLibraryTileList) + NavigationButton( + label: mediaQuery.lgAndUp ? Text(tile.title) : null, + onPressed: () { + context.navigateTo(tile.route); + }, + child: Tooltip( + tooltip: TooltipContainer(child: Text(tile.title)), + child: Icon(tile.icon), + ), + ), + ]; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + Expanded( + child: mediaQuery.lgAndUp + ? NavigationSidebar( + index: selectedIndex, + onSelected: (index) { + final tile = tileList[index]; + context.navigateTo(tile.route); + }, + children: navigationButtons, + ) + : NavigationRail( + alignment: NavigationRailAlignment.start, + index: selectedIndex, + onSelected: (index) { + final tile = tileList[index]; + context.navigateTo(tile.route); + }, + children: navigationButtons, + ), + ), + const SidebarFooter(), + if (mediaQuery.lgAndUp) const Gap(130) else const Gap(65), + ], + ), + const VerticalDivider(), + Expanded(child: child), + ], + ); + } +} diff --git a/lib/modules/root/sidebar/sidebar_footer.dart b/lib/modules/root/sidebar/sidebar_footer.dart new file mode 100644 index 00000000..fb3edddd --- /dev/null +++ b/lib/modules/root/sidebar/sidebar_footer.dart @@ -0,0 +1,140 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart' show Badge; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/modules/connect/connect_device.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +class SidebarFooter extends HookConsumerWidget implements NavigationBarItem { + const SidebarFooter({ + super.key, + }); + + @override + Widget build(BuildContext context, ref) { + final theme = Theme.of(context); + final router = AutoRouter.of(context, watch: true); + final mediaQuery = MediaQuery.of(context); + final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; + final userSnapshot = ref.watch(meProvider); + final data = userSnapshot.asData?.value; + + final avatarImg = (data?.images).asUrlString( + index: (data?.images?.length ?? 1) - 1, + placeholder: ImagePlaceholder.artist, + ); + + final auth = ref.watch(authenticationProvider); + + if (mediaQuery.mdAndDown) { + return Column( + mainAxisSize: MainAxisSize.min, + spacing: 10, + children: [ + Badge( + isLabelVisible: downloadCount > 0, + label: Text(downloadCount.toString()), + child: IconButton( + variance: router.topRoute.name == UserDownloadsRoute.name + ? ButtonVariance.secondary + : ButtonVariance.ghost, + icon: const Icon(SpotubeIcons.download), + onPressed: () => context.navigateTo(const UserDownloadsRoute()), + ), + ), + const ConnectDeviceButton.sidebar(), + IconButton( + variance: ButtonVariance.ghost, + icon: const Icon(SpotubeIcons.settings), + onPressed: () => context.navigateTo(const SettingsRoute()), + ), + ], + ); + } + + return Container( + padding: const EdgeInsets.only(left: 12), + width: 180, + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 10, + children: [ + SizedBox( + width: double.infinity, + child: Button( + style: router.topRoute.name == UserDownloadsRoute.name + ? ButtonVariance.secondary + : ButtonVariance.outline, + onPressed: () { + context.navigateTo(const UserDownloadsRoute()); + }, + leading: const Icon(SpotubeIcons.download), + trailing: downloadCount > 0 + ? PrimaryBadge( + child: Text(downloadCount.toString()), + ) + : null, + child: Text(context.l10n.downloads), + ), + ), + const ConnectDeviceButton.sidebar(), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (auth.asData?.value != null && data == null) + const CircularProgressIndicator() + else if (data != null) + Flexible( + child: GestureDetector( + onTap: () { + context.navigateTo(const ProfileRoute()); + }, + child: Row( + children: [ + Avatar( + initials: + Avatar.getInitials(data.displayName ?? "User"), + provider: UniversalImage.imageProvider(avatarImg), + ), + const SizedBox(width: 10), + Flexible( + child: Text( + data.displayName ?? context.l10n.guest, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.fade, + style: theme.typography.normal + .copyWith(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + ), + IconButton( + variance: ButtonVariance.ghost, + icon: const Icon(SpotubeIcons.settings), + onPressed: () { + context.navigateTo(const SettingsRoute()); + }, + ), + ], + ), + ], + ), + ); + } + + @override + bool get selectable => false; +} diff --git a/lib/modules/root/spotube_navigation_bar.dart b/lib/modules/root/spotube_navigation_bar.dart index 978891b8..15417fa6 100644 --- a/lib/modules/root/spotube_navigation_bar.dart +++ b/lib/modules/root/spotube_navigation_bar.dart @@ -1,21 +1,19 @@ -import 'dart:ui'; +import 'dart:math'; -import 'package:curved_navigation_bar/curved_navigation_bar.dart'; -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart' show Badge; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/collections/side_bar_tiles.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/service_utils.dart'; - final navigationPanelHeight = StateProvider((ref) => 50); class SpotubeNavigationBar extends HookConsumerWidget { @@ -25,19 +23,12 @@ class SpotubeNavigationBar extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final routerState = GoRouterState.of(context); + final mediaQuery = MediaQuery.of(context); final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; - final mediaQuery = MediaQuery.of(context); final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); - final buttonColor = useBrightnessValue( - theme.colorScheme.inversePrimary, - theme.colorScheme.primary.withOpacity(0.2), - ); - final navbarTileList = useMemoized( () => getNavbarTileList(context.l10n), [context.l10n], @@ -45,13 +36,13 @@ class SpotubeNavigationBar extends HookConsumerWidget { final panelHeight = ref.watch(navigationPanelHeight); - final selectedIndex = useMemoized(() { - final index = navbarTileList.indexWhere( - (e) => routerState.namedLocation(e.name) == routerState.matchedLocation, - ); - - return index == -1 ? 0 : index; - }, [navbarTileList, routerState.matchedLocation]); + final router = context.watchRouter; + final selectedIndex = max( + 0, + navbarTileList.indexWhere( + (e) => router.currentPath.startsWith(e.pathPrefix), + ), + ); if (layoutMode == LayoutMode.extended || (mediaQuery.mdAndUp && layoutMode == LayoutMode.adaptive) || @@ -62,40 +53,32 @@ class SpotubeNavigationBar extends HookConsumerWidget { 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.surface, - 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: SingleChildScrollView( + child: Column( + children: [ + const Divider(), + NavigationBar( + index: selectedIndex, + surfaceBlur: context.theme.surfaceBlur, + surfaceOpacity: context.theme.surfaceOpacity, + children: [ + for (final tile in navbarTileList) + NavigationButton( + style: navbarTileList[selectedIndex] == tile + ? const ButtonStyle.fixed(density: ButtonDensity.icon) + : const ButtonStyle.muted(density: ButtonDensity.icon), child: Badge( - isLabelVisible: e.id == "library" && downloadCount > 0, + isLabelVisible: tile.id == "library" && downloadCount > 0, label: Text(downloadCount.toString()), - child: Icon( - e.icon, - color: Theme.of(context).colorScheme.primary, - ), + child: Icon(tile.icon), ), - ); - }); - }, - ).toList(), - index: selectedIndex, - onTap: (i) { - ServiceUtils.navigateNamed(context, navbarTileList[i].name); - }, - ), + onPressed: () { + context.navigateTo(tile.route); + }, + ) + ], + ), + ], ), ), ); diff --git a/lib/modules/root/update_dialog.dart b/lib/modules/root/update_dialog.dart index 27b857df..4aa2fd13 100644 --- a/lib/modules/root/update_dialog.dart +++ b/lib/modules/root/update_dialog.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/components/links/anchor_button.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:spotube/extensions/context.dart'; @@ -19,7 +19,7 @@ class RootAppUpdateDialog extends StatelessWidget { return AlertDialog( title: Text(context.l10n.spotube_has_an_update), actions: [ - FilledButton( + Button.primary( child: Text(context.l10n.download_now), onPressed: () => launchUrlString( nightlyBuildNum != null ? nightlyUrl : url, diff --git a/lib/modules/root/use_downloader_dialogs.dart b/lib/modules/root/use_downloader_dialogs.dart new file mode 100644 index 00000000..e2f91043 --- /dev/null +++ b/lib/modules/root/use_downloader_dialogs.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/components/dialogs/replace_downloaded_dialog.dart'; +import 'package:spotube/provider/download_manager_provider.dart'; + +void useDownloaderDialogs(WidgetRef ref) { + final context = useContext(); + final showingDialogCompleter = useRef(Completer()..complete()); + final downloader = ref.watch(downloadManagerProvider); + + useEffect(() { + downloader.onFileExists = (track) async { + if (!context.mounted) return false; + + if (!showingDialogCompleter.value.isCompleted) { + await showingDialogCompleter.value.future; + } + + final replaceAll = ref.read(replaceDownloadedFileState); + + if (replaceAll != null) return replaceAll; + + showingDialogCompleter.value = Completer(); + + if (context.mounted) { + final result = await showDialog( + context: context, + builder: (context) => ReplaceDownloadedDialog( + track: track, + ), + ) ?? + false; + + showingDialogCompleter.value.complete(); + return result; + } + + // it'll never reach here as root_app is always mounted + return false; + }; + return null; + }, [downloader]); +} diff --git a/lib/modules/root/use_global_subscriptions.dart b/lib/modules/root/use_global_subscriptions.dart new file mode 100644 index 00000000..e0e4dae7 --- /dev/null +++ b/lib/modules/root/use_global_subscriptions.dart @@ -0,0 +1,127 @@ +import 'dart:async'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/server/routes/connect.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/connectivity_adapter.dart'; +import 'package:spotube/utils/service_utils.dart'; + +void useGlobalSubscriptions(WidgetRef ref) { + final context = useContext(); + final theme = Theme.of(context); + final connectRoutes = ref.watch(serverConnectRoutesProvider); + + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + ServiceUtils.checkForUpdates(context, ref); + }); + + StreamSubscription? audioPlayerSubscription; + bool pausedByStream = false; + + final subscriptions = [ + ConnectionCheckerService.instance.onConnectivityChanged + .listen((connected) async { + audioPlayerSubscription?.cancel(); + + /// Pausing or resuming based on connectivity to avoid MPV skipping + /// audio while retrying to connect + if (audioPlayer.currentIndex >= 0) { + if (connected && audioPlayer.isPaused && pausedByStream) { + await audioPlayer.resume(); + pausedByStream = false; + } else if (!connected && audioPlayer.isPlaying) { + if ((audioPlayer.bufferedPosition - const Duration(seconds: 1)) <= + audioPlayer.position) { + await audioPlayer.pause(); + pausedByStream = true; + } else { + audioPlayerSubscription = + audioPlayer.positionStream.listen((position) async { + if (ConnectionCheckerService.instance.isConnectedSync) return; + + final bufferedPosition = + audioPlayer.bufferedPosition - const Duration(seconds: 1); + final duration = + audioPlayer.duration - const Duration(seconds: 1); + + if (bufferedPosition <= position || position >= duration) { + audioPlayer.pause(); + pausedByStream = true; + } + }); + } + } + } + + // Show notification for connection related issues + if (!context.mounted) return; + + showToast( + context: context, + location: ToastLocation.bottomCenter, + builder: (context, overlay) { + if (connected) { + return SurfaceCard( + child: Basic( + leading: const Icon(SpotubeIcons.wifi), + title: Text(context.l10n.connection_restored), + ), + ); + } + + return SurfaceCard( + fillColor: theme.colorScheme.destructive, + filled: true, + child: Basic( + leading: Icon( + SpotubeIcons.noWifi, + color: theme.colorScheme.destructiveForeground, + ), + trailing: Text( + context.l10n.you_are_offline, + style: TextStyle( + color: theme.colorScheme.destructiveForeground, + ), + ), + ), + ); + }, + ); + }), + connectRoutes.connectClientStream.listen((clientOrigin) { + if (!context.mounted) return; + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + fillColor: Colors.yellow[600], + filled: true, + child: Basic( + leading: const Icon( + SpotubeIcons.error, + color: Colors.black, + ), + title: Text( + context.l10n.connect_client_alert(clientOrigin), + style: const TextStyle(color: Colors.black), + ), + ), + ); + }, + ); + }) + ]; + + return () { + for (final subscription in subscriptions) { + subscription.cancel(); + } + }; + }, []); +} diff --git a/lib/modules/settings/color_scheme_picker_dialog.dart b/lib/modules/settings/color_scheme_picker_dialog.dart index f2933505..8092f825 100644 --- a/lib/modules/settings/color_scheme_picker_dialog.dart +++ b/lib/modules/settings/color_scheme_picker_dialog.dart @@ -1,10 +1,11 @@ -import 'package:flutter/material.dart'; +import 'package:collection/collection.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:system_theme/system_theme.dart'; class SpotubeColor extends Color { final String name; @@ -25,23 +26,33 @@ class SpotubeColor extends Color { } final Set colorsMap = { - SpotubeColor(SystemTheme.accentColor.accent.value, name: "System"), - SpotubeColor(Colors.red.value, name: "Red"), - SpotubeColor(Colors.pink.value, name: "Pink"), - SpotubeColor(Colors.purple.value, name: "Purple"), - SpotubeColor(Colors.deepPurple.value, name: "DeepPurple"), - SpotubeColor(Colors.indigo.value, name: "Indigo"), - SpotubeColor(Colors.blue.value, name: "Blue"), - SpotubeColor(Colors.lightBlue.value, name: "LightBlue"), - SpotubeColor(Colors.cyan.value, name: "Cyan"), - SpotubeColor(Colors.teal.value, name: "Teal"), - SpotubeColor(Colors.green.value, name: "Green"), - SpotubeColor(Colors.lightGreen.value, name: "LightGreen"), - SpotubeColor(Colors.yellow.value, name: "Yellow"), - SpotubeColor(Colors.amber.value, name: "Amber"), - SpotubeColor(Colors.orange.value, name: "Orange"), - SpotubeColor(Colors.deepOrange.value, name: "DeepOrange"), - SpotubeColor(Colors.brown.value, name: "Brown"), + SpotubeColor(Colors.slate.value, name: "slate"), + SpotubeColor(Colors.gray.value, name: "gray"), + SpotubeColor(Colors.zinc.value, name: "zinc"), + SpotubeColor(Colors.neutral.value, name: "neutral"), + SpotubeColor(Colors.stone.value, name: "stone"), + SpotubeColor(Colors.red.value, name: "red"), + SpotubeColor(Colors.orange.value, name: "orange"), + SpotubeColor(Colors.yellow.value, name: "yellow"), + SpotubeColor(Colors.green.value, name: "green"), + SpotubeColor(Colors.blue.value, name: "blue"), + SpotubeColor(Colors.violet.value, name: "violet"), + SpotubeColor(Colors.rose.value, name: "rose"), +}; + +final colorSchemeMap = { + "slate": ColorSchemes.slate, + "gray": ColorSchemes.gray, + "zinc": ColorSchemes.zinc, + "neutral": ColorSchemes.neutral, + "stone": ColorSchemes.stone, + "red": ColorSchemes.red, + "orange": ColorSchemes.orange, + "yellow": ColorSchemes.yellow, + "green": ColorSchemes.green, + "blue": ColorSchemes.blue, + "violet": ColorSchemes.violet, + "rose": ColorSchemes.rose, }; class ColorSchemePickerDialog extends HookConsumerWidget { @@ -51,180 +62,93 @@ class ColorSchemePickerDialog extends HookConsumerWidget { Widget build(BuildContext context, ref) { final preferences = ref.watch(userPreferencesProvider); final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); - final scheme = preferences.accentColorScheme; - final active = useState(colorsMap.firstWhere( - (element) { - return scheme.name == element.name; - }, - ).name); - onOk() { - preferencesNotifier.setAccentColorScheme( - colorsMap.firstWhere( - (element) { - return element.name == active.value; - }, - ), - ); - Navigator.pop(context); - } + final scheme = preferences.accentColorScheme; + final active = useState( + colorsMap.firstWhereOrNull( + (element) { + return scheme.name == element.name; + }, + )?.name, + ); return AlertDialog( - title: Text(context.l10n.pick_color_scheme), + title: Text( + context.l10n.pick_color_scheme, + style: TextStyle(color: context.theme.colorScheme.foreground), + ).large(), actions: [ - OutlinedButton( + Button.outline( child: Text(context.l10n.cancel), onPressed: () { Navigator.pop(context); }, ), - FilledButton( - onPressed: onOk, + Button.primary( + onPressed: () { + Navigator.pop(context); + }, child: Text(context.l10n.save), ), ], content: SizedBox( height: 200, width: 400, - child: ListView.separated( - separatorBuilder: (context, index) { - return const SizedBox(height: 10); - }, - itemCount: colorsMap.length, - itemBuilder: (context, index) { - final color = colorsMap.elementAt(index); - return ColorTile( - color: color, - isActive: active.value == color.name, - onPressed: () { - active.value = color.name; - }, - tooltip: color.name, - ); - }, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: colorsMap.map( + (color) { + return ColorChip( + name: color.name, + color: color, + isActive: color.name == active.value, + onPressed: () { + active.value = color.name; + preferencesNotifier.setAccentColorScheme( + colorsMap.firstWhere( + (element) { + return element.name == color.name; + }, + ), + ); + }, + ); + }, + ).toList(), ), ), ); } } -class ColorTile extends StatelessWidget { +class ColorChip extends StatelessWidget { + final String name; final Color color; final bool isActive; - final void Function()? onPressed; - final String? tooltip; - final bool isCompact; - const ColorTile({ - required this.color, - this.isActive = false, - this.onPressed, - this.tooltip = "", - this.isCompact = false, + final VoidCallback onPressed; + const ColorChip({ super.key, + required this.name, + required this.color, + required this.isActive, + required this.onPressed, }); - factory ColorTile.compact({ - required Color color, - bool isActive = false, - void Function()? onPressed, - String? tooltip = "", - Key? key, - }) { - return ColorTile( - color: color, - isActive: isActive, - onPressed: onPressed, - tooltip: tooltip, - isCompact: true, - key: key, - ); - } - @override Widget build(BuildContext context) { - final theme = Theme.of(context); - - final lead = Container( - height: 40, - width: 40, - decoration: BoxDecoration( - border: isActive - ? Border.fromBorderSide( - BorderSide( - color: Color.lerp( - theme.colorScheme.primary, - theme.colorScheme.onPrimary, - 0.5, - )!, - width: 4, - ), - ) - : null, - borderRadius: BorderRadius.circular(15), - color: color, - ), - ); - - if (isCompact) { - return GestureDetector( - onTap: onPressed, - child: lead, - ); - } - - final colorScheme = ColorScheme.fromSeed(seedColor: color); - - final palette = [ - colorScheme.primary, - colorScheme.inversePrimary, - colorScheme.primaryContainer, - colorScheme.secondary, - colorScheme.secondaryContainer, - colorScheme.surface, - colorScheme.surface, - colorScheme.surfaceContainerHighest, - colorScheme.onPrimary, - colorScheme.onSurface, - ]; - - return GestureDetector( - onTap: onPressed, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - lead, - const SizedBox(width: 10), - Text( - tooltip!, - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - const SizedBox(height: 10), - Wrap( - alignment: WrapAlignment.start, - spacing: 10, - runSpacing: 10, - children: [ - ...palette.map( - (e) => Container( - height: 20, - width: 20, - decoration: BoxDecoration( - color: e, - borderRadius: BorderRadius.circular(5), - ), - ), - ), - ], - ), - ], + return Chip( + leading: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(10), + ), ), + onPressed: onPressed, + style: isActive ? ButtonVariance.primary : ButtonVariance.outline, + child: Text(name), ); } } diff --git a/lib/modules/settings/section_card_with_heading.dart b/lib/modules/settings/section_card_with_heading.dart index 87060579..c7bc1f26 100644 --- a/lib/modules/settings/section_card_with_heading.dart +++ b/lib/modules/settings/section_card_with_heading.dart @@ -1,4 +1,6 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show ListTileTheme, ListTileThemeData; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Theme, ThemeData; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; class SectionCardWithHeading extends StatelessWidget { final String heading; @@ -11,27 +13,43 @@ class SectionCardWithHeading extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Text( - heading, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + return ListTileTheme( + data: ListTileThemeData( + shape: RoundedRectangleBorder( + borderRadius: context.theme.borderRadiusLg, + side: BorderSide( + color: context.theme.colorScheme.border, + width: .5, ), ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Card( - clipBehavior: Clip.antiAliasWithSaveLayer, - child: Column(mainAxisSize: MainAxisSize.min, children: children), + textColor: context.theme.colorScheme.foreground, + iconColor: context.theme.colorScheme.foreground, + selectedColor: context.theme.colorScheme.accent, + subtitleTextStyle: context.theme.typography.xSmall, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Text( + heading, + style: context.theme.typography.large.copyWith( + color: context.theme.colorScheme.foreground, + ), + ), ), - ), - ], + Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ).gap(8.0), + ), + ], + ), ); } } diff --git a/lib/modules/settings/youtube_engine_not_installed_dialog.dart b/lib/modules/settings/youtube_engine_not_installed_dialog.dart new file mode 100644 index 00000000..b993dd1b --- /dev/null +++ b/lib/modules/settings/youtube_engine_not_installed_dialog.dart @@ -0,0 +1,122 @@ +import 'dart:io'; + +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/form/text_form_field.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:yt_dlp_dart/yt_dlp_dart.dart'; + +const engineDownloadUrls = { + YoutubeClientEngine.ytDlp: + "https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#installation", +}; + +class YouTubeEngineNotInstalledDialog extends HookConsumerWidget { + final YoutubeClientEngine engine; + const YouTubeEngineNotInstalledDialog({ + super.key, + required this.engine, + }); + + @override + Widget build(BuildContext context, ref) { + final controller = useShadcnTextEditingController(); + final formKey = useMemoized(() => GlobalKey(), []); + + return AlertDialog( + title: Row( + spacing: 8, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(SpotubeIcons.error, color: Colors.red), + Text( + context.l10n.youtube_engine_not_installed_title(engine.label), + style: const TextStyle(color: Colors.red), + ), + ], + ), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + context.l10n.youtube_engine_not_installed_message(engine.label), + ), + if (engineDownloadUrls[engine] != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text("${context.l10n.download}:"), + Button.link( + child: Text(engineDownloadUrls[engine]!.split("?").first), + onPressed: () async { + launchUrl(Uri.parse(engineDownloadUrls[engine]!)); + }, + ), + ], + ), + Text(context.l10n.youtube_engine_set_path(engine.label)), + const Gap(8), + FormBuilder( + key: formKey, + child: TextFormBuilderField( + name: "path", + controller: controller, + placeholder: Text(switch (context.theme.platform) { + TargetPlatform.macOS => "e.g. /opt/homebrew/bin/yt-dlp", + TargetPlatform.windows => + r"e.g. C:\Program Files\yt-dlp\yt-dlp.exe", + _ => "e.g. /home/user/.local/bin/yt-dlp", + }), + ), + ), + if (kIsMacOS || kIsLinux) + Text(context.l10n.youtube_engine_unix_issue_message), + ], + ), + ), + actions: [ + Button.text( + onPressed: () { + if (!context.mounted) return; + Navigator.of(context).pop(false); + }, + child: Text(context.l10n.cancel), + ), + Button.secondary( + onPressed: () async { + if (controller.text.isNotEmpty) { + if (!await File(controller.text).exists() && context.mounted) { + formKey.currentState?.fields["path"] + ?.invalidate(context.l10n.file_not_found); + return; + } + await KVStoreService.setYoutubeEnginePath( + engine, + controller.text, + ); + if (engine == YoutubeClientEngine.ytDlp) { + await YtDlp.instance.setBinaryLocation(controller.text); + } + } + if (!context.mounted) return; + Navigator.of(context).pop(true); + }, + child: Text(context.l10n.save), + ), + ], + ); + } +} diff --git a/lib/modules/stats/common/album_item.dart b/lib/modules/stats/common/album_item.dart index eec68717..cd0a6caf 100644 --- a/lib/modules/stats/common/album_item.dart +++ b/lib/modules/stats/common/album_item.dart @@ -1,11 +1,12 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/modules/album/album_card.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/links/artist_link.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/pages/album/album.dart'; -import 'package:spotube/utils/service_utils.dart'; class StatsAlbumItem extends StatelessWidget { final AlbumSimple album; @@ -14,8 +15,8 @@ class StatsAlbumItem extends StatelessWidget { @override Widget build(BuildContext context) { - return ListTile( - horizontalTitleGap: 8, + return ButtonTile( + style: ButtonVariance.ghost, leading: ClipRRect( borderRadius: BorderRadius.circular(4), child: UniversalImage( @@ -35,25 +36,15 @@ class StatsAlbumItem extends StatelessWidget { child: ArtistLink( artists: album.artists ?? [], mainAxisAlignment: WrapAlignment.start, - onOverflowArtistClick: () => ServiceUtils.pushNamed( - context, - AlbumPage.name, - pathParameters: { - "id": album.id!, - }, - ), + onOverflowArtistClick: () => + context.navigateTo(AlbumRoute(id: album.id!, album: album)), ), ), ], ), trailing: info, - onTap: () { - ServiceUtils.pushNamed( - context, - AlbumPage.name, - pathParameters: {"id": album.id!}, - extra: album, - ); + onPressed: () { + context.navigateTo(AlbumRoute(id: album.id!, album: album)); }, ); } diff --git a/lib/modules/stats/common/artist_item.dart b/lib/modules/stats/common/artist_item.dart index 7e7281da..5eff9a9d 100644 --- a/lib/modules/stats/common/artist_item.dart +++ b/lib/modules/stats/common/artist_item.dart @@ -1,9 +1,10 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/pages/artist/artist.dart'; -import 'package:spotube/utils/service_utils.dart'; class StatsArtistItem extends StatelessWidget { final Artist artist; @@ -16,23 +17,20 @@ class StatsArtistItem extends StatelessWidget { @override Widget build(BuildContext context) { - return ListTile( + return ButtonTile( + style: ButtonVariance.ghost, title: Text(artist.name!), - horizontalTitleGap: 8, - leading: CircleAvatar( - backgroundImage: UniversalImage.imageProvider( + leading: Avatar( + initials: artist.name!.substring(0, 1), + provider: UniversalImage.imageProvider( (artist.images).asUrlString( placeholder: ImagePlaceholder.artist, ), ), ), trailing: info, - onTap: () { - ServiceUtils.pushNamed( - context, - ArtistPage.name, - pathParameters: {"id": artist.id!}, - ); + onPressed: () { + context.navigateTo(ArtistRoute(artistId: artist.id!)); }, ); } diff --git a/lib/modules/stats/common/playlist_item.dart b/lib/modules/stats/common/playlist_item.dart index 515c97b3..58610af1 100644 --- a/lib/modules/stats/common/playlist_item.dart +++ b/lib/modules/stats/common/playlist_item.dart @@ -1,10 +1,11 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/string.dart'; -import 'package:spotube/pages/playlist/playlist.dart'; -import 'package:spotube/utils/service_utils.dart'; class StatsPlaylistItem extends StatelessWidget { final PlaylistSimple playlist; @@ -14,8 +15,8 @@ class StatsPlaylistItem extends StatelessWidget { @override Widget build(BuildContext context) { - return ListTile( - horizontalTitleGap: 8, + return ButtonTile( + style: ButtonVariance.ghost, leading: ClipRRect( borderRadius: BorderRadius.circular(4), child: UniversalImage( @@ -33,13 +34,8 @@ class StatsPlaylistItem extends StatelessWidget { overflow: TextOverflow.ellipsis, ), trailing: info, - onTap: () { - ServiceUtils.pushNamed( - context, - PlaylistPage.name, - pathParameters: {"id": playlist.id!}, - extra: playlist, - ); + onPressed: () { + context.navigateTo(PlaylistRoute(id: playlist.id!, playlist: playlist)); }, ); } diff --git a/lib/modules/stats/common/track_item.dart b/lib/modules/stats/common/track_item.dart index 44e81340..ae2e22c6 100644 --- a/lib/modules/stats/common/track_item.dart +++ b/lib/modules/stats/common/track_item.dart @@ -1,10 +1,11 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/links/artist_link.dart'; +import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/pages/track/track.dart'; -import 'package:spotube/utils/service_utils.dart'; class StatsTrackItem extends StatelessWidget { final Track track; @@ -17,8 +18,8 @@ class StatsTrackItem extends StatelessWidget { @override Widget build(BuildContext context) { - return ListTile( - horizontalTitleGap: 8, + return ButtonTile( + style: ButtonVariance.ghost, leading: ClipRRect( borderRadius: BorderRadius.circular(4), child: UniversalImage( @@ -33,23 +34,13 @@ class StatsTrackItem extends StatelessWidget { subtitle: ArtistLink( artists: track.artists!, mainAxisAlignment: WrapAlignment.start, - onOverflowArtistClick: () => ServiceUtils.pushNamed( - context, - TrackPage.name, - pathParameters: { - "id": track.id!, - }, - ), + onOverflowArtistClick: () { + context.navigateTo(TrackRoute(trackId: track.id!)); + }, ), trailing: info, - onTap: () { - ServiceUtils.pushNamed( - context, - TrackPage.name, - pathParameters: { - "id": track.id!, - }, - ); + onPressed: () { + context.navigateTo(TrackRoute(trackId: track.id!)); }, ); } diff --git a/lib/modules/stats/summary/summary.dart b/lib/modules/stats/summary/summary.dart index 46068fec..30e68b1f 100644 --- a/lib/modules/stats/summary/summary.dart +++ b/lib/modules/stats/summary/summary.dart @@ -1,19 +1,14 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/modules/stats/summary/summary_card.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/stats/albums/albums.dart'; -import 'package:spotube/pages/stats/artists/artists.dart'; -import 'package:spotube/pages/stats/fees/fees.dart'; -import 'package:spotube/pages/stats/minutes/minutes.dart'; -import 'package:spotube/pages/stats/playlists/playlists.dart'; -import 'package:spotube/pages/stats/streams/streams.dart'; import 'package:spotube/provider/history/summary.dart'; -import 'package:spotube/utils/service_utils.dart'; class StatsPageSummarySection extends HookConsumerWidget { const StatsPageSummarySection({super.key}); @@ -48,18 +43,18 @@ class StatsPageSummarySection extends HookConsumerWidget { title: summaryData.duration.inMinutes.toDouble(), unit: context.l10n.summary_minutes, description: context.l10n.summary_listened_to_music, - color: Colors.purple, + color: Colors.indigo, onTap: () { - ServiceUtils.pushNamed(context, StatsMinutesPage.name); + context.navigateTo(const StatsMinutesRoute()); }, ), SummaryCard( title: summaryData.tracks.toDouble(), unit: context.l10n.summary_songs, description: context.l10n.summary_streamed_overall, - color: Colors.lightBlue, + color: Colors.blue, onTap: () { - ServiceUtils.pushNamed(context, StatsStreamsPage.name); + context.navigateTo(const StatsStreamsRoute()); }, ), SummaryCard.unformatted( @@ -68,7 +63,7 @@ class StatsPageSummarySection extends HookConsumerWidget { description: context.l10n.summary_owed_to_artists, color: Colors.green, onTap: () { - ServiceUtils.pushNamed(context, StatsStreamFeesPage.name); + context.navigateTo(const StatsStreamFeesRoute()); }, ), SummaryCard( @@ -77,7 +72,7 @@ class StatsPageSummarySection extends HookConsumerWidget { description: context.l10n.summary_music_reached_you, color: Colors.yellow, onTap: () { - ServiceUtils.pushNamed(context, StatsArtistsPage.name); + context.navigateTo(const StatsArtistsRoute()); }, ), SummaryCard( @@ -86,7 +81,7 @@ class StatsPageSummarySection extends HookConsumerWidget { description: context.l10n.summary_got_your_love, color: Colors.pink, onTap: () { - ServiceUtils.pushNamed(context, StatsAlbumsPage.name); + context.navigateTo(const StatsAlbumsRoute()); }, ), SummaryCard( @@ -95,7 +90,7 @@ class StatsPageSummarySection extends HookConsumerWidget { description: context.l10n.summary_were_on_repeat, color: Colors.teal, onTap: () { - ServiceUtils.pushNamed(context, StatsPlaylistsPage.name); + context.navigateTo(const StatsPlaylistsRoute()); }, ), ]), diff --git a/lib/modules/stats/summary/summary_card.dart b/lib/modules/stats/summary/summary_card.dart index 243c50e8..e78dd080 100644 --- a/lib/modules/stats/summary/summary_card.dart +++ b/lib/modules/stats/summary/summary_card.dart @@ -1,6 +1,7 @@ import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/collections/formatters.dart'; class SummaryCard extends StatelessWidget { @@ -9,7 +10,7 @@ class SummaryCard extends StatelessWidget { final String description; final VoidCallback? onTap; - final MaterialColor color; + final ColorShades color; SummaryCard({ super.key, @@ -31,15 +32,18 @@ class SummaryCard extends StatelessWidget { @override Widget build(BuildContext context) { - final ThemeData(:textTheme, :brightness) = Theme.of(context); + final ThemeData(:typography, :brightness) = Theme.of(context); final descriptionNewLines = description.split("").where((s) => s == "\n"); return Card( - color: brightness == Brightness.dark ? color.shade100 : color.shade50, - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: onTap, + fillColor: brightness == Brightness.dark ? color.shade100 : color.shade50, + filled: true, + borderColor: color, + padding: EdgeInsets.zero, + borderRadius: context.theme.borderRadiusLg, + child: Button.ghost( + onPressed: onTap, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 15), child: Column( @@ -52,13 +56,13 @@ class SummaryCard extends StatelessWidget { children: [ TextSpan( text: title, - style: textTheme.headlineLarge?.copyWith( + style: typography.h2.copyWith( color: color.shade900, ), ), TextSpan( text: " $unit", - style: textTheme.titleMedium?.copyWith( + style: typography.semiBold.copyWith( color: color.shade900, ), ), @@ -73,7 +77,7 @@ class SummaryCard extends StatelessWidget { ? descriptionNewLines.length + 1 : 1, minFontSize: 9, - style: textTheme.labelMedium!.copyWith( + style: typography.small.copyWith( color: color.shade900, ), ), diff --git a/lib/modules/stats/top/albums.dart b/lib/modules/stats/top/albums.dart index e401340e..09bf755c 100644 --- a/lib/modules/stats/top/albums.dart +++ b/lib/modules/stats/top/albums.dart @@ -1,5 +1,7 @@ -import 'package:flutter/material.dart'; +import 'package:flutter_undraw/flutter_undraw.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/modules/stats/common/album_item.dart'; @@ -31,6 +33,24 @@ class TopAlbums extends HookConsumerWidget { isLoading: topAlbums.isLoading && !topAlbums.isLoadingNextPage, hasReachedMax: topAlbums.asData?.value.hasMore ?? true, itemCount: albumsData.length, + emptyBuilder: (context) => Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Gap(50), + Undraw( + illustration: UndrawIllustration.happyMusic, + color: context.theme.colorScheme.primary, + height: 200 * context.theme.scaling, + ), + Text( + context.l10n.no_tracks_listened_yet, + textAlign: TextAlign.center, + ).muted().small(), + ], + ), + ), itemBuilder: (context, index) { final album = albumsData[index]; return StatsAlbumItem( diff --git a/lib/modules/stats/top/artists.dart b/lib/modules/stats/top/artists.dart index 3e4e098d..c53c34fd 100644 --- a/lib/modules/stats/top/artists.dart +++ b/lib/modules/stats/top/artists.dart @@ -1,6 +1,8 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_undraw/flutter_undraw.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/modules/stats/common/artist_item.dart'; @@ -35,6 +37,24 @@ class TopArtists extends HookConsumerWidget { isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, hasReachedMax: topTracks.asData?.value.hasMore ?? true, itemCount: artistsData.length, + emptyBuilder: (context) => Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Gap(50), + Undraw( + illustration: UndrawIllustration.happyMusic, + color: context.theme.colorScheme.primary, + height: 200 * context.theme.scaling, + ), + Text( + context.l10n.no_tracks_listened_yet, + textAlign: TextAlign.center, + ).muted().small(), + ], + ), + ), itemBuilder: (context, index) { final artist = artistsData[index]; return StatsArtistItem( diff --git a/lib/modules/stats/top/top.dart b/lib/modules/stats/top/top.dart index 643064aa..38f04ccb 100644 --- a/lib/modules/stats/top/top.dart +++ b/lib/modules/stats/top/top.dart @@ -1,7 +1,8 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/themed_button_tab_bar.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/modules/stats/top/albums.dart'; import 'package:spotube/modules/stats/top/artists.dart'; import 'package:spotube/modules/stats/top/tracks.dart'; @@ -14,94 +15,97 @@ class StatsPageTopSection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final tabController = useTabController(initialLength: 3); + final selectedIndex = useState(0); final historyDuration = ref.watch(playbackHistoryTopDurationProvider); final historyDurationNotifier = ref.watch(playbackHistoryTopDurationProvider.notifier); - return SliverMainAxisGroup( - slivers: [ - SliverAppBar( - floating: true, - flexibleSpace: ThemedButtonsTabBar( - controller: tabController, - tabs: [ - Tab( - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(context.l10n.top_tracks), - ), - ), - Tab( - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(context.l10n.top_artists), - ), - ), - Tab( - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(context.l10n.top_albums), - ), - ), - ], - ), - ), - SliverToBoxAdapter( - child: Align( - alignment: Alignment.centerRight, - child: DropdownButton( - style: Theme.of(context).textTheme.bodySmall!, - isDense: true, - padding: const EdgeInsets.all(4), - borderRadius: BorderRadius.circular(4), - underline: const SizedBox(), - value: historyDuration, - onChanged: (value) { - if (value == null) return; - historyDurationNotifier.update((_) => value); + final translations = { + HistoryDuration.days7: context.l10n.this_week, + HistoryDuration.days30: context.l10n.this_month, + HistoryDuration.months6: context.l10n.last_6_months, + HistoryDuration.year: context.l10n.this_year, + HistoryDuration.years2: context.l10n.last_2_years, + HistoryDuration.allTime: context.l10n.all_time, + }; + + final dropdown = Select( + popupConstraints: const BoxConstraints(maxWidth: 150), + popupWidthConstraint: PopoverConstraint.flexible, + padding: const EdgeInsets.all(4), + borderRadius: BorderRadius.circular(4), + value: historyDuration, + onChanged: (value) { + if (value == null) return; + historyDurationNotifier.update((_) => value); + }, + itemBuilder: (context, item) => Text(translations[item]!), + popup: (context) { + return SelectPopup( + items: SelectItemBuilder( + childCount: HistoryDuration.values.length, + builder: (context, index) { + final item = HistoryDuration.values[index]; + return SelectItemButton( + value: item, + child: Text(translations[item]!), + ); }, - icon: const Icon(Icons.arrow_drop_down), - items: [ - DropdownMenuItem( - value: HistoryDuration.days7, - child: Text(context.l10n.this_week), - ), - DropdownMenuItem( - value: HistoryDuration.days30, - child: Text(context.l10n.this_month), - ), - DropdownMenuItem( - value: HistoryDuration.months6, - child: Text(context.l10n.last_6_months), - ), - DropdownMenuItem( - value: HistoryDuration.year, - child: Text(context.l10n.this_year), - ), - DropdownMenuItem( - value: HistoryDuration.years2, - child: Text(context.l10n.last_2_years), - ), - DropdownMenuItem( - value: HistoryDuration.allTime, - child: Text(context.l10n.all_time), - ), - ], + ), + ); + }); + + return SliverLayoutBuilder(builder: (context, constraints) { + return SliverMainAxisGroup( + slivers: [ + SliverAppBar( + floating: true, + elevation: 0, + backgroundColor: context.theme.colorScheme.background, + automaticallyImplyLeading: false, + flexibleSpace: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + TabList( + index: selectedIndex.value, + onChanged: (value) { + selectedIndex.value = value; + }, + children: [ + TabItem( + child: Text(context.l10n.top_tracks), + ), + TabItem( + child: Text(context.l10n.top_artists), + ), + TabItem( + child: Text(context.l10n.top_albums), + ), + ], + ), + if (constraints.mdAndUp) ...[ + const Spacer(), + dropdown, + ] + ], + ), ), ), - ), - ListenableBuilder( - listenable: tabController, - builder: (context, _) { - return switch (tabController.index) { - 1 => const TopArtists(), - 2 => const TopAlbums(), - _ => const TopTracks(), - }; + if (constraints.smAndDown) + SliverToBoxAdapter( + child: Align( + alignment: Alignment.centerRight, + child: dropdown, + ), + ), + switch (selectedIndex.value) { + 1 => const TopArtists(), + 2 => const TopAlbums(), + _ => const TopTracks(), }, - ), - ], - ); + ], + ); + }); } } diff --git a/lib/modules/stats/top/tracks.dart b/lib/modules/stats/top/tracks.dart index 7fba220d..c4015431 100644 --- a/lib/modules/stats/top/tracks.dart +++ b/lib/modules/stats/top/tracks.dart @@ -1,5 +1,7 @@ -import 'package:flutter/material.dart'; +import 'package:flutter_undraw/flutter_undraw.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/modules/stats/common/track_item.dart'; @@ -33,6 +35,24 @@ class TopTracks extends HookConsumerWidget { isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, hasReachedMax: topTracks.asData?.value.hasMore ?? true, itemCount: tracksData.length, + emptyBuilder: (context) => Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Gap(50), + Undraw( + illustration: UndrawIllustration.happyMusic, + color: context.theme.colorScheme.primary, + height: 200 * context.theme.scaling, + ), + Text( + context.l10n.no_tracks_listened_yet, + textAlign: TextAlign.center, + ).muted().small(), + ], + ), + ), itemBuilder: (context, index) { final track = tracksData[index]; return StatsTrackItem( diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index 0c6cfd69..5773f9fa 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -1,18 +1,23 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' as material; +import 'package:auto_route/auto_route.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/tracks_view/track_view.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; +import 'package:spotube/components/track_presentation/track_presentation.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; +@RoutePage() class AlbumPage extends HookConsumerWidget { static const name = "album"; final AlbumSimple album; + final String id; const AlbumPage({ super.key, + @PathParam("id") required this.id, required this.album, }); @@ -23,43 +28,52 @@ class AlbumPage extends HookConsumerWidget { final favoriteAlbumsNotifier = ref.watch(favoriteAlbumsProvider.notifier); final isSavedAlbum = ref.watch(albumsIsSavedProvider(album.id!)); - return InheritedTrackView( - collection: album, - image: album.images.asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - title: album.name!, - description: - "${context.l10n.released} • ${album.releaseDate} • ${album.artists!.first.name}", - tracks: tracks.asData?.value.items ?? [], - pagination: PaginationProps( - hasNextPage: tracks.asData?.value.hasMore ?? false, - isLoading: tracks.isLoadingNextPage, - onFetchMore: () async { - await tracksNotifier.fetchMore(); - }, - onFetchAll: () async { - return tracksNotifier.fetchAll(); - }, - onRefresh: () async { - ref.invalidate(albumTracksProvider(album)); - }, - ), - routePath: "/album/${album.id}", - shareUrl: album.externalUrls?.spotify ?? - "https://open.spotify.com/album/${album.id}", - isLiked: isSavedAlbum.asData?.value ?? false, - onHeart: isSavedAlbum.asData?.value == null - ? null - : () async { - if (isSavedAlbum.asData!.value) { - await favoriteAlbumsNotifier.removeFavorites([album.id!]); - } else { - await favoriteAlbumsNotifier.addFavorites([album.id!]); - } - return null; + return material.RefreshIndicator.adaptive( + onRefresh: () async { + ref.invalidate(albumTracksProvider(album)); + ref.invalidate(favoriteAlbumsProvider); + ref.invalidate(albumsIsSavedProvider(album.id!)); + }, + child: TrackPresentation( + options: TrackPresentationOptions( + collection: album, + image: album.images.asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + title: album.name!, + description: + "${context.l10n.released} • ${album.releaseDate} • ${album.artists!.first.name}", + tracks: tracks.asData?.value.items ?? [], + pagination: PaginationProps( + hasNextPage: tracks.asData?.value.hasMore ?? false, + isLoading: tracks.isLoading || tracks.isLoadingNextPage, + onFetchMore: () async { + await tracksNotifier.fetchMore(); }, - child: const TrackView(), + onFetchAll: () async { + return tracksNotifier.fetchAll(); + }, + onRefresh: () async { + ref.invalidate(albumTracksProvider(album)); + }, + ), + routePath: "/album/${album.id}", + shareUrl: album.externalUrls?.spotify ?? + "https://open.spotify.com/album/${album.id}", + isLiked: isSavedAlbum.asData?.value ?? false, + owner: album.artists!.first.name, + onHeart: isSavedAlbum.asData?.value == null + ? null + : () async { + if (isSavedAlbum.asData!.value) { + await favoriteAlbumsNotifier.removeFavorites([album.id!]); + } else { + await favoriteAlbumsNotifier.addFavorites([album.id!]); + } + return null; + }, + ), + ), ); } } diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index 70ad72de..2037174a 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -1,8 +1,9 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' as material; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotube/components/button/back_button.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/artist/artist_album_list.dart'; @@ -13,12 +14,17 @@ import 'package:spotube/pages/artist/section/header.dart'; import 'package:spotube/pages/artist/section/related_artists.dart'; import 'package:spotube/pages/artist/section/top_tracks.dart'; import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class ArtistPage extends HookConsumerWidget { static const name = "artist"; final String artistId; - const ArtistPage(this.artistId, {super.key}); + const ArtistPage( + @PathParam("id") this.artistId, { + super.key, + }); @override Widget build(BuildContext context, ref) { @@ -30,55 +36,66 @@ class ArtistPage extends HookConsumerWidget { return SafeArea( bottom: false, child: Scaffold( - appBar: const PageWindowTitleBar( - leading: BackButton(), - backgroundColor: Colors.transparent, - ), - extendBodyBehindAppBar: true, - body: Builder(builder: (context) { - if (artistQuery.hasError && artistQuery.asData?.value == null) { - return Center(child: Text(artistQuery.error.toString())); - } - return Skeletonizer( - enabled: artistQuery.isLoading, - child: CustomScrollView( - controller: scrollController, - slivers: [ - SliverToBoxAdapter( - child: SafeArea( - bottom: false, - child: ArtistPageHeader(artistId: artistId), - ), - ), - const SliverGap(50), - ArtistPageTopTracks(artistId: artistId), - const SliverGap(50), - SliverToBoxAdapter(child: ArtistAlbumList(artistId)), - const SliverGap(20), - SliverPadding( - padding: const EdgeInsets.all(8.0), - sliver: SliverToBoxAdapter( - child: Text( - context.l10n.fans_also_like, - style: theme.textTheme.headlineSmall, + headers: const [ + TitleBar( + leading: [BackButton()], + backgroundColor: Colors.transparent, + ) + ], + floatingHeader: true, + child: material.RefreshIndicator.adaptive( + onRefresh: () async { + ref.invalidate(artistProvider(artistId)); + ref.invalidate(relatedArtistsProvider(artistId)); + ref.invalidate(artistAlbumsProvider(artistId)); + ref.invalidate(artistIsFollowingProvider(artistId)); + ref.invalidate(artistTopTracksProvider(artistId)); + if (artistQuery.hasValue) { + ref.invalidate( + artistWikipediaSummaryProvider(artistQuery.asData!.value)); + } + }, + child: Builder(builder: (context) { + if (artistQuery.hasError && artistQuery.asData?.value == null) { + return Center(child: Text(artistQuery.error.toString())); + } + return Skeletonizer( + enabled: artistQuery.isLoading, + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverToBoxAdapter( + child: SafeArea( + bottom: false, + child: ArtistPageHeader(artistId: artistId), ), ), - ), - SliverSafeArea( - sliver: ArtistPageRelatedArtists(artistId: artistId), - ), - if (artistQuery.asData?.value != null) - SliverSafeArea( - top: false, + const SliverGap(20), + ArtistPageTopTracks(artistId: artistId), + const SliverGap(20), + SliverToBoxAdapter(child: ArtistAlbumList(artistId)), + SliverPadding( + padding: const EdgeInsets.all(8.0), sliver: SliverToBoxAdapter( + child: Text( + context.l10n.fans_also_like, + style: theme.typography.h4, + ), + ), + ), + ArtistPageRelatedArtists(artistId: artistId), + const SliverGap(20), + if (artistQuery.asData?.value != null) + SliverToBoxAdapter( child: ArtistPageFooter(artist: artistQuery.asData!.value), ), - ), - ], - ), - ); - }), + const SliverSafeArea(sliver: SliverGap(10)), + ], + ), + ); + }), + ), ), ); } diff --git a/lib/pages/artist/section/footer.dart b/lib/pages/artist/section/footer.dart index abe86410..0fe2ab68 100644 --- a/lib/pages/artist/section/footer.dart +++ b/lib/pages/artist/section/footer.dart @@ -1,5 +1,5 @@ import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -16,7 +16,7 @@ class ArtistPageFooter extends ConsumerWidget { @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme) = Theme.of(context); + final ThemeData(:typography) = Theme.of(context); final mediaQuery = MediaQuery.of(context); final artistImage = artist.images.asUrlString( @@ -26,7 +26,7 @@ class ArtistPageFooter extends ConsumerWidget { if (summary.asData?.value == null) return const SizedBox.shrink(); return Container( - margin: const EdgeInsets.all(16), + margin: const EdgeInsets.all(8), padding: mediaQuery.smAndDown ? const EdgeInsets.all(20) : const EdgeInsets.all(30), @@ -50,7 +50,7 @@ class ArtistPageFooter extends ConsumerWidget { alignment: Alignment.center, child: RichText( text: TextSpan( - style: textTheme.bodyLarge?.copyWith( + style: typography.semiBold.copyWith( color: Colors.white, ), children: [ @@ -64,7 +64,7 @@ class ArtistPageFooter extends ConsumerWidget { ), TextSpan( text: " Wikipedia", - style: textTheme.titleLarge?.copyWith( + style: typography.large.copyWith( color: Colors.white, ), ), @@ -74,10 +74,10 @@ class ArtistPageFooter extends ConsumerWidget { ), TextSpan( text: '\n...read more at wikipedia', - style: textTheme.bodyLarge?.copyWith( - color: Colors.lightBlue[300], + style: typography.semiBold.copyWith( + color: Colors.sky[300], decoration: TextDecoration.underline, - decorationColor: Colors.lightBlue[300], + decorationColor: Colors.sky[300], ), recognizer: TapGestureRecognizer() ..onTap = () async { diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart index 713e0d26..b6224428 100644 --- a/lib/pages/artist/section/header.dart +++ b/lib/pages/artist/section/header.dart @@ -1,7 +1,7 @@ -import 'package:flutter/material.dart'; +import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/services.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -9,7 +9,6 @@ import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/blacklist_provider.dart'; @@ -25,19 +24,8 @@ class ArtistPageHeader extends HookConsumerWidget { final artistQuery = ref.watch(artistProvider(artistId)); final artist = artistQuery.asData?.value ?? FakeData.artist; - final scaffoldMessenger = ScaffoldMessenger.of(context); - final mediaQuery = MediaQuery.of(context); final theme = Theme.of(context); - final ThemeData(:textTheme) = theme; - - final chipTextVariant = useBreakpointValue( - xs: textTheme.bodySmall, - sm: textTheme.bodySmall, - md: textTheme.bodyMedium, - lg: textTheme.bodyLarge, - xl: textTheme.titleSmall, - xxl: textTheme.titleMedium, - ); + final ThemeData(:typography) = theme; final auth = ref.watch(authenticationProvider); ref.watch(blacklistProvider); @@ -48,190 +36,192 @@ class ArtistPageHeader extends HookConsumerWidget { placeholder: ImagePlaceholder.artist, ); + final actions = Skeleton.keep( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (auth.asData?.value != null) + Consumer( + builder: (context, ref, _) { + final isFollowingQuery = ref.watch( + artistIsFollowingProvider(artist.id!), + ); + final followingArtistNotifier = ref.watch( + followedArtistsProvider.notifier, + ); + + return switch (isFollowingQuery) { + AsyncData(value: final following) => Builder( + builder: (context) { + if (following) { + return Button.outline( + onPressed: () async { + await followingArtistNotifier + .removeArtists([artist.id!]); + }, + child: Text(context.l10n.following), + ); + } + + return Button.primary( + onPressed: () async { + await followingArtistNotifier + .saveArtists([artist.id!]); + }, + child: Text(context.l10n.follow), + ); + }, + ), + AsyncError() => const SizedBox(), + _ => const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(), + ) + }; + }, + ), + const SizedBox(width: 5), + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.add_artist_to_blacklist), + ), + child: IconButton( + icon: Icon( + SpotubeIcons.userRemove, + color: !isBlackListed ? Colors.red[400] : null, + ), + variance: isBlackListed + ? ButtonVariance.destructive + : ButtonVariance.ghost, + onPressed: () async { + if (isBlackListed) { + await ref.read(blacklistProvider.notifier).remove(artist.id!); + } else { + await ref.read(blacklistProvider.notifier).add( + BlacklistTableCompanion.insert( + name: artist.name!, + elementId: artist.id!, + elementType: BlacklistedType.artist, + ), + ); + } + }, + ), + ), + IconButton.ghost( + icon: const Icon(SpotubeIcons.share), + onPressed: () async { + if (artist.externalUrls?.spotify != null) { + await Clipboard.setData( + ClipboardData( + text: artist.externalUrls!.spotify!, + ), + ); + } + + if (!context.mounted) return; + + showToast( + context: context, + location: ToastLocation.topRight, + dismissible: true, + builder: (context, overlay) { + return SurfaceCard( + child: Text( + context.l10n.artist_url_copied, + textAlign: TextAlign.center, + ), + ); + }, + ); + }, + ) + ], + ), + ); + return LayoutBuilder( builder: (context, constrains) { - return Center( - child: Flex( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: constrains.smAndDown - ? CrossAxisAlignment.start - : CrossAxisAlignment.center, - direction: constrains.smAndDown ? Axis.vertical : Axis.horizontal, - children: [ - DecoratedBox( - decoration: BoxDecoration( - boxShadow: kElevationToShadow[2], - borderRadius: BorderRadius.circular(35), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(35), - child: UniversalImage( - path: image, - width: 250, - height: 250, - fit: BoxFit.cover, - ), - ), - ), - const Gap(20), - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.blue, - borderRadius: BorderRadius.circular(50)), - child: Skeleton.keep( - child: Text( - artist.type!.toUpperCase(), - style: chipTextVariant.copyWith( - color: Colors.white, - ), - ), - ), - ), - if (isBlackListed) ...[ - const SizedBox(width: 5), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.red[400], - borderRadius: BorderRadius.circular(50)), - child: Text( - context.l10n.blacklisted, - style: chipTextVariant.copyWith( - color: Colors.white, - ), - ), - ), - ] - ], - ), - Text( - artist.name!, - style: mediaQuery.smAndDown - ? textTheme.headlineSmall - : textTheme.headlineMedium, - ), - Text( - context.l10n.followers( - PrimitiveUtils.toReadableNumber( - artist.followers!.total!.toDouble(), + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: theme.borderRadiusXl, + child: UniversalImage( + path: image, + width: constrains.mdAndUp ? 200 : 120, + height: constrains.mdAndUp ? 200 : 120, + fit: BoxFit.cover, ), ), - style: textTheme.bodyMedium?.copyWith( - fontWeight: mediaQuery.mdAndUp ? FontWeight.bold : null, - ), - ), - const Gap(20), - Skeleton.keep( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (auth.asData?.value != null) - Consumer( - builder: (context, ref, _) { - final isFollowingQuery = ref - .watch(artistIsFollowingProvider(artist.id!)); - final followingArtistNotifier = - ref.watch(followedArtistsProvider.notifier); - - return switch (isFollowingQuery) { - AsyncData(value: final following) => Builder( - builder: (context) { - if (following) { - return OutlinedButton( - onPressed: () async { - await followingArtistNotifier - .removeArtists([artist.id!]); - }, - child: Text(context.l10n.following), - ); - } - - return FilledButton( - onPressed: () async { - await followingArtistNotifier - .saveArtists([artist.id!]); - }, - child: Text(context.l10n.follow), - ); - }, - ), - AsyncError() => const SizedBox(), - _ => const SizedBox.square( - dimension: 20, - child: CircularProgressIndicator(), - ) - }; - }, - ), - const SizedBox(width: 5), - 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) { - await ref - .read(blacklistProvider.notifier) - .remove(artist.id!); - } else { - await ref.read(blacklistProvider.notifier).add( - BlacklistTableCompanion.insert( - name: artist.name!, - elementId: artist.id!, - elementType: BlacklistedType.artist, - ), - ); - } - }, - ), - IconButton( - icon: const Icon(SpotubeIcons.share), - onPressed: () async { - if (artist.externalUrls?.spotify != null) { - await Clipboard.setData( - ClipboardData( - text: artist.externalUrls!.spotify!, + const Gap(20), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + OutlineBadge( + child: + Text(context.l10n.artist).small().muted(), + ), + if (isBlackListed) ...[ + const Gap(5), + DestructiveBadge( + child: Text(context.l10n.blacklisted).small(), ), - ); - } - - if (!context.mounted) return; - - scaffoldMessenger.showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - context.l10n.artist_url_copied, - textAlign: TextAlign.center, + ] + ], + ), + const Gap(10), + Flexible( + child: AutoSizeText( + artist.name!, + style: constrains.smAndDown + ? typography.h4 + : typography.h3, + maxLines: 2, + overflow: TextOverflow.ellipsis, + minFontSize: 14, + ), + ), + const Gap(5), + Flexible( + child: AutoSizeText( + context.l10n.followers( + PrimitiveUtils.toReadableNumber( + artist.followers!.total!.toDouble(), ), ), - ); - }, - ) - ], + maxLines: 1, + overflow: TextOverflow.ellipsis, + minFontSize: 12, + ).muted(), + ), + if (constrains.mdAndUp) ...[ + const Gap(20), + actions, + ] + ], + ), ), - ) - ], - ), - ], + ], + ), + if (constrains.smAndDown) ...[ + const Gap(20), + actions, + ] + ], + ), ), ); }, diff --git a/lib/pages/artist/section/related_artists.dart b/lib/pages/artist/section/related_artists.dart index 066f73fd..2db9ca94 100644 --- a/lib/pages/artist/section/related_artists.dart +++ b/lib/pages/artist/section/related_artists.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/modules/artist/artist_card.dart'; import 'package:spotube/provider/spotify/spotify.dart'; diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart index d52ed470..72709751 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.dart'; @@ -19,7 +19,6 @@ class ArtistPageTopTracks extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); - final scaffoldMessenger = ScaffoldMessenger.of(context); final playlist = ref.watch(audioPlayerProvider); final playlistNotifier = ref.watch(audioPlayerProvider.notifier); @@ -44,6 +43,9 @@ class ArtistPageTopTracks extends HookConsumerWidget { currentTrack ??= tracks.first; final isRemoteDevice = await showSelectDeviceDialog(context, ref); + + if (isRemoteDevice == null) return; + if (isRemoteDevice) { final remotePlayback = ref.read(connectProvider.notifier); final remotePlaylist = ref.read(queueProvider); @@ -90,46 +92,46 @@ class ArtistPageTopTracks extends HookConsumerWidget { padding: const EdgeInsets.all(8.0), child: Text( context.l10n.top_tracks, - style: theme.textTheme.headlineSmall, + style: theme.typography.h4, ), ), if (!isPlaylistPlaying) - IconButton( + IconButton.outline( icon: const Icon( SpotubeIcons.queueAdd, ), onPressed: () { playlistNotifier.addTracks(topTracks.toList()); - scaffoldMessenger.showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - context.l10n.added_to_queue( - topTracks.length, + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + child: Text( + context.l10n.added_to_queue( + topTracks.length, + ), ), - textAlign: TextAlign.center, - ), - ), + ); + }, ); }, ), const SizedBox(width: 5), - IconButton( + IconButton.primary( + shape: ButtonShape.circle, + enabled: !isPlaylistPlaying, icon: Skeleton.keep( child: Icon( - isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play, - color: Colors.white, + isPlaylistPlaying ? SpotubeIcons.pause : SpotubeIcons.play, ), ), - style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.primary, - ), onPressed: () => playPlaylist(topTracks.toList()), ) ], ), ), + const SliverGap(10), SliverList.builder( itemCount: topTracks.length, itemBuilder: (context, index) { diff --git a/lib/pages/connect/connect.dart b/lib/pages/connect/connect.dart index d3b0d0cb..bb8bbfae 100644 --- a/lib/pages/connect/connect.dart +++ b/lib/pages/connect/connect.dart @@ -1,14 +1,15 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/modules/connect/local_devices.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/connect/control/control.dart'; import 'package:spotube/provider/connect/clients.dart'; -import 'package:spotube/utils/service_utils.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class ConnectPage extends HookConsumerWidget { static const name = "connect"; @@ -16,23 +17,18 @@ class ConnectPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final ThemeData(:colorScheme, :textTheme) = Theme.of(context); + final ThemeData(:colorScheme, :typography) = Theme.of(context); final connectClients = ref.watch(connectClientsProvider); final connectClientsNotifier = ref.read(connectClientsProvider.notifier); final discoveredDevices = connectClients.asData?.value.services; - return Scaffold( - appBar: PageWindowTitleBar( - automaticallyImplyLeading: true, - title: Text(context.l10n.devices), - titleSpacing: 0, - ), - body: ListTileTheme( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - selectedTileColor: colorScheme.secondary.withOpacity(0.1), + return SafeArea( + bottom: false, + child: Scaffold( + headers: [ + TitleBar(title: Text(context.l10n.devices)), + ], child: Padding( padding: const EdgeInsets.all(10.0), child: CustomScrollView( @@ -42,7 +38,7 @@ class ConnectPage extends HookConsumerWidget { sliver: SliverToBoxAdapter( child: Text( context.l10n.remote, - style: textTheme.titleMedium, + style: typography.bold, ), ), ), @@ -55,35 +51,31 @@ class ConnectPage extends HookConsumerWidget { final selected = connectClients.asData?.value.resolvedService?.name == device.name; - return Card( - child: ListTile( - leading: const Icon(SpotubeIcons.monitor), - title: Text(device.name), - subtitle: selected - ? Text( - "${connectClients.asData?.value.resolvedService?.host}" - ":${connectClients.asData?.value.resolvedService?.port}", - ) - : null, - selected: selected, - onTap: () { - if (selected) { - ServiceUtils.pushNamed( - context, - ConnectControlPage.name, - ); - } else { - connectClientsNotifier.resolveService(device); - } - }, - trailing: selected - ? IconButton( - icon: const Icon(SpotubeIcons.power), - onPressed: () => - connectClientsNotifier.clearResolvedService(), - ) - : null, - ), + return ButtonTile( + selected: selected, + leading: const Icon(SpotubeIcons.monitor), + title: Text(device.name), + subtitle: selected + ? Text( + "${connectClients.asData?.value.resolvedService?.host}" + ":${connectClients.asData?.value.resolvedService?.port}", + ) + : null, + trailing: selected + ? IconButton.outline( + icon: const Icon(SpotubeIcons.power), + size: ButtonSize.small, + onPressed: () => + connectClientsNotifier.clearResolvedService(), + ) + : null, + onPressed: () { + if (selected) { + context.navigateTo(const ConnectControlRoute()); + } else { + connectClientsNotifier.resolveService(device); + } + }, ); }, ), diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart index cae0bd1b..2511809c 100644 --- a/lib/pages/connect/control/control.dart +++ b/lib/pages/connect/control/control.dart @@ -1,7 +1,8 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; -import 'package:go_router/go_router.dart'; +import 'package:auto_route/auto_route.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/player/player_queue.dart'; import 'package:spotube/modules/player/volume_slider.dart'; @@ -13,11 +14,9 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/provider/connect/connect.dart'; import 'package:media_kit/media_kit.dart' hide Track; -import 'package:spotube/utils/service_utils.dart'; class RemotePlayerQueue extends ConsumerWidget { const RemotePlayerQueue({super.key}); @@ -46,6 +45,7 @@ class RemotePlayerQueue extends ConsumerWidget { } } +@RoutePage() class ConnectControlPage extends HookConsumerWidget { static const name = "connect_control"; @@ -53,7 +53,7 @@ class ConnectControlPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme, :colorScheme) = Theme.of(context); + final ThemeData(:typography, :colorScheme) = Theme.of(context); final resolvedService = ref.watch(connectClientsProvider).asData?.value.resolvedService; @@ -63,36 +63,21 @@ class ConnectControlPage extends HookConsumerWidget { final shuffled = ref.watch(shuffleProvider); final loopMode = ref.watch(loopModeProvider); - final resumePauseStyle = IconButton.styleFrom( - backgroundColor: colorScheme.primary, - foregroundColor: colorScheme.onPrimary, - padding: const EdgeInsets.all(12), - iconSize: 24, - ); - final buttonStyle = IconButton.styleFrom( - backgroundColor: colorScheme.surface.withOpacity(0.4), - minimumSize: const Size(28, 28), - ); - - final activeButtonStyle = IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer, - foregroundColor: colorScheme.onPrimaryContainer, - minimumSize: const Size(28, 28), - ); - ref.listen(connectClientsProvider, (prev, next) { if (next.asData?.value.resolvedService == null) { - context.pop(); + context.back(); } }); return SafeArea( + bottom: false, child: Scaffold( - appBar: PageWindowTitleBar( - title: Text(resolvedService!.name), - automaticallyImplyLeading: true, - ), - body: LayoutBuilder(builder: (context, constrains) { + headers: [ + TitleBar( + title: Text(resolvedService!.name), + ) + ], + child: LayoutBuilder(builder: (context, constrains) { return Row( children: [ Expanded( @@ -106,7 +91,7 @@ class ConnectControlPage extends HookConsumerWidget { vertical: 10, ).copyWith(top: 0), constraints: - const BoxConstraints(maxHeight: 400, maxWidth: 400), + const BoxConstraints(maxHeight: 350, maxWidth: 350), child: ClipRRect( borderRadius: BorderRadius.circular(20), child: UniversalImage( @@ -126,15 +111,12 @@ class ConnectControlPage extends HookConsumerWidget { SliverToBoxAdapter( child: AnchorButton( playlist.activeTrack?.name ?? "", - style: textTheme.titleLarge!, + style: typography.h4, onTap: () { if (playlist.activeTrack == null) return; - ServiceUtils.pushNamed( - context, - TrackPage.name, - pathParameters: { - "id": playlist.activeTrack!.id!, - }, + context.navigateTo( + TrackRoute( + trackId: playlist.activeTrack!.id!), ); }, ), @@ -142,15 +124,10 @@ class ConnectControlPage extends HookConsumerWidget { SliverToBoxAdapter( child: ArtistLink( artists: playlist.activeTrack?.artists ?? [], - textStyle: textTheme.bodyMedium!, + textStyle: typography.normal, mainAxisAlignment: WrapAlignment.start, - onOverflowArtistClick: () => - ServiceUtils.pushNamed( - context, - TrackPage.name, - pathParameters: { - "id": playlist.activeTrack!.id!, - }, + onOverflowArtistClick: () => context.navigateTo( + TrackRoute(trackId: playlist.activeTrack!.id!), ), ), ), @@ -164,19 +141,25 @@ class ConnectControlPage extends HookConsumerWidget { final position = ref.watch(positionProvider); final duration = ref.watch(durationProvider); + final progress = duration.inSeconds == 0 + ? 0 + : position.inSeconds / duration.inSeconds; + return Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Column( children: [ Slider( - value: position > duration - ? 0 - : position.inSeconds.toDouble(), - min: 0, - max: duration.inSeconds.toDouble(), + value: + SliderValue.single(progress.toDouble()), onChanged: (value) { - connectNotifier - .seek(Duration(seconds: value.toInt())); + connectNotifier.seek( + Duration( + seconds: + (value.value * duration.inSeconds) + .toInt(), + ), + ); }, ), Row( @@ -196,94 +179,157 @@ class ConnectControlPage extends HookConsumerWidget { SliverToBoxAdapter( child: Row( mainAxisAlignment: MainAxisAlignment.center, + spacing: 20, children: [ - IconButton( - tooltip: shuffled - ? context.l10n.unshuffle_playlist - : context.l10n.shuffle_playlist, - icon: const Icon(SpotubeIcons.shuffle), - style: shuffled ? activeButtonStyle : buttonStyle, - onPressed: playlist.activeTrack == null - ? null - : () { - connectNotifier.setShuffle(!shuffled); - }, - ), - IconButton( - tooltip: context.l10n.previous_track, - icon: const Icon(SpotubeIcons.skipBack), - onPressed: playlist.activeTrack == null - ? null - : connectNotifier.previous, - ), - IconButton( - tooltip: playing - ? context.l10n.pause_playback - : context.l10n.resume_playback, - icon: playlist.activeTrack == null - ? SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - color: colorScheme.onPrimary, - ), - ) - : Icon( - playing - ? SpotubeIcons.pause - : SpotubeIcons.play, - ), - style: resumePauseStyle, - onPressed: playlist.activeTrack == null - ? null - : () { - if (playing) { - connectNotifier.pause(); - } else { - connectNotifier.resume(); - } - }, - ), - IconButton( - tooltip: context.l10n.next_track, - icon: const Icon(SpotubeIcons.skipForward), - onPressed: playlist.activeTrack == null - ? null - : connectNotifier.next, - ), - IconButton( - tooltip: loopMode == PlaylistMode.single - ? context.l10n.loop_track - : loopMode == PlaylistMode.loop - ? context.l10n.repeat_playlist - : null, - icon: Icon( - loopMode == PlaylistMode.single - ? SpotubeIcons.repeatOne - : SpotubeIcons.repeat, + Tooltip( + tooltip: TooltipContainer( + child: Text( + shuffled + ? context.l10n.unshuffle_playlist + : context.l10n.shuffle_playlist, + ), + ), + child: IconButton( + icon: const Icon(SpotubeIcons.shuffle), + variance: shuffled + ? ButtonVariance.secondary + : ButtonVariance.ghost, + onPressed: playlist.activeTrack == null + ? null + : () { + connectNotifier.setShuffle(!shuffled); + }, + ), + ), + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.previous_track), + ), + child: IconButton.ghost( + icon: const Icon(SpotubeIcons.skipBack), + onPressed: playlist.activeTrack == null + ? null + : connectNotifier.previous, + ), + ), + Tooltip( + tooltip: TooltipContainer( + child: Text( + playing + ? context.l10n.pause_playback + : context.l10n.resume_playback, + ), + ), + child: IconButton.primary( + shape: ButtonShape.circle, + icon: playlist.activeTrack == null + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + onSurface: false), + ) + : Icon( + playing + ? SpotubeIcons.pause + : SpotubeIcons.play, + ), + onPressed: playlist.activeTrack == null + ? null + : () { + if (playing) { + connectNotifier.pause(); + } else { + connectNotifier.resume(); + } + }, + ), + ), + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.next_track)), + child: IconButton.ghost( + icon: const Icon(SpotubeIcons.skipForward), + onPressed: playlist.activeTrack == null + ? null + : connectNotifier.next, + ), + ), + Tooltip( + tooltip: TooltipContainer( + child: Text( + loopMode == PlaylistMode.single + ? context.l10n.loop_track + : loopMode == PlaylistMode.loop + ? context.l10n.repeat_playlist + : context.l10n.no_loop, + ), + ), + child: IconButton( + icon: Icon( + loopMode == PlaylistMode.single + ? SpotubeIcons.repeatOne + : SpotubeIcons.repeat, + ), + variance: loopMode == PlaylistMode.single || + loopMode == PlaylistMode.loop + ? ButtonVariance.secondary + : ButtonVariance.ghost, + onPressed: playlist.activeTrack == null + ? null + : () async { + connectNotifier.setLoopMode( + switch (loopMode) { + PlaylistMode.loop => + PlaylistMode.single, + PlaylistMode.single => + PlaylistMode.none, + PlaylistMode.none => + PlaylistMode.loop, + }, + ); + }, ), - style: loopMode == PlaylistMode.single || - loopMode == PlaylistMode.loop - ? activeButtonStyle - : buttonStyle, - onPressed: playlist.activeTrack == null - ? null - : () async { - connectNotifier.setLoopMode( - switch (loopMode) { - PlaylistMode.loop => - PlaylistMode.single, - PlaylistMode.single => - PlaylistMode.none, - PlaylistMode.none => PlaylistMode.loop, - }, - ); - }, ) ], ), ), const SliverGap(30), + if (constrains.mdAndDown) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20), + sliver: SliverToBoxAdapter( + child: Button.outline( + leading: const Icon(SpotubeIcons.queue), + child: Text(context.l10n.queue), + onPressed: () { + openDrawer( + context: context, + barrierDismissible: true, + draggable: true, + barrierColor: Colors.black.withAlpha(100), + borderRadius: BorderRadius.circular(10), + transformBackdrop: false, + position: OverlayPosition.bottom, + surfaceBlur: context.theme.surfaceBlur, + surfaceOpacity: 0.7, + expands: true, + builder: (context) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: + MediaQuery.sizeOf(context).height * + 0.8, + ), + child: const RemotePlayerQueue(), + ); + }, + ); + }, + ), + ), + ), + const SliverGap(30), SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 20), sliver: SliverToBoxAdapter( @@ -300,25 +346,7 @@ class ConnectControlPage extends HookConsumerWidget { }), ), ), - const SliverGap(30), - if (constrains.mdAndDown) - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 20), - sliver: SliverToBoxAdapter( - child: OutlinedButton.icon( - icon: const Icon(SpotubeIcons.queue), - label: Text(context.l10n.queue), - onPressed: () { - showModalBottomSheet( - context: context, - builder: (context) { - return const RemotePlayerQueue(); - }, - ); - }, - ), - ), - ) + const SliverSafeArea(sliver: SliverGap(10)), ], ), ), diff --git a/lib/pages/getting_started/getting_started.dart b/lib/pages/getting_started/getting_started.dart index 0159a77f..a576ed09 100644 --- a/lib/pages/getting_started/getting_started.dart +++ b/lib/pages/getting_started/getting_started.dart @@ -1,6 +1,6 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; @@ -8,22 +8,16 @@ import 'package:spotube/pages/getting_started/sections/greeting.dart'; import 'package:spotube/pages/getting_started/sections/playback.dart'; import 'package:spotube/pages/getting_started/sections/region.dart'; import 'package:spotube/pages/getting_started/sections/support.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/themes/theme.dart'; +import 'package:auto_route/auto_route.dart'; -class GettingStarting extends HookConsumerWidget { +@RoutePage() +class GettingStartedPage extends HookConsumerWidget { static const name = "getting_started"; - const GettingStarting({super.key}); + const GettingStartedPage({super.key}); @override Widget build(BuildContext context, ref) { - final preferences = ref.watch(userPreferencesProvider); - final themeData = theme( - preferences.accentColorScheme, - Brightness.dark, - preferences.amoledDarkTheme, - ); final pageController = usePageController(); final onNext = useCallback(() { @@ -40,66 +34,59 @@ class GettingStarting extends HookConsumerWidget { ); }, [pageController]); - return Theme( - data: themeData, - child: Scaffold( - appBar: PageWindowTitleBar( - backgroundColor: Colors.transparent, - actions: [ - ListenableBuilder( - listenable: pageController, - builder: (context, _) { - return AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: pageController.hasClients && - (pageController.page == 0 || pageController.page == 3) - ? const SizedBox() - : TextButton( - onPressed: () { - pageController.animateToPage( - 3, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - child: Text( - context.l10n.skip_this_nonsense, - style: TextStyle( - decoration: TextDecoration.underline, - decorationColor: themeData.colorScheme.primary, - ), + return Scaffold( + headers: [ + SafeArea( + child: TitleBar( + backgroundColor: Colors.transparent, + surfaceBlur: 0, + trailing: [ + ListenableBuilder( + listenable: pageController, + builder: (context, _) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: pageController.hasClients && + (pageController.page == 0 || + pageController.page == 3) + ? const SizedBox() + : Button.secondary( + onPressed: () { + pageController.animateToPage( + 3, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }, + child: Text(context.l10n.skip_this_nonsense), ), - ), - ); - }, - ), - ], - ), - extendBodyBehindAppBar: true, - body: DecoratedBox( - decoration: BoxDecoration( - image: DecorationImage( - image: Assets.bengaliPatternsBg.provider(), - fit: BoxFit.cover, - colorFilter: const ColorFilter.mode( - Colors.black38, - BlendMode.srcOver, + ); + }, ), - ), - ), - child: PageView( - controller: pageController, - children: [ - GettingStartedPageGreetingSection(onNext: onNext), - GettingStartedPageLanguageRegionSection(onNext: onNext), - GettingStartedPagePlaybackSection( - onNext: onNext, - onPrevious: onPrevious, - ), - const GettingStartedScreenSupportSection(), ], ), ), + ], + floatingHeader: true, + child: DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: Assets.bengaliPatternsBg.provider(), + fit: BoxFit.cover, + ), + ), + child: PageView( + controller: pageController, + children: [ + GettingStartedPageGreetingSection(onNext: onNext), + GettingStartedPageLanguageRegionSection(onNext: onNext), + GettingStartedPagePlaybackSection( + onNext: onNext, + onPrevious: onPrevious, + ), + const GettingStartedScreenSupportSection(), + ], + ), ), ); } diff --git a/lib/pages/getting_started/sections/greeting.dart b/lib/pages/getting_started/sections/greeting.dart index 6d649351..4b9c0a89 100644 --- a/lib/pages/getting_started/sections/greeting.dart +++ b/lib/pages/getting_started/sections/greeting.dart @@ -1,10 +1,9 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/modules/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/modules/getting_started/blur_card.dart'; import 'package:spotube/utils/platform.dart'; class GettingStartedPageGreetingSection extends HookConsumerWidget { @@ -13,8 +12,6 @@ class GettingStartedPageGreetingSection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme) = Theme.of(context); - return Center( child: BlurCard( child: Column( @@ -22,30 +19,19 @@ class GettingStartedPageGreetingSection extends HookConsumerWidget { children: [ Assets.spotubeLogoPng.image(height: 200), const Gap(24), - Text( - "Spotube", - style: - textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), + const Text("Spotube").semiBold().h4(), const Gap(4), Text( kIsMobile ? context.l10n.freedom_of_music_palm : context.l10n.freedom_of_music, textAlign: TextAlign.center, - style: textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w300, - fontStyle: FontStyle.italic, - ), - ), + ).light().large().italic(), const Gap(84), - Directionality( - textDirection: TextDirection.rtl, - child: FilledButton.icon( - onPressed: onNext, - icon: const Icon(SpotubeIcons.angleRight), - label: Text(context.l10n.get_started), - ), + Button.primary( + onPressed: onNext, + trailing: const Icon(SpotubeIcons.angleRight), + child: Text(context.l10n.get_started), ), ], ), diff --git a/lib/pages/getting_started/sections/playback.dart b/lib/pages/getting_started/sections/playback.dart index dbf0bda2..f122dbcd 100644 --- a/lib/pages/getting_started/sections/playback.dart +++ b/lib/pages/getting_started/sections/playback.dart @@ -1,9 +1,9 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/modules/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; @@ -14,14 +14,14 @@ final audioSourceToIconMap = { AudioSource.youtube: const Icon( SpotubeIcons.youtube, color: Colors.red, - size: 30, + size: 20, ), - AudioSource.piped: const Icon(SpotubeIcons.piped, size: 30), + AudioSource.piped: const Icon(SpotubeIcons.piped, size: 20), AudioSource.invidious: ClipRRect( - borderRadius: BorderRadius.circular(48), - child: Assets.invidious.image(width: 48, height: 48), + borderRadius: BorderRadius.circular(26), + child: Assets.invidious.image(width: 26, height: 26), ), - AudioSource.jiosaavn: Assets.jiosaavn.image(width: 48, height: 48), + AudioSource.jiosaavn: Assets.jiosaavn.image(width: 20, height: 20), }; class GettingStartedPagePlaybackSection extends HookConsumerWidget { @@ -36,8 +36,6 @@ class GettingStartedPagePlaybackSection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme, :colorScheme, :dividerColor) = - Theme.of(context); final preferences = ref.watch(userPreferencesProvider); final preferencesNotifier = ref.read(userPreferencesProvider.notifier); @@ -62,76 +60,64 @@ class GettingStartedPagePlaybackSection extends HookConsumerWidget { children: [ const Icon(SpotubeIcons.album, size: 16), const Gap(8), - Text(context.l10n.playback, style: textTheme.titleMedium), + Text(context.l10n.playback).semiBold().large(), ], ), const Gap(16), - ListTile( - title: Text( - context.l10n.select_audio_source, - style: textTheme.titleMedium, - ), + Align( + alignment: Alignment.centerLeft, + child: Text(context.l10n.select_audio_source).semiBold().large(), ), const Gap(16), - ToggleButtons( - isSelected: [ - for (final source in AudioSource.values) - preferences.audioSource == source, - ], - onPressed: (index) { - preferencesNotifier.setAudioSource(AudioSource.values[index]); + Select( + value: preferences.audioSource, + onChanged: (value) { + if (value == null) return; + preferencesNotifier.setAudioSource(value); }, - borderRadius: BorderRadius.circular(8), - children: [ - for (final source in AudioSource.values) - SizedBox.square( - dimension: 84, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - audioSourceToIconMap[source]!, - const Gap(8), - Text( - source.name.capitalize(), - style: textTheme.bodySmall!.copyWith( - color: preferences.audioSource == source - ? colorScheme.primary - : null, - ), - ), - ], - ), - ), - ], - ), - ListTile( - title: Align( - alignment: switch (preferences.audioSource) { - AudioSource.youtube => Alignment.centerLeft, - AudioSource.piped || - AudioSource.invidious => - Alignment.center, - AudioSource.jiosaavn => Alignment.centerRight, - }, - child: Text( - audioSourceToDescription[preferences.audioSource]!, - style: textTheme.bodySmall?.copyWith( - color: dividerColor, - ), - ), + placeholder: Text(preferences.audioSource.name.capitalize()), + itemBuilder: (context, value) => Row( + mainAxisSize: MainAxisSize.min, + spacing: 6, + children: [ + audioSourceToIconMap[value]!, + Text(value.name.capitalize()), + ], ), + popup: (context) { + return SelectPopup( + items: SelectItemBuilder( + childCount: AudioSource.values.length, + builder: (context, index) { + final source = AudioSource.values[index]; + + return SelectItemButton( + value: source, + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 6, + children: [ + audioSourceToIconMap[source]!, + Text(source.name.capitalize()), + ], + ), + ); + }, + ), + ); + }, ), const Gap(16), - ListTile( + Text( + audioSourceToDescription[preferences.audioSource]!, + ).small().muted(), + const Gap(16), + ButtonTile( title: Text(context.l10n.endless_playback), subtitle: Text( context.l10n.endless_playback_description, - style: textTheme.bodySmall?.copyWith( - color: dividerColor, - ), - ), - onTap: () { + ).small().muted(), + onPressed: () { preferencesNotifier .setEndlessPlayback(!preferences.endlessPlayback); }, @@ -146,17 +132,17 @@ class GettingStartedPagePlaybackSection extends HookConsumerWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - FilledButton.icon( - icon: const Icon(SpotubeIcons.angleLeft), - label: Text(context.l10n.previous), + Button.secondary( + leading: const Icon(SpotubeIcons.angleLeft), onPressed: onPrevious, + child: Text(context.l10n.previous), ), Directionality( textDirection: TextDirection.rtl, - child: FilledButton.icon( - icon: const Icon(SpotubeIcons.angleRight), - label: Text(context.l10n.next), + child: Button.primary( + leading: const Icon(SpotubeIcons.angleRight), onPressed: onNext, + child: Text(context.l10n.next), ), ), ], diff --git a/lib/pages/getting_started/sections/region.dart b/lib/pages/getting_started/sections/region.dart index 9e31a273..0cd09be7 100644 --- a/lib/pages/getting_started/sections/region.dart +++ b/lib/pages/getting_started/sections/region.dart @@ -1,6 +1,6 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/collections/language_codes.dart'; import 'package:spotube/collections/spotify_markets.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -14,9 +14,24 @@ class GettingStartedPageLanguageRegionSection extends HookConsumerWidget { const GettingStartedPageLanguageRegionSection( {super.key, required this.onNext}); + bool filterMarkets(Market item, String query) { + final market = spotifyMarkets + .firstWhere((element) => element.$1 == item) + .$2 + .toLowerCase(); + + return market.contains(query.toLowerCase()); + } + + bool filterLocale(Locale locale, String query) { + final language = + LanguageLocals.getDisplayLanguage(locale.languageCode).toString(); + + return language.toLowerCase().contains(query.toLowerCase()); + } + @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme, :dividerColor) = Theme.of(context); final preferences = ref.watch(userPreferencesProvider); return SafeArea( @@ -32,92 +47,140 @@ class GettingStartedPageLanguageRegionSection extends HookConsumerWidget { size: 16, ), const SizedBox(width: 8), - Text( - context.l10n.language_region, - style: textTheme.titleMedium, - ), + Text(context.l10n.language_region).semiBold(), ], ), - const Gap(48), + const Gap(30), Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( - context.l10n.choose_your_region, - style: textTheme.titleSmall, - ), + Text(context.l10n.choose_your_region).semiBold(), Text( context.l10n.choose_your_region_description, - style: textTheme.bodySmall?.copyWith( - color: dividerColor, - ), - ), + ).small().muted(), const Gap(16), - DropdownMenu( - initialSelection: preferences.market, - onSelected: (value) { - if (value == null) return; - ref - .read(userPreferencesProvider.notifier) - .setRecommendationMarket(value); - }, - hintText: preferences.market.name, - label: Text(context.l10n.market_place_region), - inputDecorationTheme: - const InputDecorationTheme(isDense: true), - dropdownMenuEntries: [ - for (final market in spotifyMarkets) - DropdownMenuEntry( - value: market.$1, - label: market.$2, - ), - ], + Text(context.l10n.market_place_region).small(), + const Gap(8), + SizedBox( + width: double.infinity, + child: Select( + value: preferences.market, + onChanged: (value) { + if (value == null) return; + ref + .read(userPreferencesProvider.notifier) + .setRecommendationMarket(value); + }, + placeholder: Text(preferences.market.name), + itemBuilder: (context, value) => Text( + spotifyMarkets + .firstWhere((element) => element.$1 == value) + .$2, + ), + popup: SelectPopup.builder( + searchPlaceholder: Text(context.l10n.search), + builder: (context, searchQuery) { + final filteredMarkets = searchQuery == null || + searchQuery.isEmpty + ? spotifyMarkets + : spotifyMarkets + .where( + (element) => + filterMarkets(element.$1, searchQuery), + ) + .toList(); + return SelectItemBuilder( + childCount: filteredMarkets.length, + builder: (context, index) { + final market = filteredMarkets[index]; + return SelectItemButton( + value: market.$1, + child: Text(market.$2), + ); + }, + ); + }, + ).call, + ), ), const Gap(36), Text( context.l10n.choose_your_language, - style: textTheme.titleSmall, - ), + ).semiBold(), const Gap(16), - DropdownMenu( - initialSelection: preferences.locale, - onSelected: (locale) { - if (locale == null) return; - ref - .read(userPreferencesProvider.notifier) - .setLocale(locale); - }, - hintText: context.l10n.system_default, - label: Text(context.l10n.language), - inputDecorationTheme: - const InputDecorationTheme(isDense: true), - dropdownMenuEntries: [ - DropdownMenuEntry( - value: const Locale("system", "system"), - label: context.l10n.system_default, - ), - for (final locale in L10n.all) - DropdownMenuEntry( - value: locale, - label: LanguageLocals.getDisplayLanguage( - locale.languageCode) - .toString(), - ), - ], + Text(context.l10n.language).small(), + const Gap(8), + SizedBox( + width: double.infinity, + child: Select( + value: preferences.locale, + onChanged: (locale) { + if (locale == null) return; + ref + .read(userPreferencesProvider.notifier) + .setLocale(locale); + }, + placeholder: Text(context.l10n.system_default), + itemBuilder: (context, value) => + value.languageCode == "system" + ? Text(context.l10n.system_default) + : Text( + LanguageLocals.getDisplayLanguage( + value.languageCode) + .toString(), + ), + popup: SelectPopup.builder( + searchPlaceholder: Text(context.l10n.search), + builder: (context, searchQuery) { + final filteredLocale = searchQuery?.isNotEmpty != true + ? L10n.all + : L10n.all + .where( + (element) => + filterLocale(element, searchQuery!), + ) + .toList(); + + return SelectItemBuilder( + childCount: filteredLocale.length + 1, + builder: (context, index) { + if (index == 0 && + searchQuery?.isNotEmpty != true) { + return SelectItemButton( + value: const Locale("system", "system"), + child: Text(context.l10n.system_default), + ); + } + + final indexThen = searchQuery?.isNotEmpty != true + ? index + : index - 1; + + final locale = filteredLocale[indexThen]; + return SelectItemButton( + value: locale, + child: Text( + LanguageLocals.getDisplayLanguage( + locale.languageCode) + .toString(), + ), + ); + }, + ); + }, + ).call, + ), ), ], ), const Gap(48), Align( alignment: Alignment.centerRight, - child: Directionality( - textDirection: TextDirection.rtl, - child: FilledButton.icon( - icon: const Icon(SpotubeIcons.angleRight), - label: Text(context.l10n.next), - onPressed: onNext, - ), + child: Button.primary( + trailing: const Icon(SpotubeIcons.angleRight), + onPressed: onNext, + child: Text(context.l10n.next), ), ), ], diff --git a/lib/pages/getting_started/sections/support.dart b/lib/pages/getting_started/sections/support.dart index f09a585d..9559d28d 100644 --- a/lib/pages/getting_started/sections/support.dart +++ b/lib/pages/getting_started/sections/support.dart @@ -1,12 +1,11 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; -import 'package:go_router/go_router.dart'; +import 'package:auto_route/auto_route.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/env.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/mobile_login/hooks/login_callback.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -16,7 +15,6 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme, :colorScheme) = Theme.of(context); final onLogin = useLoginCallback(ref); return Center( @@ -34,9 +32,8 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { const SizedBox(width: 8), Text( context.l10n.help_project_grow, - style: - textTheme.titleMedium?.copyWith(color: Colors.pink), - ), + style: const TextStyle(color: Colors.pink), + ).semiBold(), ], ), const Gap(16), @@ -46,38 +43,57 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - FilledButton.icon( - icon: const Icon(SpotubeIcons.github), - label: Text(context.l10n.contribute_on_github), - style: FilledButton.styleFrom( - backgroundColor: Colors.black, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( + Button( + leading: const Icon(SpotubeIcons.github), + style: ButtonVariance.primary.copyWith( + decoration: (context, states, value) { + if (states.isNotEmpty) { + return ButtonVariance.primary + .decoration(context, states); + } + + return BoxDecoration( + color: Colors.black, borderRadius: BorderRadius.circular(8), - ), - ), + ); + }), onPressed: () async { await launchUrlString( "https://github.com/KRTirtho/spotube", mode: LaunchMode.externalApplication, ); }, + child: Text( + context.l10n.contribute_on_github, + style: const TextStyle(color: Colors.white), + ), ), if (!Env.hideDonations) ...[ const Gap(16), - FilledButton.icon( - icon: const Icon(SpotubeIcons.openCollective), - label: Text(context.l10n.donate_on_open_collective), - style: FilledButton.styleFrom( - backgroundColor: const Color(0xff4cb7f6), - foregroundColor: Colors.white, - ), + Button( + leading: const Icon(SpotubeIcons.openCollective), + style: ButtonVariance.primary.copyWith( + decoration: (context, states, value) { + if (states.isNotEmpty) { + return ButtonVariance.primary + .decoration(context, states); + } + + return BoxDecoration( + color: const Color(0xff4cb7f6), + borderRadius: BorderRadius.circular(8), + ); + }), onPressed: () async { await launchUrlString( "https://opencollective.com/spotube", mode: LaunchMode.externalApplication, ); }, + child: Text( + context.l10n.donate_on_open_collective, + style: const TextStyle(color: Colors.white), + ), ), ] ], @@ -91,42 +107,40 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - gradient: LinearGradient( - colors: [ - colorScheme.primary, - colorScheme.secondary, - ], - ), - ), - child: TextButton.icon( - icon: const Icon(SpotubeIcons.anonymous), - label: Text(context.l10n.browse_anonymously), - style: TextButton.styleFrom( - foregroundColor: Colors.white, - ), - onPressed: () async { - await KVStoreService.setDoneGettingStarted(true); - if (context.mounted) { - context.goNamed(HomePage.name); - } - }, - ), + Button.secondary( + leading: const Icon(SpotubeIcons.anonymous), + onPressed: () async { + await KVStoreService.setDoneGettingStarted(true); + if (context.mounted) { + context.navigateTo(const HomeRoute()); + } + }, + child: Text(context.l10n.browse_anonymously), ), const Gap(16), - FilledButton.icon( - icon: const Icon(SpotubeIcons.spotify), - label: Text(context.l10n.connect_with_spotify), - style: FilledButton.styleFrom( - backgroundColor: const Color(0xff1db954), - foregroundColor: Colors.white, + Button.primary( + leading: const Icon(SpotubeIcons.spotify), + style: ButtonVariance.primary.copyWith( + decoration: (context, states, value) { + if (states.isNotEmpty) { + return ButtonVariance.primary + .decoration(context, states); + } + + return BoxDecoration( + color: const Color(0xff1db954), + borderRadius: BorderRadius.circular(8), + ); + }, ), onPressed: () async { await KVStoreService.setDoneGettingStarted(true); await onLogin(); }, + child: Text( + context.l10n.connect_with_spotify, + style: const TextStyle(color: Colors.white), + ), ), ], ), diff --git a/lib/pages/home/feed/feed_section.dart b/lib/pages/home/feed/feed_section.dart index bcfc0b81..2b38d0ed 100644 --- a/lib/pages/home/feed/feed_section.dart +++ b/lib/pages/home/feed/feed_section.dart @@ -1,67 +1,99 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/fake.dart'; +import 'package:spotube/components/playbutton_view/playbutton_view.dart'; import 'package:spotube/modules/album/album_card.dart'; import 'package:spotube/modules/artist/artist_card.dart'; import 'package:spotube/modules/playlist/playlist_card.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/spotify/views/home_section.dart'; +@RoutePage() class HomeFeedSectionPage extends HookConsumerWidget { static const name = "home_feed_section"; final String sectionUri; - const HomeFeedSectionPage({super.key, required this.sectionUri}); + const HomeFeedSectionPage({ + super.key, + @PathParam("feedId") required this.sectionUri, + }); @override Widget build(BuildContext context, ref) { final homeFeedSection = ref.watch(homeSectionViewProvider(sectionUri)); final section = homeFeedSection.asData?.value ?? FakeData.feedSection; + final controller = useScrollController(); + final isArtist = section.items.every((item) => item.artist != null); - return Skeletonizer( - enabled: homeFeedSection.isLoading, - child: Scaffold( - appBar: PageWindowTitleBar( - title: Text(section.title ?? ""), - centerTitle: false, - automaticallyImplyLeading: true, - titleSpacing: 0, - ), - body: CustomScrollView( - slivers: [ - SliverLayoutBuilder( - builder: (context, constrains) { - return SliverGrid.builder( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - mainAxisExtent: constrains.smAndDown ? 225 : 250, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - itemCount: section.items.length, - itemBuilder: (context, index) { - final item = section.items[index]; - - if (item.album != null) { - return AlbumCard(item.album!.asAlbum); - } else if (item.artist != null) { - return ArtistCard(item.artist!.asArtist); - } else if (item.playlist != null) { - return PlaylistCard(item.playlist!.asPlaylist); - } - return const SizedBox(); - }, - ); - }, - ), - const SliverToBoxAdapter( - child: SafeArea( - child: SizedBox(), - ), - ), + return SafeArea( + bottom: false, + child: Skeletonizer( + enabled: homeFeedSection.isLoading, + child: Scaffold( + headers: [ + TitleBar( + title: Text(section.title ?? ""), + ) ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: CustomScrollView( + controller: controller, + slivers: [ + if (isArtist) + SliverGrid.builder( + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: 250, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: section.items.length, + itemBuilder: (context, index) { + final item = section.items[index]; + return ArtistCard(item.artist!.asArtist); + }, + ) + else + PlaybuttonView( + controller: controller, + itemCount: section.items.length, + hasMore: false, + isLoading: false, + onRequestMore: () => {}, + listItemBuilder: (context, index) { + final item = section.items[index]; + if (item.album != null) { + return AlbumCard.tile(item.album!.asAlbum); + } + if (item.playlist != null) { + return PlaylistCard.tile(item.playlist!.asPlaylist); + } + return const SizedBox.shrink(); + }, + gridItemBuilder: (context, index) { + final item = section.items[index]; + if (item.album != null) { + return AlbumCard(item.album!.asAlbum); + } + if (item.playlist != null) { + return PlaylistCard(item.playlist!.asPlaylist); + } + return const SizedBox.shrink(); + }, + ), + const SliverToBoxAdapter( + child: SafeArea( + child: SizedBox(), + ), + ), + ], + ), + ), ), ), ); diff --git a/lib/pages/home/genres/genre_playlists.dart b/lib/pages/home/genres/genre_playlists.dart index 04658965..ea421cb4 100644 --- a/lib/pages/home/genres/genre_playlists.dart +++ b/lib/pages/home/genres/genre_playlists.dart @@ -1,26 +1,34 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show CollapseMode, FlexibleSpaceBar; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; -import 'package:go_router/go_router.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; + import 'package:spotify/spotify.dart' hide Offset; -import 'package:spotube/collections/fake.dart'; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/components/button/back_button.dart'; +import 'package:spotube/components/playbutton_view/playbutton_view.dart'; import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart'; import 'package:spotube/modules/playlist/playlist_card.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/components/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:collection/collection.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class GenrePlaylistsPage extends HookConsumerWidget { static const name = "genre_playlists"; final Category category; - const GenrePlaylistsPage({super.key, required this.category}); + final String id; + const GenrePlaylistsPage({ + super.key, + @PathParam("categoryId") required this.id, + required this.category, + }); @override Widget build(BuildContext context, ref) { @@ -29,133 +37,107 @@ class GenrePlaylistsPage extends HookConsumerWidget { final playlistsNotifier = ref.read(categoryPlaylistsProvider(category.id!).notifier); final scrollController = useScrollController(); - final routeName = GoRouterState.of(context).name; useCustomStatusBarColor( Colors.black, - routeName == GenrePlaylistsPage.name, + context.watchRouter.topRoute.name == GenrePlaylistsRoute.name, noSetBGColor: true, automaticSystemUiAdjustment: false, ); - return Scaffold( - appBar: kIsDesktop - ? const PageWindowTitleBar( - leading: BackButton(color: Colors.white), + return SafeArea( + child: Scaffold( + headers: [ + if (kIsDesktop) + const TitleBar( + leading: [ + BackButton(), + ], backgroundColor: Colors.transparent, - foregroundColor: Colors.white, + surfaceOpacity: 0, + surfaceBlur: 0, ) - : null, - extendBodyBehindAppBar: true, - body: DecoratedBox( - decoration: BoxDecoration( - image: DecorationImage( - image: UniversalImage.imageProvider(category.icons!.first.url!), - alignment: Alignment.topCenter, - fit: BoxFit.cover, - colorFilter: ColorFilter.mode( - Colors.black.withOpacity(0.5), - BlendMode.darken, + ], + floatingHeader: true, + child: DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: UniversalImage.imageProvider(category.icons!.first.url!), + alignment: Alignment.topCenter, + fit: BoxFit.cover, + repeat: ImageRepeat.noRepeat, + matchTextDirection: true, ), - repeat: ImageRepeat.noRepeat, - matchTextDirection: true, ), - ), - child: CustomScrollView( - controller: scrollController, - slivers: [ - SliverAppBar( - automaticallyImplyLeading: kIsMobile, - expandedHeight: mediaQuery.mdAndDown ? 200 : 150, - title: const Text(""), - backgroundColor: Colors.transparent, - flexibleSpace: FlexibleSpaceBar( - centerTitle: kIsDesktop, - title: Text( - category.name!, - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: Colors.white, - letterSpacing: 3, - shadows: [ - const Shadow( - offset: Offset(-1.5, -1.5), - color: Colors.black54, + child: SurfaceCard( + borderRadius: BorderRadius.zero, + padding: EdgeInsets.zero, + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverSafeArea( + bottom: false, + sliver: SliverAppBar( + automaticallyImplyLeading: false, + leading: kIsMobile ? const BackButton() : null, + expandedHeight: mediaQuery.mdAndDown ? 200 : 150, + title: const Text(""), + backgroundColor: Colors.transparent, + flexibleSpace: FlexibleSpaceBar( + centerTitle: kIsDesktop, + title: Text( + category.name!, + style: context.theme.typography.h3.copyWith( + color: Colors.white, + letterSpacing: 3, + shadows: [ + Shadow( + offset: const Offset(-1.5, -1.5), + color: Colors.black.withAlpha(138), + ), + Shadow( + offset: const Offset(1.5, -1.5), + color: Colors.black.withAlpha(138), + ), + Shadow( + offset: const Offset(1.5, 1.5), + color: Colors.black.withAlpha(138), + ), + Shadow( + offset: const Offset(-1.5, 1.5), + color: Colors.black.withAlpha(138), + ), + ], + ), ), - const Shadow( - offset: Offset(1.5, -1.5), - color: Colors.black54, - ), - const Shadow( - offset: Offset(1.5, 1.5), - color: Colors.black54, - ), - const Shadow( - offset: Offset(-1.5, 1.5), - color: Colors.black54, - ), - ], + collapseMode: CollapseMode.parallax, + ), ), ), - collapseMode: CollapseMode.parallax, - ), - ), - const SliverGap(20), - SliverSafeArea( - top: false, - sliver: SliverPadding( - padding: EdgeInsets.symmetric( - horizontal: mediaQuery.mdAndDown ? 12 : 24, + const SliverGap(20), + SliverSafeArea( + top: false, + sliver: SliverPadding( + padding: EdgeInsets.symmetric( + horizontal: mediaQuery.mdAndDown ? 12 : 24, + ), + sliver: PlaybuttonView( + controller: scrollController, + itemCount: playlists.asData?.value.items.length ?? 0, + isLoading: playlists.isLoading, + hasMore: playlists.asData?.value.hasMore == true, + onRequestMore: playlistsNotifier.fetchMore, + listItemBuilder: (context, index) => PlaylistCard.tile( + playlists.asData!.value.items[index]), + gridItemBuilder: (context, index) => + PlaylistCard(playlists.asData!.value.items[index]), + ), + ), ), - sliver: playlists.asData?.value.items.isNotEmpty != true - ? 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.asData?.value.items.length ?? 0) + 1, - itemBuilder: (context, index) { - final playlist = playlists.asData?.value.items - .elementAtOrNull(index); - - if (playlist == null) { - if (playlists.asData?.value.hasMore == false) { - return const SizedBox.shrink(); - } - return Skeletonizer( - enabled: true, - child: Waypoint( - controller: scrollController, - isGrid: true, - onTouchEdge: playlistsNotifier.fetchMore, - child: PlaylistCard(FakeData.playlist), - ), - ); - } - - return Skeleton.keep( - child: PlaylistCard(playlist), - ); - }, - ), - ), + const SliverGap(20), + ], ), - const SliverGap(20), - ], + ), ), ), ); diff --git a/lib/pages/home/genres/genres.dart b/lib/pages/home/genres/genres.dart index 4846d633..38d110db 100644 --- a/lib/pages/home/genres/genres.dart +++ b/lib/pages/home/genres/genres.dart @@ -1,37 +1,39 @@ import 'dart:math'; import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart'; + import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/collections/gradients.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/home/genres/genre_playlists.dart'; import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class GenrePage extends HookConsumerWidget { static const name = "genre"; const GenrePage({super.key}); @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme) = Theme.of(context); final scrollController = useScrollController(); final categories = ref.watch(categoriesProvider); final mediaQuery = MediaQuery.of(context); - return Scaffold( - appBar: PageWindowTitleBar( - title: Text(context.l10n.explore_genres), - automaticallyImplyLeading: true, - titleSpacing: 0, - ), - body: SafeArea( - top: false, + return SafeArea( + child: Scaffold( + headers: [ + TitleBar( + title: Text(context.l10n.explore_genres), + ) + ], child: GridView.builder( padding: const EdgeInsets.all(12), controller: scrollController, @@ -46,48 +48,54 @@ class GenrePage extends HookConsumerWidget { itemBuilder: (context, index) { final category = categories.asData!.value[index]; final gradient = gradients[Random().nextInt(gradients.length)]; - return InkWell( - borderRadius: BorderRadius.circular(8), - onTap: () { - context.pushNamed( - GenrePlaylistsPage.name, - pathParameters: { - "categoryId": category.id!, - }, - extra: category, + return CardImage( + onPressed: () { + context.navigateTo( + GenrePlaylistsRoute( + id: category.id!, + category: category, + ), ); }, - child: Ink( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - image: DecorationImage( - image: NetworkImage(category.icons!.first.url!), - fit: BoxFit.cover, - ), - gradient: gradient, - ), - child: Align( - alignment: Alignment.bottomCenter, - child: AutoSizeText( - category.name!, - style: textTheme.titleLarge?.copyWith( - color: Colors.white, - shadows: [ - // stroke shadow - const Shadow( - color: Colors.black, - offset: Offset(1, 1), - blurRadius: 2, - ), - ], + image: Stack( + children: [ + Container( + height: 300, + width: 250, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: DecorationImage( + image: NetworkImage(category.icons!.first.url!), + fit: BoxFit.cover, + ), + gradient: gradient, ), - maxLines: 1, - textAlign: TextAlign.center, - maxFontSize: textTheme.titleLarge!.fontSize!, - minFontSize: textTheme.titleMedium!.fontSize!, ), - ), + Positioned.fill( + bottom: 10, + child: Align( + alignment: Alignment.bottomCenter, + child: AutoSizeText( + category.name!, + style: context.theme.typography.h3.copyWith( + color: Colors.white, + shadows: [ + // stroke shadow + const Shadow( + color: Colors.black, + offset: Offset(1, 1), + blurRadius: 2, + ), + ], + ), + maxLines: 1, + textAlign: TextAlign.center, + maxFontSize: context.theme.typography.h3.fontSize!, + minFontSize: context.theme.typography.large.fontSize!, + ), + ), + ), + ], ), ); }, diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index efdca4f7..9ca71c04 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -1,25 +1,27 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/modules/connect/connect_device.dart'; import 'package:spotube/modules/home/sections/featured.dart'; import 'package:spotube/modules/home/sections/feed.dart'; import 'package:spotube/modules/home/sections/friends.dart'; -import 'package:spotube/modules/home/sections/genres.dart'; +import 'package:spotube/modules/home/sections/genres/genres.dart'; import 'package:spotube/modules/home/sections/made_for_user.dart'; import 'package:spotube/modules/home/sections/new_releases.dart'; import 'package:spotube/modules/home/sections/recent.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/service_utils.dart'; +@RoutePage() class HomePage extends HookConsumerWidget { static const name = "home"; const HomePage({super.key}); @@ -34,21 +36,25 @@ class HomePage extends HookConsumerWidget { return SafeArea( bottom: false, child: Scaffold( - appBar: kIsMobile || kIsMacOS ? null : const PageWindowTitleBar(), - body: CustomScrollView( + headers: [ + if (kTitlebarVisible) const TitleBar(height: 30), + ], + child: CustomScrollView( controller: controller, slivers: [ if (mediaQuery.smAndDown || layoutMode == LayoutMode.compact) SliverAppBar( floating: true, title: Assets.spotubeLogoPng.image(height: 45), + backgroundColor: context.theme.colorScheme.background, + foregroundColor: context.theme.colorScheme.foreground, actions: [ const ConnectDeviceButton(), const Gap(10), - IconButton( + IconButton.ghost( icon: const Icon(SpotubeIcons.settings, size: 20), onPressed: () { - ServiceUtils.pushNamed(context, SettingsPage.name); + context.navigateTo(const SettingsRoute()); }, ), const Gap(10), @@ -56,12 +62,19 @@ class HomePage extends HookConsumerWidget { ) else if (kIsMacOS) const SliverGap(10), - const HomeGenresSection(), const SliverGap(10), - const SliverToBoxAdapter(child: HomeRecentlyPlayedSection()), - const SliverToBoxAdapter(child: HomeFeaturedSection()), - const HomePageFriendsSection(), - const SliverToBoxAdapter(child: HomeNewReleasesSection()), + SliverList.builder( + itemCount: 5, + itemBuilder: (context, index) { + return switch (index) { + 0 => const HomeGenresSection(), + 1 => const HomeRecentlyPlayedSection(), + 2 => const HomeFeaturedSection(), + 3 => const HomePageFriendsSection(), + _ => const HomeNewReleasesSection() + }; + }, + ), const HomePageFeedSection(), const SliverSafeArea(sliver: HomeMadeForUserSection()), ], diff --git a/lib/pages/lastfm_login/lastfm_login.dart b/lib/pages/lastfm_login/lastfm_login.dart index 8107e627..41042a1b 100644 --- a/lib/pages/lastfm_login/lastfm_login.dart +++ b/lib/pages/lastfm_login/lastfm_login.dart @@ -1,140 +1,148 @@ -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:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/button/back_button.dart'; import 'package:spotube/components/dialogs/prompt_dialog.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/scrobbler/scrobbler.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class LastFMLoginPage extends HookConsumerWidget { static const name = "lastfm_login"; const LastFMLoginPage({super.key}); @override Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final router = GoRouter.of(context); final scrobblerNotifier = ref.read(scrobblerProvider.notifier); - final formKey = useMemoized(() => GlobalKey(), []); - final username = useTextEditingController(); - final password = useTextEditingController(); + final usernameKey = + useMemoized(() => const FormKey("username"), []); + final passwordKey = + useMemoized(() => const FormKey("password"), []); + final passwordVisible = useState(false); final isLoading = useState(false); return Scaffold( - appBar: const PageWindowTitleBar(leading: BackButton()), - body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Card( - margin: const EdgeInsets.all(8.0), - child: Padding( - padding: const EdgeInsets.all(16.0).copyWith(top: 8), - child: Form( - key: formKey, - autovalidateMode: AutovalidateMode.onUserInteraction, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(30), - color: const Color.fromARGB(255, 186, 0, 0), + headers: const [ + SafeArea( + bottom: false, + child: TitleBar( + leading: [BackButton()], + ), + ), + ], + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Container( + constraints: const BoxConstraints(maxWidth: 400), + alignment: Alignment.center, + padding: const EdgeInsets.all(16), + child: Card( + padding: const EdgeInsets.all(16.0), + child: Form( + onSubmit: (context, values) async { + try { + isLoading.value = true; + await scrobblerNotifier.login( + values[usernameKey].trim(), + values[passwordKey], + ); + if (context.mounted) { + context.back(); + } + } catch (e) { + if (context.mounted) { + showPromptDialog( + context: context, + title: context.l10n.error("Authentication failed"), + message: e.toString(), + cancelText: null, + ); + } + } finally { + isLoading.value = false; + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 10, + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30), + color: const Color.fromARGB(255, 186, 0, 0), + ), + padding: const EdgeInsets.all(12), + child: const Icon( + SpotubeIcons.lastFm, + color: Colors.white, + size: 60, + ), ), - padding: const EdgeInsets.all(12), - child: const Icon( - SpotubeIcons.lastFm, - color: Colors.white, - size: 60, - ), - ), - Text( - "last.fm", - style: theme.textTheme.titleLarge, - ), - const SizedBox(height: 10), - Text(context.l10n.login_with_your_lastfm), - const SizedBox(height: 10), - AutofillGroup( - child: Column( - children: [ - TextFormField( - autofillHints: const [ - AutofillHints.username, - AutofillHints.email, - ], - controller: username, - validator: ValidationBuilder().required().build(), - decoration: InputDecoration( - labelText: context.l10n.username, - ), - ), - const SizedBox(height: 10), - TextFormField( - autofillHints: const [ - AutofillHints.password, - ], - controller: password, - validator: ValidationBuilder().required().build(), - obscureText: !passwordVisible.value, - decoration: InputDecoration( - labelText: context.l10n.password, - suffixIcon: IconButton( - icon: Icon( - passwordVisible.value - ? SpotubeIcons.eye - : SpotubeIcons.noEye, - ), - onPressed: () => passwordVisible.value = - !passwordVisible.value, + const Text("last.fm").h3(), + Text(context.l10n.login_with_your_lastfm), + AutofillGroup( + child: Column( + spacing: 10, + children: [ + FormField( + label: Text(context.l10n.username), + key: usernameKey, + validator: const NotEmptyValidator(), + child: TextField( + autofillHints: const [ + AutofillHints.username, + AutofillHints.email, + ], + placeholder: Text(context.l10n.username), ), ), - ), - ], + FormField( + key: passwordKey, + validator: const NotEmptyValidator(), + label: Text(context.l10n.password), + child: TextField( + autofillHints: const [ + AutofillHints.password, + ], + obscureText: !passwordVisible.value, + placeholder: Text(context.l10n.password), + trailing: IconButton.ghost( + icon: Icon( + passwordVisible.value + ? SpotubeIcons.eye + : SpotubeIcons.noEye, + ), + onPressed: () => passwordVisible.value = + !passwordVisible.value, + ), + ), + ), + ], + ), ), - ), - const SizedBox(height: 10), - FilledButton( - onPressed: isLoading.value - ? null - : () async { - try { - isLoading.value = true; - if (formKey.currentState?.validate() != true) { - return; - } - await scrobblerNotifier.login( - username.text.trim(), - password.text, - ); - router.pop(); - } catch (e) { - if (context.mounted) { - showPromptDialog( - context: context, - title: context.l10n - .error("Authentication failed"), - message: e.toString(), - cancelText: null, - ); - } - } finally { - isLoading.value = false; - } - }, - child: Text(context.l10n.login), - ), - ], + FormErrorBuilder(builder: (context, errors, child) { + return Button.primary( + onPressed: () => context.submitForm(), + enabled: errors.isEmpty && !isLoading.value, + child: Text(context.l10n.login), + ); + }), + ], + ), ), ), ), ), - ), + ], ), ); } diff --git a/lib/pages/library/library.dart b/lib/pages/library/library.dart index a0bc1bb7..172d9af3 100644 --- a/lib/pages/library/library.dart +++ b/lib/pages/library/library.dart @@ -1,58 +1,87 @@ -import 'package:flutter/material.dart' hide Image; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart' show Badge; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'package:spotube/modules/library/user_local_tracks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/collections/side_bar_tiles.dart'; +import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/modules/library/user_albums.dart'; -import 'package:spotube/modules/library/user_artists.dart'; -import 'package:spotube/modules/library/user_downloads.dart'; -import 'package:spotube/modules/library/user_playlists.dart'; -import 'package:spotube/components/themed_button_tab_bar.dart'; +import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; +@RoutePage() class LibraryPage extends HookConsumerWidget { - static const name = "library"; - const LibraryPage({super.key}); + @override Widget build(BuildContext context, ref) { final downloadingCount = ref.watch(downloadManagerProvider).$downloadCount; + final router = context.watchRouter; + final sidebarLibraryTileList = useMemoized( + () => [ + ...getSidebarLibraryTileList(context.l10n), + SideBarTiles( + id: "downloads", + pathPrefix: "library/downloads", + title: context.l10n.downloads, + route: const UserDownloadsRoute(), + icon: SpotubeIcons.download, + ), + ], + [context.l10n], + ); + final index = sidebarLibraryTileList.indexWhere( + (e) => router.currentPath.startsWith(e.pathPrefix), + ); - return DefaultTabController( - length: 5, + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + context.navigateTo(const HomeRoute()); + }, child: SafeArea( bottom: false, - child: Scaffold( - appBar: PageWindowTitleBar( - centerTitle: true, - leading: ThemedButtonsTabBar( - tabs: [ - Tab(text: " ${context.l10n.playlists} "), - Tab(text: " ${context.l10n.local_tab} "), - Tab( - child: Badge( - isLabelVisible: downloadingCount > 0, - label: Text(downloadingCount.toString()), - child: Text(" ${context.l10n.downloads} "), + child: LayoutBuilder(builder: (context, constraints) { + return Scaffold( + headers: [ + if (constraints.smAndDown) + TitleBar( + automaticallyImplyLeading: false, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: TabList( + index: index, + onChanged: (index) { + context.navigateTo(sidebarLibraryTileList[index].route); + }, + children: [ + for (final tile in sidebarLibraryTileList) + TabItem( + child: Badge( + isLabelVisible: tile.id == 'downloads' && + downloadingCount > 0, + label: Text(downloadingCount.toString()), + child: Text(tile.title), + ), + ), + ], + ), ), + ) + else + const TitleBar( + automaticallyImplyLeading: false, + backgroundColor: Colors.transparent, + surfaceBlur: 0, + height: 32, ), - Tab(text: " ${context.l10n.artists} "), - Tab(text: " ${context.l10n.albums} "), - ], - ), - leadingWidth: double.infinity, - ), - body: const TabBarView( - children: [ - UserPlaylists(), - UserLocalTracks(), - UserDownloads(), - UserArtists(), - UserAlbums(), + const Gap(10), ], - ), - ), + child: const AutoRouter(), + ); + }), ), ); } diff --git a/lib/pages/library/local_folder.dart b/lib/pages/library/local_folder.dart deleted file mode 100644 index c2848b24..00000000 --- a/lib/pages/library/local_folder.dart +++ /dev/null @@ -1,375 +0,0 @@ -import 'dart:io'; -import 'dart:math'; - -import 'package:collection/collection.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:fuzzywuzzy/fuzzywuzzy.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/extensions/string.dart'; -import 'package:spotube/modules/library/local_folder/cache_export_dialog.dart'; -import 'package:spotube/modules/library/user_local_tracks.dart'; -import 'package:spotube/components/expandable_search/expandable_search.dart'; -import 'package:spotube/components/fallbacks/not_found.dart'; -import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/components/sort_tracks_dropdown.dart'; -import 'package:spotube/components/track_tile/track_tile.dart'; -import 'package:spotube/extensions/artist_simple.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/local_track.dart'; -import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/service_utils.dart'; - -class LocalLibraryPage extends HookConsumerWidget { - static const name = "local_library_page"; - - final String location; - final bool isDownloads; - final bool isCache; - const LocalLibraryPage( - this.location, { - super.key, - this.isDownloads = false, - this.isCache = false, - }); - - Future playLocalTracks( - WidgetRef ref, - List tracks, { - LocalTrack? currentTrack, - }) async { - final playlist = ref.read(audioPlayerProvider); - final playback = ref.read(audioPlayerProvider.notifier); - currentTrack ??= tracks.first; - final isPlaylistPlaying = playlist.containsTracks(tracks); - if (!isPlaylistPlaying) { - var indexWhere = tracks.indexWhere((s) => s.id == currentTrack?.id); - await playback.load( - tracks, - initialIndex: indexWhere, - autoPlay: true, - ); - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != playlist.activeTrack?.id) { - await playback.jumpToTrack(currentTrack); - } - } - - @override - Widget build(BuildContext context, ref) { - final ThemeData(:textTheme) = Theme.of(context); - - final sortBy = useState(SortBy.none); - final playlist = ref.watch(audioPlayerProvider); - final trackSnapshot = ref.watch(localTracksProvider); - final isPlaylistPlaying = playlist.containsTracks( - trackSnapshot.asData?.value.values.flattened.toList() ?? []); - - final searchController = useTextEditingController(); - useValueListenable(searchController); - final searchFocus = useFocusNode(); - final isFiltering = useState(false); - - final controller = useScrollController(); - - final directorySize = useMemoized(() async { - final dir = Directory(location); - final files = await dir.list(recursive: true).toList(); - - final filesLength = - await Future.wait(files.whereType().map((e) => e.length())); - - return (filesLength.sum.toInt() / pow(10, 9)).toStringAsFixed(2); - }, [location]); - - return SafeArea( - bottom: false, - child: Scaffold( - appBar: PageWindowTitleBar( - leading: const BackButton(), - centerTitle: true, - title: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isDownloads - ? context.l10n.downloads - : isCache - ? context.l10n.cache_folder.capitalize() - : location, - style: textTheme.titleLarge, - ), - FutureBuilder( - future: directorySize, - builder: (context, snapshot) { - return Text( - "${(snapshot.data ?? 0)} GB", - style: textTheme.labelSmall, - ); - }, - ) - ], - ), - backgroundColor: Colors.transparent, - actions: [ - if (isCache) ...[ - IconButton( - iconSize: 16, - icon: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(SpotubeIcons.delete), - Text( - context.l10n.clear_cache, - style: textTheme.labelSmall, - ) - ], - ), - onPressed: () async { - final accepted = await showDialog( - context: context, - builder: (context) => AlertDialog.adaptive( - title: Text(context.l10n.clear_cache_confirmation), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(false); - }, - child: Text(context.l10n.decline), - ), - TextButton( - onPressed: () async { - Navigator.of(context).pop(true); - }, - child: Text(context.l10n.accept), - ), - ], - ), - ); - - if (accepted ?? false) return; - - final cacheDir = Directory( - await UserPreferencesNotifier.getMusicCacheDir(), - ); - - if (cacheDir.existsSync()) { - await cacheDir.delete(recursive: true); - } - }, - ), - IconButton( - iconSize: 16, - icon: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(SpotubeIcons.export), - Text( - context.l10n.export, - style: textTheme.labelSmall, - ) - ], - ), - onPressed: () async { - final exportPath = - await FilePicker.platform.getDirectoryPath(); - - if (exportPath == null) return; - final exportDirectory = Directory(exportPath); - - if (!exportDirectory.existsSync()) { - await exportDirectory.create(recursive: true); - } - - final cacheDir = Directory( - await UserPreferencesNotifier.getMusicCacheDir()); - - if (!context.mounted) return; - await showDialog( - context: context, - builder: (context) { - return LocalFolderCacheExportDialog( - cacheDir: cacheDir, - exportDir: exportDirectory, - ); - }, - ); - }, - ), - ] - ], - ), - body: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - const SizedBox(width: 5), - FilledButton( - onPressed: trackSnapshot.asData?.value != null - ? () async { - if (trackSnapshot.asData?.value.isNotEmpty == - true) { - if (!isPlaylistPlaying) { - await playLocalTracks( - ref, - trackSnapshot.asData!.value[location] ?? [], - ); - } - } - } - : null, - child: Row( - children: [ - Text(context.l10n.play), - Icon( - isPlaylistPlaying - ? SpotubeIcons.stop - : SpotubeIcons.play, - ) - ], - ), - ), - const Spacer(), - ExpandableSearchButton( - isFiltering: isFiltering.value, - onPressed: (value) => isFiltering.value = value, - searchFocus: searchFocus, - ), - const SizedBox(width: 10), - SortTracksDropdown( - value: sortBy.value, - onChanged: (value) { - sortBy.value = value; - }, - ), - const SizedBox(width: 5), - FilledButton( - child: const Icon(SpotubeIcons.refresh), - onPressed: () { - ref.invalidate(localTracksProvider); - }, - ) - ], - ), - ), - ExpandableSearchField( - searchController: searchController, - searchFocus: searchFocus, - isFiltering: isFiltering.value, - onChangeFiltering: (value) => isFiltering.value = value, - ), - trackSnapshot.when( - data: (tracks) { - final sortedTracks = useMemoized(() { - return ServiceUtils.sortTracks( - tracks[location] ?? [], sortBy.value); - }, [sortBy.value, tracks]); - - final filteredTracks = useMemoized(() { - if (searchController.text.isEmpty) { - return sortedTracks; - } - return sortedTracks - .map((e) => ( - weightedRatio( - "${e.name} - ${e.artists?.asString() ?? ""}", - searchController.text, - ), - e, - )) - .toList() - .sorted( - (a, b) => b.$1.compareTo(a.$1), - ) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList() - .toList(); - }, [searchController.text, sortedTracks]); - - if (!trackSnapshot.isLoading && filteredTracks.isEmpty) { - return const Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [NotFound()], - ), - ); - } - - return Expanded( - child: RefreshIndicator( - onRefresh: () async { - ref.invalidate(localTracksProvider); - }, - child: InterScrollbar( - controller: controller, - child: Skeletonizer( - enabled: trackSnapshot.isLoading, - child: ListView.builder( - controller: controller, - physics: const AlwaysScrollableScrollPhysics(), - itemCount: trackSnapshot.isLoading - ? 5 - : filteredTracks.length, - itemBuilder: (context, index) { - if (trackSnapshot.isLoading) { - return TrackTile( - playlist: playlist, - track: FakeData.track, - index: index, - ); - } - - final track = filteredTracks[index]; - return TrackTile( - index: index, - playlist: playlist, - track: track, - userPlaylist: false, - onTap: () async { - await playLocalTracks( - ref, - sortedTracks, - currentTrack: track, - ); - }, - ); - }, - ), - ), - ), - ), - ); - }, - loading: () => Expanded( - child: Skeletonizer( - enabled: true, - child: ListView.builder( - itemCount: 5, - itemBuilder: (context, index) => TrackTile( - track: FakeData.track, - index: index, - playlist: playlist, - ), - ), - ), - ), - error: (error, stackTrace) => - Text(error.toString() + stackTrace.toString()), - ) - ], - )), - ); - } -} diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index b62013c5..c0b77452 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -1,12 +1,16 @@ import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; + import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotify_markets.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/modules/library/playlist_generate/multi_select_field.dart'; +import 'package:spotube/components/button/back_button.dart'; +import 'package:spotube/components/ui/button_tile.dart'; + import 'package:spotube/modules/library/playlist_generate/recommendation_attribute_dials.dart'; import 'package:spotube/modules/library/playlist_generate/recommendation_attribute_fields.dart'; import 'package:spotube/modules/library/playlist_generate/seeds_multi_autocomplete.dart'; @@ -20,9 +24,11 @@ import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:auto_route/auto_route.dart'; const RecommendationAttribute zeroValues = (min: 0, target: 0, max: 0); +@RoutePage() class PlaylistGeneratorPage extends HookConsumerWidget { static const name = "playlist_generator"; @@ -33,7 +39,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { final spotify = ref.watch(spotifyProvider); final theme = Theme.of(context); - final textTheme = theme.textTheme; + final typography = theme.typography; final preferences = ref.watch(userPreferencesProvider); final genresCollection = ref.watch(categoryGenresProvider); @@ -59,14 +65,11 @@ class PlaylistGeneratorPage extends HookConsumerWidget { final artistAutoComplete = SeedsMultiAutocomplete( seeds: artists, enabled: enabled, - inputDecoration: InputDecoration( - labelText: context.l10n.artists, - labelStyle: textTheme.titleMedium, - helperText: context.l10n.select_up_to_count_type( - leftSeedCount, - context.l10n.artists, - ), - ), + label: Text(context.l10n.artists), + placeholder: Text(context.l10n.select_up_to_count_type( + leftSeedCount, + context.l10n.artists, + )), fetchSeeds: (textEditingValue) => spotify.search .get( textEditingValue.text, @@ -83,15 +86,15 @@ class PlaylistGeneratorPage extends HookConsumerWidget { ) .toList(), ), - autocompleteOptionBuilder: (option, onSelected) => ListTile( - leading: CircleAvatar( - backgroundImage: UniversalImage.imageProvider( + autocompleteOptionBuilder: (option, onSelected) => ButtonTile( + leading: Avatar( + initials: "O", + provider: UniversalImage.imageProvider( option.images.asUrlString( placeholder: ImagePlaceholder.artist, ), ), ), - horizontalTitleGap: 20, title: Text(option.name!), subtitle: option.genres?.isNotEmpty != true ? null @@ -101,34 +104,36 @@ class PlaylistGeneratorPage extends HookConsumerWidget { children: option.genres!.mapIndexed( (index, genre) { return Chip( - label: Text(genre), - labelStyle: textTheme.bodySmall?.copyWith( - color: theme.colorScheme.secondary, - fontWeight: FontWeight.w600, - ), - side: BorderSide.none, - backgroundColor: theme.colorScheme.secondaryContainer, + style: ButtonVariance.secondary, + child: Text(genre), ); }, ).toList(), ), - onTap: () => onSelected(option), + onPressed: () => onSelected(option), + style: ButtonVariance.ghost, ), displayStringForOption: (option) => option.name!, - selectedSeedBuilder: (artist) => Chip( - avatar: CircleAvatar( - backgroundImage: UniversalImage.imageProvider( + selectedSeedBuilder: (artist) => OutlineBadge( + leading: Avatar( + initials: artist.name!.substring(0, 1), + size: 30, + provider: UniversalImage.imageProvider( artist.images.asUrlString( placeholder: ImagePlaceholder.artist, ), ), ), - label: Text(artist.name!), - onDeleted: () { - artists.value = [ - ...artists.value..removeWhere((element) => element.id == artist.id) - ]; - }, + trailing: IconButton.ghost( + icon: const Icon(SpotubeIcons.close), + onPressed: () { + artists.value = [ + ...artists.value + ..removeWhere((element) => element.id == artist.id) + ]; + }, + ), + child: Text(artist.name!), ), ); @@ -136,14 +141,11 @@ class PlaylistGeneratorPage extends HookConsumerWidget { seeds: tracks, enabled: enabled, selectedItemDisplayType: SelectedItemDisplayType.list, - inputDecoration: InputDecoration( - labelText: context.l10n.tracks, - labelStyle: textTheme.titleMedium, - helperText: context.l10n.select_up_to_count_type( - leftSeedCount, - context.l10n.tracks, - ), - ), + label: Text(context.l10n.tracks), + placeholder: Text(context.l10n.select_up_to_count_type( + leftSeedCount, + context.l10n.tracks, + )), fetchSeeds: (textEditingValue) => spotify.search .get( textEditingValue.text, @@ -160,22 +162,23 @@ class PlaylistGeneratorPage extends HookConsumerWidget { ) .toList(), ), - autocompleteOptionBuilder: (option, onSelected) => ListTile( - leading: CircleAvatar( - backgroundImage: UniversalImage.imageProvider( + autocompleteOptionBuilder: (option, onSelected) => ButtonTile( + leading: Avatar( + initials: option.name!.substring(0, 1), + provider: UniversalImage.imageProvider( (option.album?.images).asUrlString( placeholder: ImagePlaceholder.artist, ), ), ), - horizontalTitleGap: 20, title: Text(option.name!), subtitle: Text( option.artists?.map((e) => e.name).join(", ") ?? option.album?.name ?? "", ), - onTap: () => onSelected(option), + onPressed: () => onSelected(option), + style: ButtonVariance.ghost, ), displayStringForOption: (option) => option.name!, selectedSeedBuilder: (option) => SimpleTrackTile( @@ -188,63 +191,110 @@ class PlaylistGeneratorPage extends HookConsumerWidget { ), ); - final genreSelector = MultiSelectField( - options: genresCollection.asData?.value ?? [], - selectedOptions: genres.value, - getValueForOption: (option) => option, - onSelected: (value) { - genres.value = value; + final genreSelector = MultiSelect( + value: genres.value, + onChanged: (value) { + if (!enabled) return; + genres.value = value?.toList() ?? []; }, - dialogTitle: Text(context.l10n.select_genres), - label: Text(context.l10n.add_genres), - helperText: context.l10n.select_up_to_count_type( - leftSeedCount, - context.l10n.genre, + itemBuilder: (context, item) => Text(item), + popoverAlignment: Alignment.bottomCenter, + popupConstraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * .8, ), - enabled: enabled, + placeholder: Text( + context.l10n.select_up_to_count_type( + leftSeedCount, + context.l10n.genre, + ), + ), + popup: SelectPopup.builder( + searchPlaceholder: Text(context.l10n.select_genres), + builder: (context, searchQuery) { + final filteredGenres = searchQuery?.isNotEmpty != true + ? genresCollection.asData?.value ?? [] + : genresCollection.asData?.value + .where( + (item) => item + .toLowerCase() + .contains(searchQuery!.toLowerCase()), + ) + .toList() ?? + []; + + return SelectItemBuilder( + childCount: filteredGenres.length, + builder: (context, index) { + final option = filteredGenres[index]; + + return SelectItemButton( + value: option, + child: Text(option), + ); + }, + ); + }, + ).call, ); + final countrySelector = ValueListenableBuilder( valueListenable: market, builder: (context, value, _) { - return DropdownButtonFormField( - decoration: InputDecoration( - labelText: context.l10n.country, - labelStyle: textTheme.titleMedium, - ), - isExpanded: true, - items: spotifyMarkets - .map( - (country) => DropdownMenuItem( - value: country.$1, - child: Text(country.$2), - ), - ) - .toList(), + return Select( + placeholder: Text(context.l10n.country), value: market.value, onChanged: (value) { market.value = value!; }, + popupConstraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * .8, + ), + popoverAlignment: Alignment.bottomCenter, + itemBuilder: (context, value) => Text(value.name), + popup: SelectPopup.builder( + searchPlaceholder: Text(context.l10n.search), + builder: (context, searchQuery) { + final filteredMarkets = searchQuery == null || searchQuery.isEmpty + ? spotifyMarkets + : spotifyMarkets + .where( + (item) => item.$1.name + .toLowerCase() + .contains(searchQuery.toLowerCase()), + ) + .toList(); + + return SelectItemBuilder( + childCount: filteredMarkets.length, + builder: (context, index) { + return SelectItemButton( + value: filteredMarkets[index].$1, + child: Text(filteredMarkets[index].$2), + ); + }, + ); + }, + ).call, ); }, ); final controller = useScrollController(); - return Scaffold( - appBar: PageWindowTitleBar( - leading: const BackButton(), - title: Text(context.l10n.generate_playlist), - centerTitle: true, - ), - body: Scrollbar( - controller: controller, - child: Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: Breakpoints.lg), - child: SliderTheme( - data: const SliderThemeData( - overlayShape: RoundSliderOverlayShape(), - ), + return SafeArea( + bottom: false, + child: Scaffold( + headers: [ + TitleBar( + leading: const [BackButton()], + title: Text(context.l10n.generate), + ) + ], + child: Scrollbar( + controller: controller, + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: Breakpoints.lg), child: SafeArea( child: LayoutBuilder(builder: (context, constrains) { return ScrollConfiguration( @@ -262,35 +312,36 @@ class PlaylistGeneratorPage extends HookConsumerWidget { children: [ Text( context.l10n.number_of_tracks_generate, - style: textTheme.titleMedium, + style: typography.semiBold, ), Row( + spacing: 5, children: [ Container( width: 40, height: 40, alignment: Alignment.center, decoration: BoxDecoration( - color: theme.colorScheme.primary, + color: theme.colorScheme.primary + .withAlpha(25), shape: BoxShape.circle, ), child: Text( value.round().toString(), - style: textTheme.bodyLarge?.copyWith( - color: theme - .colorScheme.primaryContainer, + style: typography.large.copyWith( + color: theme.colorScheme.primary, ), ), ), Expanded( child: Slider( - value: value.toDouble(), + value: SliderValue.single( + value.toDouble()), min: 10, max: 100, divisions: 9, - label: value.round().toString(), onChanged: (value) { - limit.value = value.round(); + limit.value = value.value.round(); }, ), ) @@ -617,33 +668,37 @@ class PlaylistGeneratorPage extends HookConsumerWidget { ); }, ), - 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 routeState = - GeneratePlaylistProviderInput( - seedArtists: artists.value - .map((a) => a.id!) - .toList(), - seedTracks: - tracks.value.map((t) => t.id!).toList(), - seedGenres: genres.value, - limit: limit.value, - max: max.value, - min: min.value, - target: target.value, - ); - GoRouter.of(context).push( - "/library/generate/result", - extra: routeState, - ); - }, + const Gap(20), + Center( + child: Button.primary( + leading: const Icon(SpotubeIcons.magic), + onPressed: artists.value.isEmpty && + tracks.value.isEmpty && + genres.value.isEmpty + ? null + : () { + final routeState = + GeneratePlaylistProviderInput( + seedArtists: artists.value + .map((a) => a.id!) + .toList(), + seedTracks: tracks.value + .map((t) => t.id!) + .toList(), + seedGenres: genres.value, + limit: limit.value, + max: max.value, + min: min.value, + target: target.value, + ); + context.navigateTo( + PlaylistGenerateResultRoute( + state: routeState, + ), + ); + }, + child: Text(context.l10n.generate), + ), ), ], ), diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index 3bdc3b52..9e6f2987 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -1,19 +1,22 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/button/back_button.dart'; import 'package:spotube/modules/library/playlist_generate/simple_track_tile.dart'; import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/spotify/recommendation_seeds.dart'; -import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/spotify/spotify.dart'; +@RoutePage() class PlaylistGenerateResultPage extends HookConsumerWidget { static const name = "playlist_generate_result"; @@ -26,8 +29,6 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final router = GoRouter.of(context); - final scaffoldMessenger = ScaffoldMessenger.of(context); final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final generatedPlaylist = ref.watch(generatePlaylistProvider(state)); @@ -47,197 +48,225 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { final isAllTrackSelected = selectedTracks.value.length == (generatedPlaylist.asData?.value.length ?? 0); - return Scaffold( - appBar: const PageWindowTitleBar(leading: BackButton()), - body: generatedPlaylist.isLoading - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - Text(context.l10n.generating_playlist), - ], - ), - ) - : Padding( - padding: const EdgeInsets.all(8.0), - child: ListView( - children: [ - GridView( - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - mainAxisExtent: 32, - ), - shrinkWrap: true, - children: [ - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.play), - label: Text(context.l10n.play), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - await playlistNotifier.load( - generatedPlaylist.asData!.value - .where( - (e) => selectedTracks.value - .contains(e.id!), - ) - .toList(), - autoPlay: true, - ); - }, + return SafeArea( + bottom: false, + child: Scaffold( + headers: const [ + TitleBar(leading: [BackButton()]) + ], + child: generatedPlaylist.isLoading + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + Text(context.l10n.generating_playlist), + ], + ), + ) + : Padding( + padding: const EdgeInsets.all(8.0), + child: ListView( + children: [ + GridView( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + mainAxisExtent: 32, ), - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.queueAdd), - label: Text(context.l10n.add_to_queue), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - await playlistNotifier.addTracks( - generatedPlaylist.asData!.value.where( - (e) => selectedTracks.value.contains(e.id!), - ), - ); - if (context.mounted) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.add_count_to_queue( - selectedTracks.value.length, - ), - ), - ), - ); - } - }, - ), - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.addFilled), - label: Text(context.l10n.create_a_playlist), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - final playlist = await showDialog( - context: context, - builder: (context) => PlaylistCreateDialog( - trackIds: selectedTracks.value, - ), - ); - - if (playlist != null) { - router.goNamed( - PlaylistPage.name, - pathParameters: { - "id": playlist.id!, - }, - extra: playlist, - ); - } - }, - ), - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.playlistAdd), - label: Text(context.l10n.add_to_playlist), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - final hasAdded = await showDialog( - context: context, - builder: (context) => PlaylistAddTrackDialog( - openFromPlaylist: null, - tracks: selectedTracks.value - .map( - (e) => generatedPlaylist.asData!.value - .firstWhere( - (element) => element.id == e, - ), + shrinkWrap: true, + children: [ + Button.primary( + leading: const Icon(SpotubeIcons.play), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + await playlistNotifier.load( + generatedPlaylist.asData!.value + .where( + (e) => selectedTracks.value + .contains(e.id!), ) .toList(), - ), - ); - - if (context.mounted && hasAdded == true) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.add_count_to_playlist( - selectedTracks.value.length, - ), - ), + autoPlay: true, + ); + }, + child: Text(context.l10n.play), + ), + Button.primary( + leading: const Icon(SpotubeIcons.queueAdd), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + await playlistNotifier.addTracks( + generatedPlaylist.asData!.value.where( + (e) => + selectedTracks.value.contains(e.id!), ), ); - } - }, - ) - ], - ), - const SizedBox(height: 16), - if (generatedPlaylist.asData?.value != null) - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - context.l10n.selected_count_tracks( - selectedTracks.value.length, - ), + if (context.mounted) { + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + child: Text( + context.l10n.add_count_to_queue( + selectedTracks.value.length, + ), + ), + ); + }, + ); + } + }, + child: Text(context.l10n.add_to_queue), ), - ElevatedButton.icon( - onPressed: () { - if (isAllTrackSelected) { - selectedTracks.value = []; - } else { - selectedTracks.value = generatedPlaylist - .asData?.value - .map((e) => e.id!) - .toList() ?? - []; - } - }, - icon: const Icon(SpotubeIcons.selectionCheck), - label: Text( - isAllTrackSelected - ? context.l10n.deselect_all - : context.l10n.select_all, - ), + Button.primary( + leading: const Icon(SpotubeIcons.addFilled), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + final playlist = await showDialog( + context: context, + builder: (context) => PlaylistCreateDialog( + trackIds: selectedTracks.value, + ), + ); + + if (playlist != null && context.mounted) { + context.navigateTo( + PlaylistRoute( + id: playlist.id!, + playlist: playlist, + ), + ); + } + }, + child: Text(context.l10n.create_a_playlist), ), + Button.primary( + leading: const Icon(SpotubeIcons.playlistAdd), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + final hasAdded = await showDialog( + context: context, + builder: (context) => + PlaylistAddTrackDialog( + openFromPlaylist: null, + tracks: selectedTracks.value + .map( + (e) => generatedPlaylist + .asData!.value + .firstWhere( + (element) => element.id == e, + ), + ) + .toList(), + ), + ); + + if (context.mounted && hasAdded == true) { + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + child: Text( + context.l10n.add_count_to_playlist( + selectedTracks.value.length, + ), + ), + ); + }, + ); + } + }, + child: Text(context.l10n.add_to_playlist), + ) ], ), - const SizedBox(height: 8), - Card( - margin: const EdgeInsets.all(0), - child: SafeArea( + const SizedBox(height: 16), + if (generatedPlaylist.asData?.value != null) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.l10n.selected_count_tracks( + selectedTracks.value.length, + ), + ), + Button.secondary( + onPressed: () { + if (isAllTrackSelected) { + selectedTracks.value = []; + } else { + selectedTracks.value = generatedPlaylist + .asData?.value + .map((e) => e.id!) + .toList() ?? + []; + } + }, + leading: const Icon(SpotubeIcons.selectionCheck), + child: Text( + isAllTrackSelected + ? context.l10n.deselect_all + : context.l10n.select_all, + ), + ), + ], + ), + const SizedBox(height: 8), + SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ for (final track in generatedPlaylist.asData?.value ?? []) - CheckboxListTile( - value: selectedTracks.value.contains(track.id), - onChanged: (value) { - if (value == true) { - selectedTracks.value.add(track.id!); - } else { - selectedTracks.value.remove(track.id); - } - selectedTracks.value = - selectedTracks.value.toList(); - }, - controlAffinity: ListTileControlAffinity.leading, - contentPadding: EdgeInsets.zero, - dense: true, - title: SimpleTrackTile(track: track), + Row( + spacing: 5, + children: [ + Checkbox( + state: selectedTracks.value.contains(track.id) + ? CheckboxState.checked + : CheckboxState.unchecked, + onChanged: (value) { + if (value == CheckboxState.checked) { + selectedTracks.value.add(track.id!); + } else { + selectedTracks.value.remove(track.id); + } + selectedTracks.value = + selectedTracks.value.toList(); + }, + ), + Expanded( + child: GestureDetector( + onTap: () { + selectedTracks.value.contains(track.id) + ? selectedTracks.value + .remove(track.id) + : selectedTracks.value.add(track.id!); + selectedTracks.value = + selectedTracks.value.toList(); + }, + child: SimpleTrackTile(track: track), + ), + ), + ], ) ], ), ), - ), - ], + ], + ), ), - ), + ), ); } } diff --git a/lib/pages/library/user_albums.dart b/lib/pages/library/user_albums.dart new file mode 100644 index 00000000..60ba7319 --- /dev/null +++ b/lib/pages/library/user_albums.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_undraw/flutter_undraw.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Image; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:collection/collection.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; + +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/playbutton_view/playbutton_view.dart'; +import 'package:spotube/modules/album/album_card.dart'; +import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:auto_route/auto_route.dart'; + +@RoutePage() +class UserAlbumsPage extends HookConsumerWidget { + static const name = 'user_albums'; + const UserAlbumsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final auth = ref.watch(authenticationProvider); + final albumsQuery = ref.watch(favoriteAlbumsProvider); + final albumsQueryNotifier = ref.watch(favoriteAlbumsProvider.notifier); + + final controller = useScrollController(); + + final searchText = useState(''); + + final albums = useMemoized(() { + if (searchText.value.isEmpty) { + return albumsQuery.asData?.value.items ?? []; + } + return albumsQuery.asData?.value.items + .map((e) => ( + weightedRatio(e.name!, searchText.value), + e, + )) + .sorted((a, b) => b.$1.compareTo(a.$1)) + .where((e) => e.$1 > 50) + .map((e) => e.$2) + .toList() ?? + []; + }, [albumsQuery.asData?.value, searchText.value]); + + if (auth.asData?.value == null) { + return const AnonymousFallback(); + } + + return SafeArea( + bottom: false, + child: Scaffold( + child: material.RefreshIndicator.adaptive( + onRefresh: () async { + ref.invalidate(favoriteAlbumsProvider); + }, + child: InterScrollbar( + controller: controller, + child: CustomScrollView( + controller: controller, + slivers: [ + SliverAppBar( + automaticallyImplyLeading: false, + backgroundColor: Theme.of(context).colorScheme.background, + floating: true, + flexibleSpace: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: SizedBox( + height: 48, + child: TextField( + onChanged: (value) => searchText.value = value, + leading: const Icon(SpotubeIcons.filter), + placeholder: Text(context.l10n.filter_artist), + ), + ), + ), + ), + const SliverGap(10), + if (albums.isEmpty && + !albumsQuery.isLoading && + searchText.value.isEmpty) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8), + sliver: SliverToBoxAdapter( + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 10, + children: [ + Undraw( + height: 200 * context.theme.scaling, + illustration: UndrawIllustration.followMeDrone, + color: Theme.of(context).colorScheme.primary, + ), + Text( + context.l10n.not_following_artists, + textAlign: TextAlign.center, + ).muted().small() + ], + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8), + sliver: PlaybuttonView( + controller: controller, + itemCount: albums.length, + hasMore: albumsQuery.asData?.value.hasMore == true, + isLoading: albumsQuery.isLoading, + onRequestMore: albumsQueryNotifier.fetchMore, + gridItemBuilder: (context, index) => AlbumCard( + albums[index], + ), + listItemBuilder: (context, index) => + AlbumCard.tile(albums[index]), + ), + ), + const SliverSafeArea(sliver: SliverGap(10)), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/library/user_artists.dart b/lib/pages/library/user_artists.dart new file mode 100644 index 00000000..5f304f5e --- /dev/null +++ b/lib/pages/library/user_artists.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_undraw/flutter_undraw.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotube/collections/fake.dart'; + +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; +import 'package:spotube/modules/artist/artist_card.dart'; +import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/waypoint.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:auto_route/auto_route.dart'; + +@RoutePage() +class UserArtistsPage extends HookConsumerWidget { + static const name = 'user_artists'; + const UserArtistsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final auth = ref.watch(authenticationProvider); + + final artistQuery = ref.watch(followedArtistsProvider); + final artistQueryNotifier = ref.watch(followedArtistsProvider.notifier); + + final searchText = useState(''); + + final filteredArtists = useMemoized(() { + final artists = artistQuery.asData?.value.items ?? []; + + if (searchText.value.isEmpty) { + return artists.toList(); + } + return artists + .map((e) => ( + weightedRatio(e.name!, searchText.value), + e, + )) + .sorted((a, b) => b.$1.compareTo(a.$1)) + .where((e) => e.$1 > 50) + .map((e) => e.$2) + .toList(); + }, [artistQuery.asData?.value.items, searchText.value]); + + final controller = useScrollController(); + + if (auth.asData?.value == null) { + return const AnonymousFallback(); + } + + return SafeArea( + bottom: false, + child: Scaffold( + child: material.RefreshIndicator.adaptive( + onRefresh: () async { + ref.invalidate(followedArtistsProvider); + }, + child: InterScrollbar( + controller: controller, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: CustomScrollView( + controller: controller, + slivers: [ + SliverAppBar( + automaticallyImplyLeading: false, + backgroundColor: Theme.of(context).colorScheme.background, + floating: true, + flexibleSpace: SizedBox( + height: 48, + child: TextField( + onChanged: (value) => searchText.value = value, + leading: const Icon(SpotubeIcons.filter), + placeholder: Text(context.l10n.filter_artist), + ), + ), + ), + const SliverGap(10), + if (filteredArtists.isNotEmpty || artistQuery.isLoading) + SliverLayoutBuilder(builder: (context, constrains) { + return SliverGrid.builder( + itemCount: filteredArtists.length + 1, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: constrains.smAndDown ? 225 : 250, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) { + if (filteredArtists.isNotEmpty && + index == filteredArtists.length) { + if (artistQuery.asData?.value.hasMore != true) { + return const SizedBox.shrink(); + } + + return Waypoint( + controller: controller, + isGrid: true, + onTouchEdge: artistQueryNotifier.fetchMore, + child: Skeletonizer( + enabled: true, + child: ArtistCard(FakeData.artist), + ), + ); + } + + return Skeletonizer( + enabled: artistQuery.isLoading, + child: ArtistCard( + filteredArtists.elementAtOrNull(index) ?? + FakeData.artist, + ), + ); + }, + ); + }) + else if (filteredArtists.isEmpty && + searchText.value.isEmpty && + !artistQuery.isLoading) + SliverToBoxAdapter( + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 10, + children: [ + Undraw( + height: 200 * context.theme.scaling, + illustration: UndrawIllustration.followMeDrone, + color: Theme.of(context).colorScheme.primary, + ), + Text( + context.l10n.not_following_artists, + textAlign: TextAlign.center, + ).muted().small() + ], + ), + ) + else + SliverToBoxAdapter( + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 10, + children: [ + Undraw( + height: 200 * context.theme.scaling, + illustration: UndrawIllustration.taken, + color: Theme.of(context).colorScheme.primary, + ), + Text( + context.l10n.nothing_found, + textAlign: TextAlign.center, + ).muted().small() + ], + ), + ), + const SliverSafeArea(sliver: SliverGap(10)), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/modules/library/user_downloads.dart b/lib/pages/library/user_downloads.dart similarity index 79% rename from lib/modules/library/user_downloads.dart rename to lib/pages/library/user_downloads.dart index 7fe9800c..1d8f560a 100644 --- a/lib/modules/library/user_downloads.dart +++ b/lib/pages/library/user_downloads.dart @@ -1,13 +1,16 @@ import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/modules/library/user_downloads/download_item.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:auto_route/auto_route.dart'; -class UserDownloads extends HookConsumerWidget { - const UserDownloads({super.key}); +@RoutePage() +class UserDownloadsPage extends HookConsumerWidget { + static const name = 'user_downloads'; + const UserDownloadsPage({super.key}); @override Widget build(BuildContext context, ref) { @@ -31,15 +34,10 @@ class UserDownloads extends HookConsumerWidget { context.l10n .currently_downloading(downloadManager.$downloadCount), maxLines: 1, - style: Theme.of(context).textTheme.titleMedium, - ), + ).semiBold(), ), const SizedBox(width: 10), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: Colors.red[50], - foregroundColor: Colors.red[400], - ), + Button.destructive( onPressed: downloadManager.$downloadCount == 0 ? null : downloadManager.cancelAll, diff --git a/lib/pages/library/user_local_tracks/local_folder.dart b/lib/pages/library/user_local_tracks/local_folder.dart new file mode 100644 index 00000000..a6f3ad51 --- /dev/null +++ b/lib/pages/library/user_local_tracks/local_folder.dart @@ -0,0 +1,413 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/material.dart' as material; +import 'package:collection/collection.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_undraw/flutter_undraw.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotube/collections/fake.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/button/back_button.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/string.dart'; +import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; +import 'package:spotube/modules/library/local_folder/cache_export_dialog.dart'; +import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart'; +import 'package:spotube/components/expandable_search/expandable_search.dart'; +import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; +import 'package:spotube/components/track_presentation/sort_tracks_dropdown.dart'; +import 'package:spotube/components/track_tile/track_tile.dart'; +import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/utils/service_utils.dart'; +import 'package:auto_route/auto_route.dart'; + +@RoutePage() +class LocalLibraryPage extends HookConsumerWidget { + static const name = "local_library_page"; + + final String location; + final bool isDownloads; + final bool isCache; + const LocalLibraryPage( + this.location, { + super.key, + this.isDownloads = false, + this.isCache = false, + }); + + Future playLocalTracks( + WidgetRef ref, + List tracks, { + LocalTrack? currentTrack, + }) async { + final playlist = ref.read(audioPlayerProvider); + final playback = ref.read(audioPlayerProvider.notifier); + currentTrack ??= tracks.first; + final isPlaylistPlaying = playlist.containsTracks(tracks); + if (!isPlaylistPlaying) { + var indexWhere = tracks.indexWhere((s) => s.id == currentTrack?.id); + await playback.load( + tracks, + initialIndex: indexWhere, + autoPlay: true, + ); + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != playlist.activeTrack?.id) { + await playback.jumpToTrack(currentTrack); + } + } + + @override + Widget build(BuildContext context, ref) { + final scale = context.theme.scaling; + + final sortBy = useState(SortBy.none); + final playlist = ref.watch(audioPlayerProvider); + final trackSnapshot = ref.watch(localTracksProvider); + final isPlaylistPlaying = playlist.containsTracks( + trackSnapshot.asData?.value.values.flattened.toList() ?? []); + + final searchController = useShadcnTextEditingController(); + useValueListenable(searchController); + final searchFocus = useFocusNode(); + final isFiltering = useState(false); + + final controller = useScrollController(); + + final directorySize = useMemoized(() async { + final dir = Directory(location); + final files = await dir.list(recursive: true).toList(); + + final filesLength = + await Future.wait(files.whereType().map((e) => e.length())); + + return (filesLength.sum.toInt() / pow(10, 9)).toStringAsFixed(2); + }, [location]); + + return SafeArea( + bottom: false, + child: Scaffold( + headers: [ + TitleBar( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 0, + ), + surfaceBlur: 0, + leading: const [BackButton()], + title: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isDownloads + ? context.l10n.downloads + : isCache + ? context.l10n.cache_folder.capitalize() + : location, + ), + FutureBuilder( + future: directorySize, + builder: (context, snapshot) { + return Text( + "${(snapshot.data ?? 0)} GB", + ).xSmall().muted(); + }, + ) + ], + ), + backgroundColor: Colors.transparent, + trailingGap: 10, + trailing: [ + if (isCache) ...[ + IconButton.outline( + size: ButtonSize.small, + icon: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(SpotubeIcons.delete), + Text(context.l10n.clear_cache) + ], + ).xSmall().iconSmall(), + onPressed: () async { + final accepted = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.l10n.clear_cache_confirmation), + actions: [ + Button.outline( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Text(context.l10n.decline), + ), + Button.destructive( + onPressed: () async { + Navigator.of(context).pop(true); + }, + child: Text(context.l10n.accept), + ), + ], + ), + ); + + if (accepted ?? false) return; + + final cacheDir = Directory( + await UserPreferencesNotifier.getMusicCacheDir(), + ); + + if (cacheDir.existsSync()) { + await cacheDir.delete(recursive: true); + } + }, + ), + IconButton.outline( + size: ButtonSize.small, + icon: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(SpotubeIcons.export), + Text( + context.l10n.export, + ) + ], + ).xSmall().iconSmall(), + onPressed: () async { + final exportPath = + await FilePicker.platform.getDirectoryPath(); + + if (exportPath == null) return; + final exportDirectory = Directory(exportPath); + + if (!exportDirectory.existsSync()) { + await exportDirectory.create(recursive: true); + } + + final cacheDir = Directory( + await UserPreferencesNotifier.getMusicCacheDir()); + + if (!context.mounted) return; + await showDialog( + context: context, + builder: (context) { + return LocalFolderCacheExportDialog( + cacheDir: cacheDir, + exportDir: exportDirectory, + ); + }, + ); + }, + ), + ] + ], + ), + ], + child: LayoutBuilder( + builder: (context, constraints) => Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const Gap(5), + Button.primary( + onPressed: trackSnapshot.asData?.value != null + ? () async { + if (trackSnapshot + .asData?.value.isNotEmpty == + true) { + if (!isPlaylistPlaying) { + await playLocalTracks( + ref, + trackSnapshot + .asData!.value[location] ?? + [], + ); + } + } + } + : null, + leading: Icon( + isPlaylistPlaying + ? SpotubeIcons.stop + : SpotubeIcons.play, + ), + child: Text(context.l10n.play), + ), + const Spacer(), + if (constraints.smAndDown) + ExpandableSearchButton( + isFiltering: isFiltering.value, + onPressed: (value) => isFiltering.value = value, + searchFocus: searchFocus, + ) + else + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 300 * scale, + maxHeight: 38 * scale, + ), + child: ExpandableSearchField( + isFiltering: true, + onChangeFiltering: (value) {}, + searchController: searchController, + searchFocus: searchFocus, + ), + ), + const Gap(5), + SortTracksDropdown( + value: sortBy.value, + onChanged: (value) { + sortBy.value = value; + }, + ), + const Gap(5), + IconButton.outline( + icon: const Icon(SpotubeIcons.refresh), + onPressed: () { + ref.invalidate(localTracksProvider); + }, + ) + ], + ), + ), + ExpandableSearchField( + searchController: searchController, + searchFocus: searchFocus, + isFiltering: isFiltering.value, + onChangeFiltering: (value) => isFiltering.value = value, + ), + HookBuilder(builder: (context) { + return trackSnapshot.when( + data: (tracks) { + final sortedTracks = useMemoized(() { + return ServiceUtils.sortTracks( + tracks[location] ?? [], + sortBy.value); + }, [sortBy.value, tracks]); + + final filteredTracks = useMemoized(() { + if (searchController.text.isEmpty) { + return sortedTracks; + } + return sortedTracks + .map((e) => ( + weightedRatio( + "${e.name} - ${e.artists?.asString() ?? ""}", + searchController.text, + ), + e, + )) + .toList() + .sorted( + (a, b) => b.$1.compareTo(a.$1), + ) + .where((e) => e.$1 > 50) + .map((e) => e.$2) + .toList() + .toList(); + }, [searchController.text, sortedTracks]); + + if (!trackSnapshot.isLoading && + filteredTracks.isEmpty) { + return Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Undraw( + illustration: UndrawIllustration.empty, + height: 200 * scale, + color: context.theme.colorScheme.primary, + ), + const Gap(10), + Text( + context.l10n.nothing_found, + textAlign: TextAlign.center, + ).muted().small() + ], + ), + ); + } + + return Expanded( + child: material.RefreshIndicator.adaptive( + onRefresh: () async { + ref.invalidate(localTracksProvider); + }, + child: InterScrollbar( + controller: controller, + child: Skeletonizer( + enabled: trackSnapshot.isLoading, + child: ListView.builder( + controller: controller, + physics: + const AlwaysScrollableScrollPhysics(), + itemCount: trackSnapshot.isLoading + ? 5 + : filteredTracks.length, + itemBuilder: (context, index) { + if (trackSnapshot.isLoading) { + return TrackTile( + playlist: playlist, + track: FakeData.track, + index: index, + ); + } + + final track = filteredTracks[index]; + return TrackTile( + index: index, + playlist: playlist, + track: track, + userPlaylist: false, + onTap: () async { + await playLocalTracks( + ref, + sortedTracks, + currentTrack: track, + ); + }, + ); + }, + ), + ), + ), + ), + ); + }, + loading: () => Expanded( + child: Skeletonizer( + enabled: true, + child: ListView.builder( + itemCount: 5, + itemBuilder: (context, index) => TrackTile( + track: FakeData.track, + index: index, + playlist: playlist, + ), + ), + ), + ), + error: (error, stackTrace) => + Text(error.toString() + stackTrace.toString()), + ); + }) + ], + ))), + ); + } +} diff --git a/lib/modules/library/user_local_tracks.dart b/lib/pages/library/user_local_tracks/user_local_tracks.dart similarity index 52% rename from lib/modules/library/user_local_tracks.dart rename to lib/pages/library/user_local_tracks/user_local_tracks.dart index 23fb3be0..67e02b0b 100644 --- a/lib/modules/library/user_local_tracks.dart +++ b/lib/pages/library/user_local_tracks/user_local_tracks.dart @@ -1,9 +1,9 @@ +import 'package:auto_route/auto_route.dart'; import 'package:file_picker/file_picker.dart'; import 'package:file_selector/file_selector.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/library/local_folder/local_folder_item.dart'; @@ -25,8 +25,10 @@ enum SortBy { album, } -class UserLocalTracks extends HookConsumerWidget { - const UserLocalTracks({super.key}); +@RoutePage() +class UserLocalLibraryPage extends HookConsumerWidget { + static const name = 'user_local_library'; + const UserLocalLibraryPage({super.key}); @override Widget build(BuildContext context, ref) { @@ -58,49 +60,48 @@ class UserLocalTracks extends HookConsumerWidget { // For now, this gets all of them. ref.watch(localTracksProvider); - return LayoutBuilder(builder: (context, constrains) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Column( - children: [ - Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - icon: const Icon(SpotubeIcons.folderAdd), - label: Text(context.l10n.add_library_location), - onPressed: addLocalLibraryLocation, + final locations = [ + preferences.downloadLocation, + if (cacheDir.hasData) cacheDir.data!, + ...preferences.localLibraryLocation, + ]; + + return LayoutBuilder( + builder: (context, constrains) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Column( + children: [ + Align( + alignment: Alignment.centerRight, + child: Button.secondary( + leading: const Icon(SpotubeIcons.folderAdd), + onPressed: addLocalLibraryLocation, + child: Text(context.l10n.add_library_location), + ), + ), + const Gap(8), + Expanded( + child: GridView.builder( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: constrains.isXs + ? 210 + : constrains.mdAndDown + ? 280 + : 250, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemCount: locations.length, + itemBuilder: (context, index) { + return LocalFolderItem( + folder: locations[index], + ); + }, + ), + ), + ], ), - ), - const Gap(8), - Expanded( - child: GridView.builder( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - mainAxisExtent: constrains.isXs - ? 210 - : constrains.mdAndDown - ? 280 - : 250, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - ), - itemCount: preferences.localLibraryLocation.length + - 1 + - (cacheDir.hasData ? 1 : 0), - itemBuilder: (context, index) { - return LocalFolderItem( - folder: index == 0 - ? preferences.downloadLocation - : index == 1 && cacheDir.hasData - ? cacheDir.data! - : preferences.localLibraryLocation[index - 1], - ); - }, - ), - ), - ], - ), - ); - }); + )); } } diff --git a/lib/modules/library/user_playlists.dart b/lib/pages/library/user_playlists.dart similarity index 51% rename from lib/modules/library/user_playlists.dart rename to lib/pages/library/user_playlists.dart index 577f9655..8249726d 100644 --- a/lib/modules/library/user_playlists.dart +++ b/lib/pages/library/user_playlists.dart @@ -1,29 +1,28 @@ -import 'package:flutter/material.dart' hide Image; +import 'package:flutter/material.dart' as material; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:collection/collection.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Image; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/fake.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/playbutton_view/playbutton_view.dart'; import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; import 'package:spotube/modules/playlist/playlist_card.dart'; -import 'package:spotube/components/waypoint.dart'; -import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/service_utils.dart'; +import 'package:auto_route/auto_route.dart'; -class UserPlaylists extends HookConsumerWidget { - const UserPlaylists({super.key}); +@RoutePage() +class UserPlaylistsPage extends HookConsumerWidget { + static const name = 'user_playlists'; + const UserPlaylistsPage({super.key}); @override Widget build(BuildContext context, ref) { @@ -79,81 +78,65 @@ class UserPlaylists extends HookConsumerWidget { return const AnonymousFallback(); } - return RefreshIndicator( + return material.RefreshIndicator.adaptive( onRefresh: () async { ref.invalidate(favoritePlaylistsProvider); }, child: SafeArea( + bottom: false, child: InterScrollbar( controller: controller, child: CustomScrollView( controller: controller, slivers: [ SliverAppBar( + automaticallyImplyLeading: false, floating: true, - flexibleSpace: Padding( + backgroundColor: context.theme.colorScheme.background, + flexibleSpace: Container( padding: const EdgeInsets.symmetric(horizontal: 8), - child: SearchBar( + height: 48, + child: TextField( onChanged: (value) => searchText.value = value, - hintText: context.l10n.filter_playlists, + placeholder: Text(context.l10n.filter_playlists), leading: const Icon(SpotubeIcons.filter), ), ), - bottom: PreferredSize( - preferredSize: - Size.fromHeight(kIsDesktop ? 35 : kToolbarHeight), - child: Row( - children: [ - const Gap(10), - const PlaylistCreateDialogButton(), - const Gap(10), - ElevatedButton.icon( - icon: const Icon(SpotubeIcons.magic), - label: Text(context.l10n.generate_playlist), - onPressed: () { - ServiceUtils.pushNamed( - context, PlaylistGeneratorPage.name); - }, - ), - const Gap(10), - ], - ), - ), ), const SliverGap(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.asData?.value.hasMore != true) { - return const SizedBox.shrink(); - } - - return Waypoint( - controller: controller, - isGrid: true, - onTouchEdge: playlistsQueryNotifier.fetchMore, - child: Skeletonizer( - enabled: true, - child: PlaylistCard(FakeData.playlistSimple), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8), + sliver: PlaybuttonView( + leading: Expanded( + child: Row( + children: [ + const PlaylistCreateDialogButton(), + const Gap(10), + Button.primary( + leading: const Icon(SpotubeIcons.magic), + child: Text(context.l10n.generate), + onPressed: () { + context.navigateTo(const PlaylistGeneratorRoute()); + }, ), - ); - } - - return PlaylistCard( - playlists.elementAtOrNull(index) ?? - FakeData.playlistSimple, - ); + const Gap(10), + ], + ), + ), + controller: controller, + hasMore: playlistsQuery.asData?.value.hasMore == true, + isLoading: playlistsQuery.isLoading, + onRequestMore: playlistsQueryNotifier.fetchMore, + itemCount: playlists.length, + gridItemBuilder: (context, index) { + return PlaylistCard(playlists[index]); }, - ); - }) + listItemBuilder: (context, index) { + return PlaylistCard.tile(playlists[index]); + }, + ), + ), + const SliverSafeArea(sliver: SliverGap(10)), ], ), ), diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index 0f4f9473..98a238f0 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -1,30 +1,25 @@ -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:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/themed_button_tab_bar.dart'; -import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/pages/lyrics/plain_lyrics.dart'; import 'package:spotube/pages/lyrics/synced_lyrics.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class LyricsPage extends HookConsumerWidget { static const name = "lyrics"; - final bool isModal; - const LyricsPage({super.key, this.isModal = false}); + const LyricsPage({super.key}); @override Widget build(BuildContext context, ref) { @@ -37,143 +32,82 @@ class LyricsPage extends HookConsumerWidget { [playlist.activeTrack?.album?.images], ); final palette = usePaletteColor(albumArt, ref); - final mediaQuery = MediaQuery.of(context); - final route = ModalRoute.of(context); + final selectedIndex = useState(0); - final resetStatusBar = useCustomStatusBarColor( - palette.color, - route?.isCurrent ?? false, - noSetBGColor: true, - ); - - PreferredSizeWidget tabbar = ThemedButtonsTabBar( - tabs: [ - Tab(text: " ${context.l10n.synced} "), - Tab(text: " ${context.l10n.plain} "), - ], - ); - - tabbar = PreferredSize( - preferredSize: tabbar.preferredSize, - child: Row( + Widget tabbar = Padding( + padding: const EdgeInsets.all(10), + child: Tabs( + index: selectedIndex.value, + onChanged: (index) => selectedIndex.value = index, children: [ - tabbar, - const Spacer(), - Consumer( - builder: (context, ref, child) { - final playback = ref.watch(audioPlayerProvider); - final lyric = - ref.watch(syncedLyricsProvider(playback.activeTrack)); - final providerName = lyric.asData?.value.provider; - - if (providerName == null) { - return const SizedBox.shrink(); - } - - return Align( - alignment: Alignment.bottomRight, - child: Text(context.l10n.powered_by_provider(providerName)), - ); - }, - ), - const Gap(5), + TabItem(child: Text(context.l10n.synced)), + TabItem(child: Text(context.l10n.plain)), ], ), ); - if (isModal) { - return PopScope( - canPop: true, - onPopInvokedWithResult: (_, __) => resetStatusBar(), - child: DefaultTabController( - length: 2, - child: SafeArea( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: Container( - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface.withOpacity(.4), - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(10), - topRight: Radius.circular(10), - ), - ), - child: Column( - children: [ - const SizedBox(height: 5), - Container( - height: 7, - width: 150, - decoration: BoxDecoration( - color: palette.titleTextColor, - borderRadius: BorderRadius.circular(10), - ), - ), - AppBar( - leadingWidth: double.infinity, - leading: tabbar, - backgroundColor: Colors.transparent, - automaticallyImplyLeading: false, - actions: [ - IconButton( - icon: const Icon(SpotubeIcons.minimize), - onPressed: () => Navigator.of(context).pop(), - ), - const SizedBox(width: 5), - ], - ), - Expanded( - child: TabBarView( - children: [ - SyncedLyrics(palette: palette, isModal: isModal), - PlainLyrics(palette: palette, isModal: isModal), - ], - ), - ), - ], - ), - ), - ), - ), + tabbar = Row( + children: [ + tabbar, + const Spacer(), + Consumer( + builder: (context, ref, child) { + final playback = ref.watch(audioPlayerProvider); + final lyric = ref.watch(syncedLyricsProvider(playback.activeTrack)); + final providerName = lyric.asData?.value.provider; + + if (providerName == null) { + return const SizedBox.shrink(); + } + + return Align( + alignment: Alignment.bottomRight, + child: Text(context.l10n.powered_by_provider(providerName)), + ); + }, ), - ); - } - return DefaultTabController( - length: 2, - child: SafeArea( - bottom: mediaQuery.mdAndUp, - child: Scaffold( - extendBodyBehindAppBar: true, - appBar: !kIsMacOS - ? PageWindowTitleBar( + const Gap(5), + ], + ); + + return SafeArea( + bottom: false, + child: Scaffold( + floatingHeader: true, + headers: [ + !kIsMacOS + ? TitleBar( backgroundColor: Colors.transparent, title: tabbar, + height: 58 * context.theme.scaling, + surfaceBlur: 0, ) - : tabbar, - body: Container( - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - image: DecorationImage( - image: UniversalImage.imageProvider(albumArt), - fit: BoxFit.cover, - ), - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(10), - ), + : tabbar + ], + child: Container( + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + image: DecorationImage( + image: UniversalImage.imageProvider(albumArt), + fit: BoxFit.cover, ), - margin: const EdgeInsets.only(bottom: 10), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), - child: ColoredBox( - color: palette.color.withOpacity(.7), - child: SafeArea( - child: TabBarView( - children: [ - SyncedLyrics(palette: palette, isModal: isModal), - PlainLyrics(palette: palette, isModal: isModal), - ], - ), + ), + margin: const EdgeInsets.only(bottom: 10), + child: SurfaceCard( + surfaceBlur: context.theme.surfaceBlur, + surfaceOpacity: context.theme.surfaceOpacity, + padding: EdgeInsets.zero, + borderRadius: BorderRadius.zero, + borderWidth: 0, + child: ColoredBox( + color: palette.color.withOpacity(.7), + child: SafeArea( + child: IndexedStack( + index: selectedIndex.value, + children: [ + SyncedLyrics(palette: palette, isModal: false), + PlainLyrics(palette: palette, isModal: false), + ], ), ), ), diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index 8f6ec1fc..3e50987d 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -1,13 +1,13 @@ -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:palette_generator/palette_generator.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/player/player_controls.dart'; import 'package:spotube/modules/player/player_queue.dart'; -import 'package:spotube/modules/root/sidebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/utils/use_force_update.dart'; import 'package:spotube/pages/lyrics/plain_lyrics.dart'; @@ -15,7 +15,9 @@ import 'package:spotube/pages/lyrics/synced_lyrics.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/utils/platform.dart'; import 'package:window_manager/window_manager.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class MiniLyricsPage extends HookConsumerWidget { static const name = "mini_lyrics"; @@ -30,6 +32,8 @@ class MiniLyricsPage extends HookConsumerWidget { final playlistQueue = ref.watch(audioPlayerProvider); + final index = useState(0); + final areaActive = useState(false); final hoverMode = useState(true); final showLyrics = useState(true); @@ -43,8 +47,6 @@ class MiniLyricsPage extends HookConsumerWidget { return null; }, []); - - return MouseRegion( onEnter: !hoverMode.value ? null @@ -56,12 +58,11 @@ class MiniLyricsPage extends HookConsumerWidget { : (event) { areaActive.value = false; }, - child: DefaultTabController( - length: 2, - child: Scaffold( - backgroundColor: theme.colorScheme.surface.withOpacity(0.4), - appBar: PreferredSize( - preferredSize: const Size.fromHeight(60), + child: Scaffold( + backgroundColor: theme.colorScheme.background.withOpacity(0.4), + headers: [ + Padding( + padding: const EdgeInsets.all(8.0), child: AnimatedCrossFade( duration: const Duration(milliseconds: 200), crossFadeState: areaActive.value @@ -70,91 +71,90 @@ class MiniLyricsPage extends HookConsumerWidget { secondChild: const SizedBox(), firstChild: DragToMoveArea( child: Row( + spacing: 2, children: [ const Gap(10), - if (!kIsMacOS) - SizedBox( - height: 30, - width: 30, - child: Sidebar.brandLogo(), - ), - const Spacer(), + if (kIsMacOS) const SizedBox(width: 65), if (showLyrics.value) - SizedBox( - height: 30, - child: TabBar( - tabs: [ - Tab(text: context.l10n.synced), - Tab(text: context.l10n.plain), - ], - isScrollable: true, - ), + Tabs( + index: index.value, + onChanged: (i) { + index.value = i; + }, + children: [ + TabItem(child: Text(context.l10n.synced)), + TabItem(child: Text(context.l10n.plain)), + ], ), const Spacer(), - IconButton( - tooltip: context.l10n.lyrics, - icon: showLyrics.value - ? const Icon(SpotubeIcons.lyrics) - : const Icon(SpotubeIcons.lyricsOff), - style: ButtonStyle( - foregroundColor: showLyrics.value - ? WidgetStateProperty.all(theme.colorScheme.primary) - : null, - ), - onPressed: () async { - showLyrics.value = !showLyrics.value; - areaActive.value = true; - hoverMode.value = false; + Tooltip( + tooltip: + TooltipContainer(child: Text(context.l10n.lyrics)), + child: IconButton( + variance: showLyrics.value + ? ButtonVariance.secondary + : ButtonVariance.ghost, + icon: showLyrics.value + ? const Icon(SpotubeIcons.lyrics) + : const Icon(SpotubeIcons.lyricsOff), + onPressed: () async { + showLyrics.value = !showLyrics.value; + areaActive.value = true; + hoverMode.value = false; - if (kIsDesktop) { - await windowManager.setSize( - showLyrics.value - ? const Size(400, 500) - : const Size(400, 150), - ); - } - }, - ), - IconButton( - tooltip: context.l10n.show_hide_ui_on_hover, - icon: hoverMode.value - ? const Icon(SpotubeIcons.hoverOn) - : const Icon(SpotubeIcons.hoverOff), - style: ButtonStyle( - foregroundColor: hoverMode.value - ? WidgetStateProperty.all(theme.colorScheme.primary) - : null, + if (kIsDesktop) { + await windowManager.setSize( + showLyrics.value + ? const Size(400, 500) + : const Size(400, 150), + ); + } + }, + ), + ), + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.show_hide_ui_on_hover), + ), + child: IconButton( + variance: hoverMode.value + ? ButtonVariance.secondary + : ButtonVariance.ghost, + icon: hoverMode.value + ? const Icon(SpotubeIcons.hoverOn) + : const Icon(SpotubeIcons.hoverOff), + onPressed: () async { + areaActive.value = true; + hoverMode.value = !hoverMode.value; + }, ), - onPressed: () async { - areaActive.value = true; - hoverMode.value = !hoverMode.value; - }, ), if (kIsDesktop) FutureBuilder( future: windowManager.isAlwaysOnTop(), builder: (context, snapshot) { - return IconButton( - tooltip: context.l10n.always_on_top, - icon: Icon( - snapshot.data == true - ? SpotubeIcons.pinOn - : SpotubeIcons.pinOff, + return Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.always_on_top), ), - style: ButtonStyle( - foregroundColor: snapshot.data == true - ? WidgetStateProperty.all( - theme.colorScheme.primary) - : null, + child: IconButton( + variance: snapshot.data == true + ? ButtonVariance.secondary + : ButtonVariance.ghost, + icon: Icon( + snapshot.data == true + ? SpotubeIcons.pinOn + : SpotubeIcons.pinOff, + ), + onPressed: snapshot.data == null + ? null + : () async { + await windowManager.setAlwaysOnTop( + snapshot.data == true ? false : true, + ); + update(); + }, ), - onPressed: snapshot.data == null - ? null - : () async { - await windowManager.setAlwaysOnTop( - snapshot.data == true ? false : true, - ); - update(); - }, ); }, ), @@ -163,79 +163,90 @@ class MiniLyricsPage extends HookConsumerWidget { ), ), ), - body: Column( - children: [ - if (playlistQueue.activeTrack != null) - Text( - playlistQueue.activeTrack!.name!, - style: theme.textTheme.titleMedium, - ), - if (showLyrics.value) - Expanded( - child: TabBarView( - children: [ - SyncedLyrics( - palette: PaletteColor(theme.colorScheme.surface, 0), - isModal: true, - defaultTextZoom: 65, - ), - PlainLyrics( - palette: PaletteColor(theme.colorScheme.surface, 0), - isModal: true, - defaultTextZoom: 65, - ), - ], - ), - ) - else - const Gap(20), - AnimatedCrossFade( - crossFadeState: areaActive.value - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - duration: const Duration(milliseconds: 200), - secondChild: const SizedBox(), - firstChild: Row( + ], + child: Column( + children: [ + if (playlistQueue.activeTrack != null) + Text(playlistQueue.activeTrack!.name!).semiBold(), + if (showLyrics.value) + Expanded( + child: IndexedStack( + index: index.value, children: [ - IconButton( + SyncedLyrics( + palette: PaletteColor(theme.colorScheme.background, 0), + isModal: true, + defaultTextZoom: 65, + ), + PlainLyrics( + palette: PaletteColor(theme.colorScheme.background, 0), + isModal: true, + defaultTextZoom: 65, + ), + ], + ), + ) + else + const Gap(20), + AnimatedCrossFade( + crossFadeState: areaActive.value + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 200), + secondChild: const SizedBox(), + firstChild: Row( + children: [ + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.queue), + ), + child: IconButton.ghost( icon: const Icon(SpotubeIcons.queue), - tooltip: context.l10n.queue, onPressed: playlistQueue.activeTrack != null ? () { - showModalBottomSheet( + openDrawer( context: context, - isDismissible: true, - enableDrag: true, - isScrollControlled: true, - backgroundColor: Colors.black12, - barrierColor: Colors.black12, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - constraints: BoxConstraints( - maxHeight: - MediaQuery.of(context).size.height * .7, - ), - builder: (context) { - return Consumer(builder: (context, ref, _) { - final playlist = - ref.watch(audioPlayerProvider); - - return PlayerQueue.fromAudioPlayerNotifier( - floating: true, - playlist: playlist, - notifier: ref - .read(audioPlayerProvider.notifier), + barrierDismissible: true, + draggable: true, + barrierColor: Colors.black.withAlpha(100), + borderRadius: BorderRadius.circular(10), + transformBackdrop: false, + position: OverlayPosition.bottom, + surfaceBlur: context.theme.surfaceBlur, + surfaceOpacity: 0.7, + expands: true, + builder: (context) => Consumer( + builder: (context, ref, _) { + final playlist = ref.watch( + audioPlayerProvider, ); - }); - }, + final playlistNotifier = + ref.read(audioPlayerProvider.notifier); + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: + MediaQuery.of(context).size.height * + 0.8, + ), + child: + PlayerQueue.fromAudioPlayerNotifier( + floating: false, + playlist: playlist, + notifier: playlistNotifier, + ), + ); + }, + ), ); } : null, ), - const Flexible(child: PlayerControls(compact: true)), - IconButton( - tooltip: context.l10n.exit_mini_player, + ), + const Flexible(child: PlayerControls(compact: true)), + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.exit_mini_player)), + child: IconButton.ghost( icon: const Icon(SpotubeIcons.maximize), onPressed: () async { if (!kIsDesktop) return; @@ -257,16 +268,16 @@ class MiniLyricsPage extends HookConsumerWidget { const Duration(milliseconds: 200)); } finally { if (context.mounted) { - GoRouter.of(context).go('/lyrics'); + context.navigateTo(LyricsRoute()); } } }, ), - ], - ), - ) - ], - ), + ), + ], + ), + ) + ], ), ), ); diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index 7c571d5f..0b5354a0 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -1,9 +1,9 @@ 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:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/lyrics/zoom_controls.dart'; import 'package:spotube/components/shimmers/shimmer_lyrics.dart'; @@ -30,7 +30,7 @@ class PlainLyrics extends HookConsumerWidget { final playlist = ref.watch(audioPlayerProvider); final lyricsQuery = ref.watch(syncedLyricsProvider(playlist.activeTrack)); final mediaQuery = MediaQuery.of(context); - final textTheme = Theme.of(context).textTheme; + final typography = Theme.of(context).typography; final textZoomLevel = useState(defaultTextZoom); @@ -44,9 +44,8 @@ class PlainLyrics extends HookConsumerWidget { child: Text( playlist.activeTrack?.name ?? "", style: mediaQuery.mdAndUp - ? textTheme.displaySmall - : textTheme.headlineMedium?.copyWith( - fontSize: 25, + ? typography.h3 + : typography.h4.copyWith( color: palette.titleTextColor, ), ), @@ -54,10 +53,10 @@ class PlainLyrics extends HookConsumerWidget { Center( child: Text( playlist.activeTrack?.artists?.asString() ?? "", - style: (mediaQuery.mdAndUp - ? textTheme.headlineSmall - : textTheme.titleLarge) - ?.copyWith(color: palette.bodyTextColor), + style: (mediaQuery.mdAndUp ? typography.h4 : typography.large) + .copyWith( + color: palette.bodyTextColor, + ), ), ) ], @@ -79,7 +78,7 @@ class PlainLyrics extends HookConsumerWidget { children: [ Text( context.l10n.no_lyrics_available, - style: textTheme.bodyLarge?.copyWith( + style: typography.large.copyWith( color: palette.bodyTextColor, ), textAlign: TextAlign.center, @@ -107,7 +106,9 @@ class PlainLyrics extends HookConsumerWidget { return AnimatedDefaultTextStyle( duration: const Duration(milliseconds: 200), style: TextStyle( - color: palette.bodyTextColor, + color: isModal == true + ? context.theme.colorScheme.foreground + : palette.bodyTextColor, fontSize: 24 * textZoomLevel.value / 100, height: textZoomLevel.value < 70 ? 1.5 diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index 59bd863a..b7423e14 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -1,10 +1,9 @@ import 'dart:async'; -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/lyrics/zoom_controls.dart'; import 'package:spotube/components/shimmers/shimmer_lyrics.dart'; @@ -35,9 +34,11 @@ class SyncedLyrics extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { + final mediaQuery = MediaQuery.sizeOf(context); + final theme = Theme.of(context); + final playlist = ref.watch(audioPlayerProvider); - final mediaQuery = MediaQuery.of(context); final controller = useAutoScrollController(); final delay = ref.watch(syncedLyricsDelayProvider); @@ -54,7 +55,7 @@ class SyncedLyrics extends HookConsumerWidget { useSyncedLyrics(ref, lyricsState.asData?.value.lyricsMap ?? {}, delay); final textZoomLevel = useState(defaultTextZoom); - final textTheme = Theme.of(context).textTheme; + final typography = Theme.of(context).typography; ref.listen( audioPlayerProvider.select((s) => s.activeTrack), @@ -69,11 +70,13 @@ class SyncedLyrics extends HookConsumerWidget { ); final headlineTextStyle = (mediaQuery.mdAndUp - ? textTheme.displaySmall - : textTheme.headlineMedium?.copyWith(fontSize: 25)) - ?.copyWith(color: palette.titleTextColor); + ? typography.h3 + : typography.h4.copyWith(fontSize: 25)) + .copyWith( + color: palette.titleTextColor, + ); - final bodyTextTheme = textTheme.bodyLarge?.copyWith( + final bodyTextTheme = typography.large.copyWith( color: palette.bodyTextColor, ); @@ -115,9 +118,8 @@ class SyncedLyrics extends HookConsumerWidget { preferredSize: const Size.fromHeight(40), child: Text( playlist.activeTrack?.artists?.asString() ?? "", - style: mediaQuery.mdAndUp - ? textTheme.headlineSmall - : textTheme.titleLarge, + style: + mediaQuery.mdAndUp ? typography.h4 : typography.x2Large, ), ), ), @@ -144,7 +146,7 @@ class SyncedLyrics extends HookConsumerWidget { ? Container( padding: index == lyricValue.lyrics.length - 1 ? EdgeInsets.only( - bottom: mediaQuery.size.height / 2, + bottom: mediaQuery.height / 2, ) : null, ) @@ -165,31 +167,40 @@ class SyncedLyrics extends HookConsumerWidget { (textZoomLevel.value / 100), ), textAlign: TextAlign.center, - child: InkWell( - onTap: () async { - final time = Duration( - seconds: - lyricSlice.time.inSeconds - delay, - ); - if (time > audioPlayer.duration || - time.isNegative) { - return; - } - audioPlayer.seek(time); - }, - child: Builder(builder: (context) { - return StrokeText( - text: lyricSlice.text, - textStyle: - DefaultTextStyle.of(context).style, - textColor: isActive - ? Colors.white - : palette.bodyTextColor, - strokeColor: isActive - ? Colors.black - : Colors.transparent, - ); - }), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () async { + final time = Duration( + seconds: + lyricSlice.time.inSeconds - delay, + ); + if (time > audioPlayer.duration || + time.isNegative) { + return; + } + audioPlayer.seek(time); + }, + child: Builder(builder: (context) { + return StrokeText( + text: lyricSlice.text, + textStyle: + DefaultTextStyle.of(context).style, + textColor: switch (( + isActive, + isModal == true + )) { + (true, _) => Colors.white, + (_, true) => + theme.colorScheme.mutedForeground, + (_, _) => palette.bodyTextColor, + }, + strokeColor: isActive + ? Colors.black + : Colors.transparent, + ); + }), + ), ), ), ), @@ -231,7 +242,7 @@ class SyncedLyrics extends HookConsumerWidget { ), TextSpan( text: " Plain Lyrics ", - style: textTheme.bodyLarge?.copyWith( + style: typography.large.copyWith( color: palette.bodyTextColor, fontWeight: FontWeight.bold, ), diff --git a/lib/pages/mobile_login/hooks/login_callback.dart b/lib/pages/mobile_login/hooks/login_callback.dart index 07c0210a..986b7f4a 100644 --- a/lib/pages/mobile_login/hooks/login_callback.dart +++ b/lib/pages/mobile_login/hooks/login_callback.dart @@ -1,14 +1,15 @@ import 'dart:io'; +import 'package:auto_route/auto_route.dart'; import 'package:desktop_webview_window/desktop_webview_window.dart'; -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide join; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:spotube/pages/mobile_login/mobile_login.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/pages/mobile_login/no_webview_runtime_dialog.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/utils/platform.dart'; @@ -20,7 +21,7 @@ Future Function() useLoginCallback(WidgetRef ref) { return useCallback(() async { if (kIsMobile || kIsMacOS) { - context.pushNamed(WebViewLogin.name); + context.navigateTo(const WebViewLoginRoute()); return; } @@ -28,7 +29,8 @@ Future Function() useLoginCallback(WidgetRef ref) { final exp = RegExp(r"https:\/\/accounts.spotify.com\/.+\/status"); final applicationSupportDir = await getApplicationSupportDirectory(); final userDataFolder = Directory( - join(applicationSupportDir.path, "webview_window_Webview2")); + join(applicationSupportDir.path, "webview_window_Webview2"), + ); if (!await userDataFolder.exists()) { await userDataFolder.create(); @@ -56,7 +58,7 @@ Future Function() useLoginCallback(WidgetRef ref) { webview.close(); if (context.mounted) { - context.go("/"); + context.navigateTo(const HomeRoute()); } }); } @@ -75,5 +77,5 @@ Future Function() useLoginCallback(WidgetRef ref) { }); } } - }, [authNotifier, theme, context.go, context.pushNamed]); + }, [authNotifier, theme, context.navigateTo]); } diff --git a/lib/pages/mobile_login/mobile_login.dart b/lib/pages/mobile_login/mobile_login.dart index c45c2184..eb50316f 100644 --- a/lib/pages/mobile_login/mobile_login.dart +++ b/lib/pages/mobile_login/mobile_login.dart @@ -1,15 +1,19 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:go_router/go_router.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/components/button/back_button.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:auto_route/auto_route.dart'; -class WebViewLogin extends HookConsumerWidget { +@RoutePage() +class WebViewLoginPage extends HookConsumerWidget { static const name = "login"; - const WebViewLogin({super.key}); + const WebViewLoginPage({super.key}); @override Widget build(BuildContext context, ref) { @@ -17,54 +21,59 @@ class WebViewLogin extends HookConsumerWidget { if (kIsDesktop) { const Scaffold( - body: Center( + child: Center( child: Text('This feature is not available on desktop'), ), ); } - return Scaffold( - appBar: const PageWindowTitleBar( - leading: BackButton(color: Colors.white), - backgroundColor: Colors.transparent, - ), - extendBodyBehindAppBar: true, - body: InAppWebView( - initialSettings: InAppWebViewSettings( - userAgent: - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 safari/537.36", - ), - initialUrlRequest: URLRequest( - url: WebUri("https://accounts.spotify.com/"), - ), - onPermissionRequest: (controller, permissionRequest) async { - return PermissionResponse( - resources: permissionRequest.resources, - action: PermissionResponseAction.GRANT, - ); - }, - onLoadStop: (controller, action) async { - if (action == null) return; - String url = action.toString(); - if (url.endsWith("/")) { - url = url.substring(0, url.length - 1); - } - - final exp = RegExp(r"https:\/\/accounts.spotify.com\/.+\/status"); - - if (exp.hasMatch(url)) { - final cookies = - await CookieManager.instance().getCookies(url: action); - final cookieHeader = - "sp_dc=${cookies.firstWhere((element) => element.name == "sp_dc").value}"; - - await authenticationNotifier.login(cookieHeader); - if (context.mounted) { - // ignore: use_build_context_synchronously - GoRouter.of(context).go("/"); + return SafeArea( + bottom: false, + child: Scaffold( + headers: const [ + TitleBar( + leading: [BackButton(color: Colors.white)], + backgroundColor: Colors.transparent, + ), + ], + floatingHeader: true, + child: InAppWebView( + initialSettings: InAppWebViewSettings( + userAgent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 safari/537.36", + ), + initialUrlRequest: URLRequest( + url: WebUri("https://accounts.spotify.com/"), + ), + onPermissionRequest: (controller, permissionRequest) async { + return PermissionResponse( + resources: permissionRequest.resources, + action: PermissionResponseAction.GRANT, + ); + }, + onLoadStop: (controller, action) async { + if (action == null) return; + String url = action.toString(); + if (url.endsWith("/")) { + url = url.substring(0, url.length - 1); } - } - }, + + final exp = RegExp(r"https:\/\/accounts.spotify.com\/.+\/status"); + + if (exp.hasMatch(url)) { + final cookies = + await CookieManager.instance().getCookies(url: action); + final cookieHeader = + "sp_dc=${cookies.firstWhere((element) => element.name == "sp_dc").value}"; + + await authenticationNotifier.login(cookieHeader); + if (context.mounted) { + // ignore: use_build_context_synchronously + context.navigateTo(const HomeRoute()); + } + } + }, + ), ), ); } diff --git a/lib/pages/mobile_login/no_webview_runtime_dialog.dart b/lib/pages/mobile_login/no_webview_runtime_dialog.dart index a6cc5ffb..b0919e5c 100644 --- a/lib/pages/mobile_login/no_webview_runtime_dialog.dart +++ b/lib/pages/mobile_login/no_webview_runtime_dialog.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/extensions/context.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -19,7 +19,7 @@ class NoWebviewRuntimeDialog extends StatelessWidget { }, child: Text(context.l10n.cancel), ), - FilledButton( + Button.primary( onPressed: () async { final url = switch (platform) { TargetPlatform.windows => @@ -30,8 +30,15 @@ class NoWebviewRuntimeDialog extends StatelessWidget { _ => "", }; if (url.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Unsupported platform')), + showToast( + context: context, + builder: (context, overlay) { + return const SurfaceCard( + child: Basic( + title: Text('Unsupported platform'), + ), + ); + }, ); } diff --git a/lib/pages/player/lyrics.dart b/lib/pages/player/lyrics.dart new file mode 100644 index 00000000..01a4e921 --- /dev/null +++ b/lib/pages/player/lyrics.dart @@ -0,0 +1,64 @@ +import 'package:auto_route/annotations.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/button/back_button.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/hooks/utils/use_palette_color.dart'; +import 'package:spotube/pages/lyrics/plain_lyrics.dart'; +import 'package:spotube/pages/lyrics/synced_lyrics.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; + +@RoutePage() +class PlayerLyricsPage extends HookConsumerWidget { + const PlayerLyricsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final playlist = ref.watch(audioPlayerProvider); + String albumArt = useMemoized( + () => (playlist.activeTrack?.album?.images).asUrlString( + index: (playlist.activeTrack?.album?.images?.length ?? 1) - 1, + placeholder: ImagePlaceholder.albumArt, + ), + [playlist.activeTrack?.album?.images], + ); + final selectedIndex = useState(0); + final palette = usePaletteColor(albumArt, ref); + + final tabbar = Padding( + padding: const EdgeInsets.all(10), + child: TabList( + index: selectedIndex.value, + onChanged: (index) => selectedIndex.value = index, + children: [ + TabItem( + child: Text(context.l10n.synced), + ), + TabItem( + child: Text(context.l10n.plain), + ), + ], + )); + + return Scaffold( + headers: [ + AppBar( + leading: [tabbar], + trailing: const [ + BackButton(icon: SpotubeIcons.angleDown), + ], + ), + ], + child: IndexedStack( + index: selectedIndex.value, + children: [ + SyncedLyrics(palette: palette, isModal: false), + PlainLyrics(palette: palette, isModal: false), + ], + ), + ); + } +} diff --git a/lib/pages/player/queue.dart b/lib/pages/player/queue.dart new file mode 100644 index 00000000..829db6eb --- /dev/null +++ b/lib/pages/player/queue.dart @@ -0,0 +1,28 @@ +import 'package:auto_route/annotations.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/modules/player/player_queue.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; + +@RoutePage() +class PlayerQueuePage extends HookConsumerWidget { + const PlayerQueuePage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final playlist = ref.watch( + audioPlayerProvider, + ); + final playlistNotifier = ref.read(audioPlayerProvider.notifier); + return Scaffold( + child: SafeArea( + bottom: false, + child: PlayerQueue.fromAudioPlayerNotifier( + floating: false, + playlist: playlist, + notifier: playlistNotifier, + ), + ), + ); + } +} diff --git a/lib/pages/player/sources.dart b/lib/pages/player/sources.dart new file mode 100644 index 00000000..8e370daf --- /dev/null +++ b/lib/pages/player/sources.dart @@ -0,0 +1,15 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/modules/player/sibling_tracks_sheet.dart'; + +@RoutePage() +class PlayerTrackSourcesPage extends StatelessWidget { + const PlayerTrackSourcesPage({super.key}); + + @override + Widget build(BuildContext context) { + return const Scaffold( + child: SiblingTracksSheet(floating: false), + ); + } +} diff --git a/lib/pages/playlist/liked_playlist.dart b/lib/pages/playlist/liked_playlist.dart index 942f46d5..5f7591ab 100644 --- a/lib/pages/playlist/liked_playlist.dart +++ b/lib/pages/playlist/liked_playlist.dart @@ -1,11 +1,14 @@ +import 'package:flutter/material.dart' as material; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/tracks_view/track_view.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; +import 'package:spotube/components/track_presentation/track_presentation.dart'; import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class LikedPlaylistPage extends HookConsumerWidget { static const name = PlaylistPage.name; @@ -20,28 +23,35 @@ class LikedPlaylistPage extends HookConsumerWidget { final likedTracks = ref.watch(likedTracksProvider); final tracks = likedTracks.asData?.value ?? []; - return InheritedTrackView( - collection: playlist, - image: "assets/liked-tracks.jpg", - pagination: PaginationProps( - hasNextPage: false, - isLoading: false, - onFetchMore: () {}, - onFetchAll: () async { - return tracks.toList(); - }, - onRefresh: () async { - ref.invalidate(likedTracksProvider); - }, + return material.RefreshIndicator.adaptive( + onRefresh: () async { + ref.invalidate(likedTracksProvider); + }, + child: TrackPresentation( + options: TrackPresentationOptions( + collection: playlist, + image: "assets/liked-tracks.jpg", + pagination: PaginationProps( + hasNextPage: false, + isLoading: likedTracks.isLoading, + onFetchMore: () {}, + onFetchAll: () async { + return tracks.toList(); + }, + onRefresh: () async { + ref.invalidate(likedTracksProvider); + }, + ), + title: playlist.name!, + description: playlist.description, + tracks: tracks, + routePath: '/playlist/${playlist.id}', + isLiked: false, + shareUrl: null, + onHeart: null, + owner: playlist.owner?.displayName, + ), ), - title: playlist.name!, - description: playlist.description, - tracks: tracks, - routePath: '/playlist/${playlist.id}', - isLiked: false, - shareUrl: "", - onHeart: null, - child: const TrackView(), ); } } diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index e1b33e98..62ced353 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -1,21 +1,26 @@ +import 'package:flutter/material.dart' as material; import 'package:collection/collection.dart'; import 'package:flutter/material.dart' hide Page; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/dialogs/prompt_dialog.dart'; -import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart'; -import 'package:spotube/components/tracks_view/track_view.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; +import 'package:spotube/components/track_presentation/track_presentation.dart'; +import 'package:spotube/components/track_presentation/use_is_user_playlist.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class PlaylistPage extends HookConsumerWidget { static const name = "playlist"; final PlaylistSimple _playlist; + final String id; const PlaylistPage({ super.key, + @PathParam("id") required this.id, required PlaylistSimple playlist, }) : _playlist = playlist; @@ -45,49 +50,59 @@ class PlaylistPage extends HookConsumerWidget { final isUserPlaylist = useIsUserPlaylist(ref, playlist.id!); - return InheritedTrackView( - collection: playlist, - image: playlist.images.asUrlString( - placeholder: ImagePlaceholder.collection, - ), - pagination: PaginationProps( - hasNextPage: tracks.asData?.value.hasMore ?? false, - isLoading: tracks.isLoadingNextPage, - onFetchMore: tracksNotifier.fetchMore, - onRefresh: () async { - ref.invalidate(playlistTracksProvider(playlist.id!)); - }, - onFetchAll: () async { - return await tracksNotifier.fetchAll(); - }, - ), - title: playlist.name!, - description: playlist.description, - tracks: tracks.asData?.value.items ?? [], - routePath: '/playlist/${playlist.id}', - isLiked: isFavoritePlaylist.asData?.value ?? false, - shareUrl: playlist.externalUrls?.spotify ?? - "https://open.spotify.com/playlist/${playlist.id}", - onHeart: isFavoritePlaylist.asData?.value == null - ? null - : () async { - final confirmed = isUserPlaylist - ? await showPromptDialog( - context: context, - title: context.l10n.delete_playlist, - message: context.l10n.delete_playlist_confirmation, - ) - : true; - if (!confirmed) return null; - - if (isFavoritePlaylist.asData!.value) { - await favoritePlaylistsNotifier.removeFavorite(playlist); - } else { - await favoritePlaylistsNotifier.addFavorite(playlist); - } - return isUserPlaylist; + return material.RefreshIndicator.adaptive( + onRefresh: () async { + ref.invalidate(playlistTracksProvider(playlist.id!)); + ref.invalidate(isFavoritePlaylistProvider(playlist.id!)); + ref.invalidate(favoritePlaylistsProvider); + }, + child: TrackPresentation( + options: TrackPresentationOptions( + collection: playlist, + image: playlist.images.asUrlString( + placeholder: ImagePlaceholder.collection, + ), + pagination: PaginationProps( + hasNextPage: tracks.asData?.value.hasMore ?? false, + isLoading: tracks.isLoading || tracks.isLoadingNextPage, + onFetchMore: tracksNotifier.fetchMore, + onRefresh: () async { + ref.invalidate(playlistTracksProvider(playlist.id!)); }, - child: const TrackView(), + onFetchAll: () async { + return await tracksNotifier.fetchAll(); + }, + ), + title: playlist.name!, + description: playlist.description, + owner: playlist.owner?.displayName, + ownerImage: playlist.owner?.images?.lastOrNull?.url, + tracks: tracks.asData?.value.items ?? [], + routePath: '/playlist/${playlist.id}', + isLiked: isFavoritePlaylist.asData?.value ?? false, + shareUrl: playlist.externalUrls?.spotify ?? + "https://open.spotify.com/playlist/${playlist.id}", + onHeart: isFavoritePlaylist.asData?.value == null + ? null + : () async { + final confirmed = isUserPlaylist + ? await showPromptDialog( + context: context, + title: context.l10n.delete_playlist, + message: context.l10n.delete_playlist_confirmation, + ) + : true; + if (!confirmed) return null; + + if (isFavoritePlaylist.asData!.value) { + await favoritePlaylistsNotifier.removeFavorite(playlist); + } else { + await favoritePlaylistsNotifier.addFavorite(playlist); + } + return isUserPlaylist; + }, + ), + ), ); } } diff --git a/lib/pages/profile/profile.dart b/lib/pages/profile/profile.dart index 9e51793d..b6c4a2cd 100644 --- a/lib/pages/profile/profile.dart +++ b/lib/pages/profile/profile.dart @@ -1,7 +1,6 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:sliver_tools/sliver_tools.dart'; import 'package:spotube/collections/fake.dart'; @@ -13,7 +12,9 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:url_launcher/url_launcher_string.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class ProfilePage extends HookConsumerWidget { static const name = "profile"; @@ -21,8 +22,6 @@ class ProfilePage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme) = Theme.of(context); - final me = ref.watch(meProvider); final meData = me.asData?.value ?? FakeData.user; @@ -42,13 +41,12 @@ class ProfilePage extends HookConsumerWidget { return SafeArea( child: Scaffold( - appBar: PageWindowTitleBar( - title: Text(context.l10n.profile), - titleSpacing: 0, - automaticallyImplyLeading: true, - centerTitle: false, - ), - body: Skeletonizer( + headers: [ + TitleBar( + title: Text(context.l10n.profile), + ) + ], + child: Skeletonizer( enabled: me.isLoading, child: CustomScrollView( slivers: [ @@ -75,9 +73,8 @@ class ProfilePage extends HookConsumerWidget { SliverToBoxAdapter( child: Text( meData.displayName ?? context.l10n.no_name, - style: textTheme.titleLarge, textAlign: TextAlign.center, - ), + ).h4(), ), const SliverGap(20), SliverCrossAxisConstrained( @@ -86,15 +83,15 @@ class ProfilePage extends HookConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - TextButton.icon( - label: Text(context.l10n.edit), - icon: const Icon(SpotubeIcons.edit), + Button.text( + leading: const Icon(SpotubeIcons.edit), onPressed: () { launchUrlString( "https://www.spotify.com/account/profile/", mode: LaunchMode.externalApplication, ); }, + child: Text(context.l10n.edit), ), ], ), @@ -104,25 +101,22 @@ class ProfilePage extends HookConsumerWidget { maxCrossAxisExtent: 500, child: SliverToBoxAdapter( child: Card( - margin: const EdgeInsets.all(10), child: Padding( padding: const EdgeInsets.all(8.0), child: Table( columnWidths: const { - 0: FixedColumnWidth(110), + 0: FixedTableSize(120), }, - children: [ + defaultRowHeight: const FixedTableSize(40), + rows: [ for (final MapEntry(:key, :value) in userProperties.entries) TableRow( - children: [ + cells: [ TableCell( child: Padding( padding: const EdgeInsets.all(6), - child: Text( - key, - style: textTheme.titleSmall, - ), + child: Text(key).large(), ), ), TableCell( @@ -139,6 +133,7 @@ class ProfilePage extends HookConsumerWidget { ), ), ), + const SliverGap(200), ], ), ), diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 0274de00..e2b64b1e 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -1,235 +1,58 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/side_bar_tiles.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/framework/app_pop_scope.dart'; -import 'package:spotube/modules/player/player_queue.dart'; -import 'package:spotube/components/dialogs/replace_downloaded_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/hooks/configurators/use_check_yt_dlp_installed.dart'; import 'package:spotube/modules/root/bottom_player.dart'; -import 'package:spotube/modules/root/sidebar.dart'; +import 'package:spotube/modules/root/sidebar/sidebar.dart'; import 'package:spotube/modules/root/spotube_navigation_bar.dart'; -import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart'; -import 'package:spotube/pages/home/home.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/server/routes/connect.dart'; -import 'package:spotube/services/connectivity_adapter.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/service_utils.dart'; +import 'package:spotube/modules/root/use_downloader_dialogs.dart'; +import 'package:spotube/modules/root/use_global_subscriptions.dart'; +import 'package:spotube/provider/glance/glance.dart'; -class RootApp extends HookConsumerWidget { - final Widget child; - const RootApp({ - required this.child, - super.key, - }); +@RoutePage() +class RootAppPage extends HookConsumerWidget { + const RootAppPage({super.key}); @override Widget build(BuildContext context, ref) { - final theme = Theme.of(context); + final backgroundColor = Theme.of(context).colorScheme.background; + final brightness = Theme.of(context).brightness; - final showingDialogCompleter = useRef(Completer()..complete()); - final downloader = ref.watch(downloadManagerProvider); - final scaffoldMessenger = ScaffoldMessenger.of(context); - final connectRoutes = ref.watch(serverConnectRoutesProvider); - - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) async { - ServiceUtils.checkForUpdates(context, ref); - }); - - final subscriptions = [ - ConnectionCheckerService.instance.onConnectivityChanged - .listen((status) { - if (status) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Row( - children: [ - Icon( - SpotubeIcons.wifi, - color: theme.colorScheme.onPrimary, - ), - const SizedBox(width: 10), - Text(context.l10n.connection_restored), - ], - ), - backgroundColor: theme.colorScheme.primary, - showCloseIcon: true, - width: 350, - ), - ); - } else { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Row( - children: [ - Icon( - SpotubeIcons.noWifi, - color: theme.colorScheme.onError, - ), - const SizedBox(width: 10), - Text(context.l10n.you_are_offline), - ], - ), - backgroundColor: theme.colorScheme.error, - showCloseIcon: true, - width: 300, - ), - ); - } - }), - connectRoutes.connectClientStream.listen((clientOrigin) { - scaffoldMessenger.showSnackBar( - SnackBar( - backgroundColor: Colors.yellow[600], - behavior: SnackBarBehavior.floating, - content: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - SpotubeIcons.error, - color: Colors.black, - ), - const SizedBox(width: 10), - Text( - context.l10n.connect_client_alert(clientOrigin), - style: const TextStyle(color: Colors.black), - ), - ], - ), - ), - ); - }) - ]; - - return () { - for (final subscription in subscriptions) { - subscription.cancel(); - } - }; - }, []); - - useEffect(() { - downloader.onFileExists = (track) async { - if (!context.mounted) return false; - - if (!showingDialogCompleter.value.isCompleted) { - await showingDialogCompleter.value.future; - } - - final replaceAll = ref.read(replaceDownloadedFileState); - - if (replaceAll != null) return replaceAll; - - showingDialogCompleter.value = Completer(); - - if (context.mounted) { - final result = await showDialog( - context: context, - builder: (context) => ReplaceDownloadedDialog( - track: track, - ), - ) ?? - false; - - showingDialogCompleter.value.complete(); - return result; - } - - // it'll never reach here as root_app is always mounted - return false; - }; - return null; - }, [downloader]); - - // checks for latest version of the application + ref.listen(glanceProvider, (_, __) {}); + useGlobalSubscriptions(ref); + useDownloaderDialogs(ref); useEndlessPlayback(ref); - - final backgroundColor = Theme.of(context).scaffoldBackgroundColor; + useCheckYtDlpInstalled(ref); useEffect(() { SystemChrome.setSystemUIOverlayStyle( SystemUiOverlayStyle( statusBarColor: backgroundColor, // status bar color - statusBarIconBrightness: backgroundColor.computeLuminance() > 0.179 - ? Brightness.dark - : Brightness.light, + statusBarIconBrightness: brightness == Brightness.dark + ? Brightness.light + : Brightness.dark, ), ); return null; - }, [backgroundColor]); + }, [backgroundColor, brightness]); - final navTileNames = useMemoized(() { - return getSidebarTileList(context.l10n).map((s) => s.name).toList(); - }, []); - - final scaffold = Scaffold( - body: Sidebar(child: child), - extendBody: true, - drawerScrimColor: Colors.transparent, - endDrawer: kIsDesktop - ? Container( - constraints: const BoxConstraints(maxWidth: 800), - decoration: BoxDecoration( - boxShadow: theme.brightness == Brightness.light - ? null - : kElevationToShadow[8], - ), - margin: const EdgeInsets.only( - top: 40, - bottom: 100, - ), - child: Consumer( - builder: (context, ref, _) { - final playlist = ref.watch(audioPlayerProvider); - final playlistNotifier = - ref.read(audioPlayerProvider.notifier); - - return PlayerQueue.fromAudioPlayerNotifier( - floating: true, - playlist: playlist, - notifier: playlistNotifier, - ); - }, - ), - ) - : null, - bottomNavigationBar: const Column( - mainAxisSize: MainAxisSize.min, - children: [ + final scaffold = MediaQuery.removeViewInsets( + context: context, + removeBottom: true, + child: const Scaffold( + footers: [ BottomPlayer(), SpotubeNavigationBar(), ], + floatingFooter: true, + child: Sidebar(child: AutoRouter()), ), ); - if (!kIsAndroid) { - return scaffold; - } - - final topRoute = GoRouterState.of(context).topRoute; - final canPop = topRoute != null && !navTileNames.contains(topRoute.name); - - return AppPopScope( - canPop: canPop, - onPopInvoked: (didPop) { - if (didPop) return; - - if (topRoute?.name == HomePage.name) { - SystemNavigator.pop(); - } else { - context.goNamed(HomePage.name); - } - }, - child: scaffold, - ); + return scaffold; } } diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index d5de12f0..3826a0b6 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -1,21 +1,20 @@ -import 'dart:async'; - -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:fuzzywuzzy/fuzzywuzzy.dart'; -import 'package:gap/gap.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_undraw/flutter_undraw.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/routes.gr.dart'; + import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/utils/use_force_update.dart'; +import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; import 'package:spotube/pages/search/sections/albums.dart'; import 'package:spotube/pages/search/sections/artists.dart'; import 'package:spotube/pages/search/sections/playlists.dart'; @@ -23,9 +22,9 @@ import 'package:spotube/pages/search/sections/tracks.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; +import 'package:auto_route/auto_route.dart'; -import 'package:spotube/utils/platform.dart'; - +@RoutePage() class SearchPage extends HookConsumerWidget { static const name = "search"; @@ -34,12 +33,15 @@ class SearchPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); - final searchTerm = ref.watch(searchTermStateProvider); - final controller = useSearchController(); + final mediaQuery = MediaQuery.sizeOf(context); + + final scrollController = useScrollController(); + final controller = useShadcnTextEditingController(); + final focusNode = useFocusNode(); final auth = ref.watch(authenticationProvider); - final mediaQuery = MediaQuery.of(context); + final searchTerm = ref.watch(searchTermStateProvider); final searchTrack = ref.watch(searchProvider(SearchType.track)); final searchAlbum = ref.watch(searchProvider(SearchType.album)); final searchPlaylist = ref.watch(searchProvider(SearchType.playlist)); @@ -55,210 +57,180 @@ class SearchPage extends HookConsumerWidget { return null; }, []); - final resultWidget = HookBuilder( - builder: (context) { - final controller = useScrollController(); + void onSubmitted(String value) { + ref.read(searchTermStateProvider.notifier).state = value; + if (value.trim().isEmpty) { + return; + } + KVStoreService.setRecentSearches( + { + value, + ...KVStoreService.recentSearches, + }.toList(), + ); + } - return InterScrollbar( - controller: controller, - child: SingleChildScrollView( - controller: controller, - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SearchTracksSection(), - SearchPlaylistsSection(), - Gap(20), - SearchArtistsSection(), - Gap(20), - SearchAlbumsSection(), - ], - ), - ), - ), - ), - ); + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + context.navigateTo(const HomeRoute()); }, - ); + child: SafeArea( + bottom: false, + child: Scaffold( + headers: [ + if (kTitlebarVisible) + const TitleBar(automaticallyImplyLeading: false, height: 30) + ], + child: auth.asData?.value == null + ? const AnonymousFallback() + : Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(20), + child: ListenableBuilder( + listenable: controller, + builder: (context, _) { + final suggestions = controller.text.isEmpty + ? KVStoreService.recentSearches + : KVStoreService.recentSearches + .where( + (s) => + weightedRatio( + s.toLowerCase(), + controller.text.toLowerCase(), + ) > + 50, + ) + .toList(); - return SafeArea( - bottom: false, - child: Scaffold( - appBar: kIsDesktop && !kIsMacOS - ? const PageWindowTitleBar(automaticallyImplyLeading: true) - : null, - body: auth.asData?.value == null - ? const AnonymousFallback() - : Column( - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if ((kIsMobile || kIsMacOS) && context.canPop()) - const BackButton() - else - const Gap(20), - Expanded( - child: Padding( - padding: const EdgeInsets.only( - right: 20, - top: 20, - bottom: 20, - ), - child: SearchAnchor( - searchController: controller, - viewBuilder: (_) => HookBuilder(builder: (context) { - final searchController = - useListenable(controller); - final update = useForceUpdate(); - final suggestions = searchController.text.isEmpty - ? KVStoreService.recentSearches - : KVStoreService.recentSearches - .where( - (s) => - weightedRatio( - s.toLowerCase(), - searchController.text - .toLowerCase(), - ) > - 50, - ) - .toList(); + return KeyboardListener( + focusNode: focusNode, + autofocus: true, + onKeyEvent: (value) { + final isEnter = value.logicalKey == + LogicalKeyboardKey.enter; - return ListView.builder( - itemCount: suggestions.length, - itemBuilder: (context, index) { - final suggestion = suggestions[index]; - - return ListTile( - leading: const Icon(SpotubeIcons.history), - title: Text(suggestion), - trailing: IconButton( - icon: const Icon(SpotubeIcons.trash), - onPressed: () { - KVStoreService.setRecentSearches( - KVStoreService.recentSearches - .where((s) => s != suggestion) - .toList(), - ); - update(); - }, - ), - onTap: () { - controller.closeView(suggestion); - ref - .read( - searchTermStateProvider.notifier) - .state = suggestion; + if (isEnter) { + onSubmitted(controller.text); + focusNode.unfocus(); + } }, + child: AutoComplete( + suggestions: suggestions, + child: TextField( + autofocus: true, + controller: controller, + leading: + const Icon(SpotubeIcons.search), + textInputAction: TextInputAction.search, + placeholder: Text(context.l10n.search), + trailing: AnimatedCrossFade( + duration: + const Duration(milliseconds: 300), + crossFadeState: + controller.text.isNotEmpty + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: IconButton.ghost( + size: ButtonSize.small, + icon: + const Icon(SpotubeIcons.close), + onPressed: () { + controller.clear(); + }, + ), + secondChild: const SizedBox.square( + dimension: 28), + ), + onSubmitted: onSubmitted, + ), + ), ); - }, - ); - }), - suggestionsBuilder: (context, controller) { - return []; - }, - viewOnSubmitted: (value) async { - controller.closeView(value); - Timer( - const Duration(milliseconds: 50), - () { - ref - .read(searchTermStateProvider.notifier) - .state = value; - if (value.trim().isEmpty) { - return; - } - KVStoreService.setRecentSearches( - { - value, - ...KVStoreService.recentSearches, - }.toList(), - ); - }, - ); - }, - builder: (context, controller) { - return SearchBar( - autoFocus: queries.none((s) => - s.asData?.value != null && - !s.hasError) && - !kIsMobile, - controller: controller, - leading: const Icon(SpotubeIcons.search), - hintText: "${context.l10n.search}...", - onTap: controller.openView, - onChanged: (_) => controller.openView(), - ); - }, + }), ), ), - ), - ], - ), - Expanded( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: searchTerm.isEmpty - ? Column( + ], + ), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: switch ((searchTerm.isEmpty, isFetching)) { + (true, false) => Column( children: [ SizedBox( - height: mediaQuery.size.height * 0.2, + height: mediaQuery.height * 0.2, ), - Icon( - SpotubeIcons.web, - size: 120, - color: theme.colorScheme.onSurface - .withOpacity(0.7), + Undraw( + illustration: UndrawIllustration.explore, + color: theme.colorScheme.primary, + height: 200 * theme.scaling, ), const SizedBox(height: 20), - Text( - context.l10n.search_to_get_results, - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w900, - color: theme.colorScheme.onSurface - .withOpacity(0.5), + Text(context.l10n.search_to_get_results) + .large(), + ], + ), + (false, true) => Container( + constraints: BoxConstraints( + maxWidth: mediaQuery.lgAndUp + ? mediaQuery.width * 0.5 + : mediaQuery.width, + ), + padding: const EdgeInsets.symmetric( + horizontal: 20, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + context.l10n.crunching_results, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w900, + color: theme.colorScheme.foreground + .withOpacity(0.7), + ), + ), + const SizedBox(height: 20), + const LinearProgressIndicator(), + ], + ), + ), + _ => InterScrollbar( + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: SafeArea( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + SearchTracksSection(), + SearchPlaylistsSection(), + Gap(20), + SearchArtistsSection(), + Gap(20), + SearchAlbumsSection(), + ], + ), ), ), - ], - ) - : isFetching - ? Container( - constraints: BoxConstraints( - maxWidth: mediaQuery.lgAndUp - ? mediaQuery.size.width * 0.5 - : mediaQuery.size.width, - ), - padding: const EdgeInsets.symmetric( - horizontal: 20, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: - CrossAxisAlignment.center, - children: [ - Text( - context.l10n.crunching_results, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w900, - color: theme.colorScheme.onSurface - .withOpacity(0.7), - ), - ), - const SizedBox(height: 20), - const LinearProgressIndicator(), - ], - ), - ) - : resultWidget, + ), + ), + }, + ), ), - ), - ], - ), + ], + ), + ), ), ); } diff --git a/lib/pages/search/sections/albums.dart b/lib/pages/search/sections/albums.dart index 857eb59c..105c23d5 100644 --- a/lib/pages/search/sections/albums.dart +++ b/lib/pages/search/sections/albums.dart @@ -1,6 +1,6 @@ -import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; diff --git a/lib/pages/search/sections/artists.dart b/lib/pages/search/sections/artists.dart index 16295580..9a94b3c1 100644 --- a/lib/pages/search/sections/artists.dart +++ b/lib/pages/search/sections/artists.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart' hide Page; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; diff --git a/lib/pages/search/sections/playlists.dart b/lib/pages/search/sections/playlists.dart index 3799f9fa..17bf4849 100644 --- a/lib/pages/search/sections/playlists.dart +++ b/lib/pages/search/sections/playlists.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart' hide Page; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart index 6ec8f685..bacbbb57 100644 --- a/lib/pages/search/sections/tracks.dart +++ b/lib/pages/search/sections/tracks.dart @@ -1,6 +1,7 @@ import 'package:collection/collection.dart'; -import 'package:flutter/material.dart' hide Page; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/dialogs/prompt_dialog.dart'; import 'package:spotube/components/dialogs/select_device_dialog.dart'; @@ -37,7 +38,7 @@ class SearchTracksSection extends HookConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( context.l10n.songs, - style: theme.textTheme.titleLarge!, + style: theme.typography.h4, ), ), if (searchTrack.isLoading) @@ -54,6 +55,8 @@ class SearchTracksSection extends HookConsumerWidget { final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice == null) return; + if (isRemoteDevice) { final remotePlayback = ref.read(connectProvider.notifier); final remotePlaylist = ref.read(queueProvider); diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart index 1357c52f..27775f3c 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -1,7 +1,8 @@ -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/env.dart'; +import 'package:spotube/components/button/back_button.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/links/hyper_link.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; @@ -10,15 +11,17 @@ import 'package:spotube/hooks/controllers/use_package_info.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:auto_route/auto_route.dart'; final _licenseProvider = FutureProvider((ref) async { return await rootBundle.loadString("LICENSE"); }); -class AboutSpotube extends HookConsumerWidget { +@RoutePage() +class AboutSpotubePage extends HookConsumerWidget { static const name = "about"; - const AboutSpotube({super.key}); + const AboutSpotubePage({super.key}); @override Widget build(BuildContext context, ref) { @@ -26,154 +29,180 @@ class AboutSpotube extends HookConsumerWidget { final license = ref.watch(_licenseProvider); final theme = Theme.of(context); - const colon = Text(":"); + const colon = TableCell(child: Text(":")); - return Scaffold( - appBar: PageWindowTitleBar( - leading: const BackButton(), - title: Text(context.l10n.about_spotube), - ), - body: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - children: [ - Assets.spotubeLogoPng.image( - height: 200, - width: 200, - ), - Center( - child: Column( - children: [ - Text( - context.l10n.spotube_description, - style: theme.textTheme.titleLarge, + return SafeArea( + bottom: false, + child: Scaffold( + headers: [ + TitleBar( + leading: const [BackButton()], + title: Text(context.l10n.about_spotube), + ) + ], + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + Assets.spotubeLogoPng.image( + height: 200, + width: 200, + ), + Center( + child: Column( + children: [ + Text(context.l10n.spotube_description).semiBold().large(), + const SizedBox(height: 20), + Table( + columnWidths: const { + 0: FixedTableSize(95), + 1: FixedTableSize(10), + 2: IntrinsicTableSize(), + }, + defaultRowHeight: const FixedTableSize(40), + rows: [ + TableRow( + cells: [ + TableCell(child: Text(context.l10n.founder)), + colon, + TableCell( + child: Hyperlink( + context.l10n.kingkor_roy_tirtho, + "https://github.com/KRTirtho", + ), + ) + ], + ), + TableRow( + cells: [ + TableCell(child: Text(context.l10n.version)), + colon, + TableCell(child: Text("v${packageInfo.version}")) + ], + ), + TableRow( + cells: [ + TableCell(child: Text(context.l10n.channel)), + colon, + TableCell(child: Text(Env.releaseChannel.name)) + ], + ), + TableRow( + cells: [ + TableCell(child: Text(context.l10n.build_number)), + colon, + TableCell( + child: Text(packageInfo.buildNumber + .replaceAll(".", " ")), + ) + ], + ), + const TableRow( + cells: [ + TableCell(child: Text("Website")), + colon, + TableCell( + child: Hyperlink( + "spotube.krtirtho.dev", + "https://spotube.krtirtho.dev", + ), + ), + ], + ), + TableRow( + cells: [ + TableCell(child: Text(context.l10n.repository)), + colon, + const TableCell( + child: Hyperlink( + "github.com/KRTirtho/spotube", + "https://github.com/KRTirtho/spotube", + ), + ), + ], + ), + TableRow( + cells: [ + TableCell(child: Text(context.l10n.license)), + colon, + const TableCell( + child: Hyperlink( + "BSD-4-Clause", + "https://raw.githubusercontent.com/KRTirtho/spotube/master/LICENSE", + ), + ), + ], + ), + TableRow( + cells: [ + TableCell(child: Text(context.l10n.bug_issues)), + colon, + const TableCell( + child: Hyperlink( + "Discord#chat", + "https://discord.gg/uJ94vxB6vg", + ), + ), + ], + ), + ], + ), + ], + ), + ), + const SizedBox(height: 20), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => launchUrl( + Uri.parse("https://discord.gg/uJ94vxB6vg"), + mode: LaunchMode.externalApplication, ), - const SizedBox(height: 20), - Table( - columnWidths: const { - 0: FixedColumnWidth(95), - 1: FixedColumnWidth(10), - 2: IntrinsicColumnWidth(), + child: const UniversalImage( + path: + "https://discord.com/api/guilds/1012234096237350943/widget.png?style=banner2", + ), + ), + ), + const SizedBox(height: 20), + Text( + context.l10n.made_with, + textAlign: TextAlign.center, + style: theme.typography.small, + ), + Text( + context.l10n.copyright(DateTime.now().year), + textAlign: TextAlign.center, + style: theme.typography.small, + ), + const SizedBox(height: 20), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 750), + child: SafeArea( + child: license.when( + data: (data) { + return Text( + data, + style: theme.typography.small, + ); + }, + loading: () { + return const Center( + child: CircularProgressIndicator(), + ); + }, + error: (e, s) { + return Text( + e.toString(), + style: theme.typography.small, + ); }, - children: [ - TableRow( - children: [ - Text(context.l10n.founder), - colon, - Hyperlink( - context.l10n.kingkor_roy_tirtho, - "https://github.com/KRTirtho", - ) - ], - ), - TableRow( - children: [ - Text(context.l10n.version), - colon, - Text("v${packageInfo.version}") - ], - ), - TableRow( - children: [ - Text(context.l10n.channel), - colon, - Text(Env.releaseChannel.name) - ], - ), - TableRow( - children: [ - Text(context.l10n.build_number), - colon, - Text(packageInfo.buildNumber.replaceAll(".", " ")) - ], - ), - TableRow( - children: [ - Text(context.l10n.repository), - colon, - const Hyperlink( - "github.com/KRTirtho/spotube", - "https://github.com/KRTirtho/spotube", - ), - ], - ), - TableRow( - children: [ - Text(context.l10n.license), - colon, - const Hyperlink( - "BSD-4-Clause", - "https://raw.githubusercontent.com/KRTirtho/spotube/master/LICENSE", - ), - ], - ), - TableRow( - children: [ - Text(context.l10n.bug_issues), - colon, - const Hyperlink( - "github.com/KRTirtho/spotube/issues", - "https://github.com/KRTirtho/spotube/issues", - ), - ], - ), - ], ), - ], - ), - ), - const SizedBox(height: 20), - MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => launchUrl( - Uri.parse("https://discord.gg/uJ94vxB6vg"), - mode: LaunchMode.externalApplication, - ), - child: const UniversalImage( - path: - "https://discord.com/api/guilds/1012234096237350943/widget.png?style=banner2", ), ), - ), - const SizedBox(height: 20), - Text( - context.l10n.made_with, - textAlign: TextAlign.center, - style: theme.textTheme.bodySmall, - ), - Text( - context.l10n.copyright(DateTime.now().year), - textAlign: TextAlign.center, - style: theme.textTheme.bodySmall, - ), - const SizedBox(height: 20), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 750), - child: SafeArea( - child: license.when( - data: (data) { - return Text( - data, - style: theme.textTheme.bodySmall, - ); - }, - loading: () { - return const Center( - child: CircularProgressIndicator(), - ); - }, - error: (e, s) { - return Text( - e.toString(), - style: theme.textTheme.bodySmall, - ); - }, - ), - ), - ), - ], + ], + ), ), ), ), diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index 1f018dab..8ac2c1b9 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -1,15 +1,19 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:collection/collection.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/button/back_button.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; +import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/blacklist_provider.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class BlackListPage extends HookConsumerWidget { static const name = "blacklist"; @@ -43,50 +47,52 @@ class BlackListPage extends HookConsumerWidget { [blacklist, searchText.value], ); - return Scaffold( - appBar: PageWindowTitleBar( - title: Text(context.l10n.blacklist), - centerTitle: true, - leading: const BackButton(), - ), - body: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: TextField( - onChanged: (value) => searchText.value = value, - decoration: InputDecoration( - hintText: context.l10n.search, - prefixIcon: const Icon(SpotubeIcons.search), + return SafeArea( + bottom: false, + child: Scaffold( + headers: [ + TitleBar( + title: Text(context.l10n.blacklist), + leading: const [BackButton()], + ) + ], + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + onChanged: (value) => searchText.value = value, + placeholder: Text(context.l10n.search), + leading: const Icon(SpotubeIcons.search), ), ), - ), - InterScrollbar( - controller: controller, - child: ListView.builder( + InterScrollbar( controller: controller, - shrinkWrap: true, - itemCount: filteredBlacklist.length, - itemBuilder: (context, index) { - final item = filteredBlacklist.elementAt(index); - return ListTile( - leading: Text("${index + 1}."), - title: Text("${item.name} (${item.elementType.name})"), - subtitle: Text(item.elementId), - trailing: IconButton( - icon: Icon(SpotubeIcons.trash, color: Colors.red[400]), - onPressed: () { - ref - .read(blacklistProvider.notifier) - .remove(filteredBlacklist.elementAt(index).elementId); - }, - ), - ); - }, + child: ListView.builder( + controller: controller, + shrinkWrap: true, + itemCount: filteredBlacklist.length, + itemBuilder: (context, index) { + final item = filteredBlacklist.elementAt(index); + return ButtonTile( + style: ButtonVariance.ghost, + leading: Text("${index + 1}."), + title: Text("${item.name} (${item.elementType.name})"), + subtitle: Text(item.elementId), + trailing: IconButton.ghost( + icon: Icon(SpotubeIcons.trash, color: Colors.red[400]), + onPressed: () { + ref.read(blacklistProvider.notifier).remove( + filteredBlacklist.elementAt(index).elementId); + }, + ), + ); + }, + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart index 6ccbe32f..4985b57a 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -1,14 +1,19 @@ -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_undraw/flutter_undraw.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/button/back_button.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/logs/logs_provider.dart'; import 'package:spotube/services/logger/logger.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class LogsPage extends HookConsumerWidget { static const name = "logs"; @@ -21,57 +26,77 @@ class LogsPage extends HookConsumerWidget { final logsQuery = ref.watch(logsProvider); return Scaffold( - appBar: PageWindowTitleBar( - title: Text(context.l10n.logs), - leading: const BackButton(), - actions: [ - IconButton( - icon: const Icon(SpotubeIcons.clipboard), - iconSize: 16, - onPressed: () async { - final logsSnapshot = await ref.read(logsProvider.future); + headers: [ + SafeArea( + bottom: false, + child: TitleBar( + title: Text(context.l10n.logs), + leading: const [BackButton()], + trailing: [ + IconButton.ghost( + icon: const Icon(SpotubeIcons.clipboard, size: 16), + onPressed: () async { + final logsSnapshot = await ref.read(logsProvider.future); - await Clipboard.setData(ClipboardData(text: logsSnapshot)); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.copied_to_clipboard("")), - ), - ); - } - }, - ), - IconButton( - icon: const Icon(SpotubeIcons.trash), - iconSize: 16, - onPressed: () async { - ref.invalidate(logsProvider); - - final logsFile = await AppLogger.getLogsPath(); - - await logsFile.writeAsString(""); - }, - ) - ], - ), - body: SafeArea( - child: switch (logsQuery) { - AsyncData(:final value) => Card( - child: InterScrollbar( - controller: controller, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: SingleChildScrollView( - controller: controller, - child: Text(value), - ), - ), + await Clipboard.setData(ClipboardData(text: logsSnapshot)); + if (context.mounted) { + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + child: Basic( + title: Text(context.l10n.copied_to_clipboard("")), + ), + ); + }, + ); + } + }, ), + IconButton.ghost( + icon: const Icon( + SpotubeIcons.trash, + size: 16, + ), + onPressed: () async { + ref.invalidate(logsProvider); + + final logsFile = await AppLogger.getLogsPath(); + + await logsFile.writeAsString(""); + }, + ) + ], + ), + ) + ], + child: switch (logsQuery) { + AsyncData(:final value) => InterScrollbar( + controller: controller, + child: SingleChildScrollView( + padding: const EdgeInsets.all(8.0), + controller: controller, + child: Card(child: SelectableText(value)), ), - AsyncError(:final error) => Center(child: Text(error.toString())), - _ => const Center(child: CircularProgressIndicator()), - }, - ), + ), + AsyncError(:final error) => switch (error) { + StateError() => Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Undraw( + illustration: UndrawIllustration.noData, + height: 200 * context.theme.scaling, + width: 200 * context.theme.scaling, + color: context.theme.colorScheme.primary, + ), + Text(context.l10n.no_logs_found).muted().small(), + ], + ), + _ => Center(child: Text(error.toString())), + }, + _ => const Center(child: CircularProgressIndicator()), + }, ); } } diff --git a/lib/pages/settings/sections/about.dart b/lib/pages/settings/sections/about.dart index a0a5bf30..82c98e90 100644 --- a/lib/pages/settings/sections/about.dart +++ b/lib/pages/settings/sections/about.dart @@ -1,8 +1,11 @@ +import 'package:auto_route/auto_route.dart'; import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; +import 'package:flutter/material.dart' show ListTile; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide ButtonStyle; import 'package:spotube/collections/env.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/components/adaptive/adaptive_list_tile.dart'; @@ -42,12 +45,25 @@ class SettingsAboutSection extends HookConsumerWidget { ), ), ), - trailing: (context, update) => FilledButton( - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll(Colors.red[100]), - foregroundColor: - const WidgetStatePropertyAll(Colors.pinkAccent), - padding: const WidgetStatePropertyAll(EdgeInsets.all(15)), + trailing: (context, update) => Button( + style: ButtonVariance.primary.copyWith( + decoration: (context, states, value) { + final decoration = ButtonVariance.primary + .decoration(context, states) as BoxDecoration; + + if (states.contains(WidgetState.hovered)) { + return decoration.copyWith(color: Colors.pink[400]); + } else if (states.contains(WidgetState.focused)) { + return decoration.copyWith(color: Colors.pink[300]); + } else if (states.isNotEmpty) { + return decoration; + } + + return decoration.copyWith(color: Colors.pink); + }, + textStyle: (context, states, value) => ButtonVariance.primary + .textStyle(context, states) + .copyWith(color: Colors.white), ), onPressed: () { launchUrlString( @@ -55,29 +71,26 @@ class SettingsAboutSection extends HookConsumerWidget { mode: LaunchMode.externalApplication, ); }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(SpotubeIcons.heart), - const SizedBox(width: 5), - Text(context.l10n.please_sponsor), - ], - ), + leading: const Icon(SpotubeIcons.heart), + child: Text(context.l10n.please_sponsor), ), ), if (Env.enableUpdateChecker) - SwitchListTile( - secondary: const Icon(SpotubeIcons.update), + ListTile( + leading: const Icon(SpotubeIcons.update), title: Text(context.l10n.check_for_updates), - value: preferences.checkUpdate, - onChanged: (checked) => preferencesNotifier.setCheckUpdate(checked), + trailing: Switch( + value: preferences.checkUpdate, + onChanged: (checked) => + preferencesNotifier.setCheckUpdate(checked), + ), ), ListTile( leading: const Icon(SpotubeIcons.info), title: Text(context.l10n.about_spotube), trailing: const Icon(SpotubeIcons.angleRight), onTap: () { - GoRouter.of(context).push("/settings/about"); + context.navigateTo(const AboutSpotubeRoute()); }, ) ], diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index b9a26147..5e40b9ec 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -1,19 +1,20 @@ +import 'package:auto_route/auto_route.dart'; import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; +import 'package:flutter/material.dart' show ListTile; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/pages/profile/profile.dart'; import 'package:spotube/pages/mobile_login/hooks/login_callback.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/scrobbler/scrobbler.dart'; import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/utils/service_utils.dart'; class SettingsAccountSection extends HookConsumerWidget { const SettingsAccountSection({super.key}); @@ -21,18 +22,12 @@ class SettingsAccountSection extends HookConsumerWidget { @override Widget build(context, ref) { final theme = Theme.of(context); - final router = GoRouter.of(context); final auth = ref.watch(authenticationProvider); final scrobbler = ref.watch(scrobblerProvider); final me = ref.watch(meProvider); final meData = me.asData?.value; - final logoutBtnStyle = FilledButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - ); - final onLogin = useLoginCallback(ref); return SectionCardWithHeading( @@ -44,8 +39,9 @@ class SettingsAccountSection extends HookConsumerWidget { title: Text(context.l10n.user_profile), trailing: Padding( padding: const EdgeInsets.all(8.0), - child: CircleAvatar( - backgroundImage: UniversalImage.imageProvider( + child: Avatar( + initials: Avatar.getInitials(meData?.displayName ?? "User"), + provider: UniversalImage.imageProvider( (meData?.images).asUrlString( placeholder: ImagePlaceholder.artist, ), @@ -53,7 +49,7 @@ class SettingsAccountSection extends HookConsumerWidget { ), ), onTap: () { - ServiceUtils.pushNamed(context, ProfilePage.name); + context.navigateTo(ProfileRoute()); }, ), if (auth.asData?.value == null) @@ -76,15 +72,8 @@ class SettingsAccountSection extends HookConsumerWidget { onTap: constrains.mdAndUp ? null : onLogin, trailing: constrains.smAndDown ? null - : FilledButton( + : Button.primary( onPressed: onLogin, - style: ButtonStyle( - shape: WidgetStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(25.0), - ), - ), - ), child: Text( context.l10n.connect_with_spotify.toUpperCase(), ), @@ -106,11 +95,10 @@ class SettingsAccountSection extends HookConsumerWidget { ), ), ), - trailing: FilledButton( - style: logoutBtnStyle, + trailing: Button.destructive( onPressed: () async { ref.read(authenticationProvider.notifier).logout(); - GoRouter.of(context).pop(); + context.maybePop(); }, child: Text(context.l10n.logout), ), @@ -121,27 +109,22 @@ class SettingsAccountSection extends HookConsumerWidget { leading: const Icon(SpotubeIcons.lastFm), title: Text(context.l10n.login_with_lastfm), subtitle: Text(context.l10n.scrobble_to_lastfm), - trailing: FilledButton.icon( - icon: const Icon(SpotubeIcons.lastFm), - label: Text(context.l10n.connect), + trailing: Button.secondary( + leading: const Icon(SpotubeIcons.lastFm), onPressed: () { - router.push("/lastfm-login"); + context.navigateTo(const LastFMLoginRoute()); }, - style: FilledButton.styleFrom( - backgroundColor: const Color.fromARGB(255, 186, 0, 0), - foregroundColor: Colors.white, - ), + child: Text(context.l10n.connect), ), ) else ListTile( leading: const Icon(SpotubeIcons.lastFm), title: Text(context.l10n.disconnect_lastfm), - trailing: FilledButton( + trailing: Button.destructive( onPressed: () { ref.read(scrobblerProvider.notifier).logout(); }, - style: logoutBtnStyle, child: Text(context.l10n.disconnect), ), ), diff --git a/lib/pages/settings/sections/appearance.dart b/lib/pages/settings/sections/appearance.dart index f97add42..88f39a25 100644 --- a/lib/pages/settings/sections/appearance.dart +++ b/lib/pages/settings/sections/appearance.dart @@ -1,6 +1,6 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show ListTile; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/models/database/database.dart'; @@ -41,15 +41,15 @@ class SettingsAppearanceSection extends HookConsumerWidget { } }, options: [ - DropdownMenuItem( + SelectItemButton( value: LayoutMode.adaptive, child: Text(context.l10n.adaptive), ), - DropdownMenuItem( + SelectItemButton( value: LayoutMode.compact, child: Text(context.l10n.compact), ), - DropdownMenuItem( + SelectItemButton( value: LayoutMode.extended, child: Text(context.l10n.extended), ), @@ -60,15 +60,15 @@ class SettingsAppearanceSection extends HookConsumerWidget { title: Text(context.l10n.theme), value: preferences.themeMode, options: [ - DropdownMenuItem( + SelectItemButton( value: ThemeMode.dark, child: Text(context.l10n.dark), ), - DropdownMenuItem( + SelectItemButton( value: ThemeMode.light, child: Text(context.l10n.light), ), - DropdownMenuItem( + SelectItemButton( value: ThemeMode.system, child: Text(context.l10n.system), ), @@ -79,13 +79,14 @@ class SettingsAppearanceSection extends HookConsumerWidget { } }, ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.amoled), - title: Text(context.l10n.use_amoled_mode), - subtitle: Text(context.l10n.pitch_dark_theme), - value: preferences.amoledDarkTheme, - onChanged: preferencesNotifier.setAmoledDarkTheme, - ), + // ListTile( + // leading: const Icon(SpotubeIcons.amoled), + // title: Text(context.l10n.use_amoled_mode), + // subtitle: Text(context.l10n.pitch_dark_theme), + // trailing: Switch( + // value: preferences.amoledDarkTheme, + // onChanged: preferencesNotifier.setAmoledDarkTheme, + // )), ListTile( leading: const Icon(SpotubeIcons.palette), title: Text(context.l10n.accent_color), @@ -93,20 +94,22 @@ class SettingsAppearanceSection extends HookConsumerWidget { horizontal: 15, vertical: 5, ), - trailing: ColorTile.compact( + trailing: ColorChip( color: preferences.accentColorScheme, + name: preferences.accentColorScheme.name, onPressed: pickColorScheme(), - isActive: true, + isActive: false, ), onTap: pickColorScheme(), ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.colorSync), - title: Text(context.l10n.sync_album_color), - subtitle: Text(context.l10n.sync_album_color_description), - value: preferences.albumColorSync, - onChanged: preferencesNotifier.setAlbumColorSync, - ), + // ListTile( + // leading: const Icon(SpotubeIcons.colorSync), + // title: Text(context.l10n.sync_album_color), + // subtitle: Text(context.l10n.sync_album_color_description), + // trailing: Switch( + // value: preferences.albumColorSync, + // onChanged: preferencesNotifier.setAlbumColorSync, + // )), ]; if (isGettingStarted) { diff --git a/lib/pages/settings/sections/desktop.dart b/lib/pages/settings/sections/desktop.dart index c61f0150..ad45c689 100644 --- a/lib/pages/settings/sections/desktop.dart +++ b/lib/pages/settings/sections/desktop.dart @@ -1,6 +1,6 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; +import 'package:flutter/material.dart' show ListTile; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; @@ -25,11 +25,11 @@ class SettingsDesktopSection extends HookConsumerWidget { title: Text(context.l10n.close_behavior), value: preferences.closeBehavior, options: [ - DropdownMenuItem( + SelectItemButton( value: CloseBehavior.close, child: Text(context.l10n.close), ), - DropdownMenuItem( + SelectItemButton( value: CloseBehavior.minimizeToTray, child: Text(context.l10n.minimize_to_tray), ), @@ -40,23 +40,29 @@ class SettingsDesktopSection extends HookConsumerWidget { } }, ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.tray), + ListTile( + leading: const Icon(SpotubeIcons.tray), title: Text(context.l10n.show_tray_icon), - value: preferences.showSystemTrayIcon, - onChanged: preferencesNotifier.setShowSystemTrayIcon, + trailing: Switch( + value: preferences.showSystemTrayIcon, + onChanged: preferencesNotifier.setShowSystemTrayIcon, + ), ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.window), + ListTile( + leading: const Icon(SpotubeIcons.window), title: Text(context.l10n.use_system_title_bar), - value: preferences.systemTitleBar, - onChanged: preferencesNotifier.setSystemTitleBar, + trailing: Switch( + value: preferences.systemTitleBar, + onChanged: preferencesNotifier.setSystemTitleBar, + ), ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.discord), + ListTile( + leading: const Icon(SpotubeIcons.discord), title: Text(context.l10n.discord_rich_presence), - value: preferences.discordPresence, - onChanged: preferencesNotifier.setDiscordPresence, + trailing: Switch( + value: preferences.discordPresence, + onChanged: preferencesNotifier.setDiscordPresence, + ), ), ], ); diff --git a/lib/pages/settings/sections/developers.dart b/lib/pages/settings/sections/developers.dart index f33fe843..0862e023 100644 --- a/lib/pages/settings/sections/developers.dart +++ b/lib/pages/settings/sections/developers.dart @@ -1,6 +1,9 @@ -import 'package:flutter/material.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart' show ListTile; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; + +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/extensions/context.dart'; @@ -18,7 +21,7 @@ class SettingsDevelopersSection extends HookWidget { title: Text(context.l10n.logs), trailing: const Icon(SpotubeIcons.angleRight), onTap: () { - GoRouter.of(context).push("/settings/logs"); + context.navigateTo(const LogsRoute()); }, ) ], diff --git a/lib/pages/settings/sections/downloads.dart b/lib/pages/settings/sections/downloads.dart index 8e679a7d..516d2aca 100644 --- a/lib/pages/settings/sections/downloads.dart +++ b/lib/pages/settings/sections/downloads.dart @@ -1,8 +1,9 @@ import 'package:file_picker/file_picker.dart'; import 'package:file_selector/file_selector.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show ListTile; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/extensions/context.dart'; @@ -40,9 +41,9 @@ class SettingsDownloadsSection extends HookConsumerWidget { leading: const Icon(SpotubeIcons.download), title: Text(context.l10n.download_location), subtitle: Text(preferences.downloadLocation), - trailing: FilledButton( + trailing: IconButton.secondary( onPressed: pickDownloadLocation, - child: const Icon(SpotubeIcons.folder), + icon: const Icon(SpotubeIcons.folder), ), onTap: pickDownloadLocation, ), diff --git a/lib/pages/settings/sections/language_region.dart b/lib/pages/settings/sections/language_region.dart index 18c2d088..26f820de 100644 --- a/lib/pages/settings/sections/language_region.dart +++ b/lib/pages/settings/sections/language_region.dart @@ -1,6 +1,5 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/language_codes.dart'; import 'package:spotube/collections/spotify_markets.dart'; @@ -24,7 +23,6 @@ class SettingsLanguageRegionSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.language_region, children: [ - const Gap(10), AdaptiveSelectTile( value: preferences.locale, onChanged: (locale) { @@ -34,12 +32,12 @@ class SettingsLanguageRegionSection extends HookConsumerWidget { title: Text(context.l10n.language), secondary: const Icon(SpotubeIcons.language), options: [ - DropdownMenuItem( + SelectItemButton( value: const Locale("system", "system"), child: Text(context.l10n.system_default), ), for (final locale in L10n.all) - DropdownMenuItem( + SelectItemButton( value: locale, child: Builder(builder: (context) { final isoCodeName = LanguageLocals.getDisplayLanguage( @@ -64,7 +62,7 @@ class SettingsLanguageRegionSection extends HookConsumerWidget { }, options: spotifyMarkets .map( - (country) => DropdownMenuItem( + (country) => SelectItemButton( value: country.$1, child: Text(country.$2), ), diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart index f8868789..f3b7d131 100644 --- a/lib/pages/settings/sections/playback.dart +++ b/lib/pages/settings/sections/playback.dart @@ -1,21 +1,33 @@ +import 'dart:io'; + +import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.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:flutter/material.dart' show ListTile; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; + import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:piped_client/piped_client.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/form/text_form_field.dart'; +import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/components/adaptive/adaptive_select_tile.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/modules/settings/youtube_engine_not_installed_dialog.dart'; import 'package:spotube/provider/audio_player/sources/invidious_instances_provider.dart'; import 'package:spotube/provider/audio_player/sources/piped_instances_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/sourced_track/enums.dart'; +import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart'; import 'package:spotube/utils/platform.dart'; class SettingsPlaybackSection extends HookConsumerWidget { @@ -30,21 +42,20 @@ class SettingsPlaybackSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.playback, children: [ - const Gap(10), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.audioQuality), title: Text(context.l10n.audio_quality), value: preferences.audioQuality, options: [ - DropdownMenuItem( + SelectItemButton( value: SourceQualities.high, child: Text(context.l10n.high), ), - DropdownMenuItem( + SelectItemButton( value: SourceQualities.medium, child: Text(context.l10n.medium), ), - DropdownMenuItem( + SelectItemButton( value: SourceQualities.low, child: Text(context.l10n.low), ), @@ -55,13 +66,12 @@ class SettingsPlaybackSection extends HookConsumerWidget { } }, ), - const Gap(5), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.api), title: Text(context.l10n.audio_source), value: preferences.audioSource, options: AudioSource.values - .map((e) => DropdownMenuItem( + .map((e) => SelectItemButton( value: e, child: Text(e.label), )) @@ -71,177 +81,391 @@ class SettingsPlaybackSection extends HookConsumerWidget { preferencesNotifier.setAudioSource(value); }, ), - AnimatedSwitcher( + AnimatedCrossFade( duration: const Duration(milliseconds: 300), - child: preferences.audioSource != AudioSource.piped - ? const SizedBox.shrink() - : Consumer(builder: (context, ref, child) { - final instanceList = ref.watch(pipedInstancesFutureProvider); + crossFadeState: preferences.audioSource != AudioSource.piped + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: const SizedBox.shrink(), + secondChild: Consumer( + builder: (context, ref, child) { + final instanceList = ref.watch(pipedInstancesFutureProvider); - return instanceList.when( - data: (data) { - return AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.piped), - title: Text(context.l10n.piped_instance), - subtitle: RichText( - text: TextSpan( - children: [ - TextSpan( - text: context.l10n.piped_description, - style: theme.textTheme.bodyMedium, + return instanceList.when( + data: (data) { + return AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.piped), + title: Text(context.l10n.piped_instance), + subtitle: Text( + "${context.l10n.piped_description}\n" + "${context.l10n.piped_warning}", + ), + value: preferences.pipedInstance, + showValueWhenUnfolded: false, + trailing: [ + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.add_custom_url), + ), + child: IconButton.outline( + icon: const Icon(SpotubeIcons.edit), + size: ButtonSize.small, + onPressed: () { + showDialog( + context: context, + barrierColor: Colors.black.withValues(alpha: 0.5), + builder: (context) => HookBuilder( + builder: (context) { + final controller = + useShadcnTextEditingController( + text: preferences.pipedInstance, + ); + final formKey = useMemoized( + () => GlobalKey(), []); + + return Alert( + title: + Text(context.l10n.piped_instance).h4(), + content: FormBuilder( + key: formKey, + child: Column( + children: [ + const Gap(10), + TextFormBuilderField( + name: "url", + controller: controller, + placeholder: Text( + context.l10n.piped_instance), + validator: + FormBuilderValidators.url(), + ), + const Gap(10), + Row( + children: [ + Expanded( + child: Button.secondary( + onPressed: () { + Navigator.of(context).pop(); + }, + child: + Text(context.l10n.cancel), + ), + ), + const Gap(10), + Expanded( + child: Button.primary( + onPressed: () { + if (!formKey.currentState! + .saveAndValidate()) { + return; + } + preferencesNotifier + .setPipedInstance( + controller.text, + ); + Navigator.of(context).pop(); + }, + child: + Text(context.l10n.save), + ), + ), + ], + ) + ], + ), + ), + ); + }, ), - const TextSpan(text: "\n"), - TextSpan( - text: context.l10n.piped_warning, - style: theme.textTheme.labelMedium, - ) - ], + ); + }, + ), + ) + ], + options: [ + if (data + .none((e) => e.apiUrl == preferences.pipedInstance)) + SelectItemButton( + value: preferences.pipedInstance, + child: Text.rich( + TextSpan( + style: theme.typography.xSmall.copyWith( + color: theme.colorScheme.foreground, + ), + children: [ + TextSpan(text: context.l10n.custom), + const TextSpan(text: "\n"), + TextSpan(text: preferences.pipedInstance), + ], + ), ), ), - value: preferences.pipedInstance, - showValueWhenUnfolded: false, - options: data - .sortedBy((e) => e.name) - .map( - (e) => DropdownMenuItem( - value: e.apiUrl, - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: "${e.name.trim()}\n", - style: theme.textTheme.labelLarge, - ), - TextSpan( - text: e.locations - .map(countryCodeToEmoji) - .join(""), - style: GoogleFonts.notoColorEmoji(), - ), - ], - ), + for (final e in data.sortedBy((e) => e.name)) + SelectItemButton( + value: e.apiUrl, + child: RichText( + text: TextSpan( + style: theme.typography.normal.copyWith( + color: theme.colorScheme.foreground, + ), + children: [ + TextSpan( + text: "${e.name.trim()}\n", ), - ), - ) - .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.invidious - ? const SizedBox.shrink() - : Consumer(builder: (context, ref, child) { - final instanceList = ref.watch(invidiousInstancesProvider); - - return instanceList.when( - data: (data) { - return AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.piped), - title: Text(context.l10n.invidious_instance), - subtitle: RichText( - text: TextSpan( - children: [ - TextSpan( - text: context.l10n.invidious_description, - style: theme.textTheme.bodyMedium, - ), - const TextSpan(text: "\n"), - TextSpan( - text: context.l10n.invidious_warning, - style: theme.textTheme.labelMedium, - ) - ], + TextSpan( + text: e.locations + .map(countryCodeToEmoji) + .join(""), + style: GoogleFonts.notoColorEmoji(), + ), + ], + ), ), ), - value: preferences.invidiousInstance, - showValueWhenUnfolded: false, - options: data - .sortedBy((e) => e.name) - .map( - (e) => DropdownMenuItem( - value: e.details.uri, - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: "${e.name.trim()}\n", - style: theme.textTheme.labelLarge, - ), - TextSpan( - text: countryCodeToEmoji( - e.details.region, - ), - style: GoogleFonts.notoColorEmoji(), - ), - ], - ), - ), - ), - ) - .toList(), - onChanged: (value) { - if (value != null) { - preferencesNotifier.setInvidiousInstance(value); - } - }, - ); + ], + onChanged: (value) { + if (value != null) { + preferencesNotifier.setPipedInstance(value); + } }, - loading: () => const Center( - child: CircularProgressIndicator(), - ), - error: (error, stackTrace) => Text(error.toString()), ); - }), - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: preferences.audioSource != AudioSource.piped - ? const SizedBox.shrink() - : AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.search), - title: Text(context.l10n.search_mode), - value: preferences.searchMode, - options: SearchMode.values - .map((e) => DropdownMenuItem( - value: e, - child: Text(e.label), - )) - .toList(), - onChanged: (value) { - if (value == null) return; - preferencesNotifier.setSearchMode(value); - }, + }, + loading: () => const Center( + child: CircularProgressIndicator(), ), + error: (error, stackTrace) => Text(error.toString()), + ); + }, + ), ), - AnimatedSwitcher( + AnimatedCrossFade( duration: const Duration(milliseconds: 300), - child: preferences.searchMode == SearchMode.youtube && + crossFadeState: preferences.audioSource != AudioSource.invidious + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: const SizedBox.shrink(), + secondChild: Consumer( + builder: (context, ref, child) { + final instanceList = ref.watch(invidiousInstancesProvider); + + return instanceList.when( + data: (data) { + return AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.piped), + title: Text(context.l10n.invidious_instance), + subtitle: Text( + "${context.l10n.invidious_description}\n" + "${context.l10n.invidious_warning}", + ), + trailing: [ + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.add_custom_url), + ), + child: IconButton.outline( + icon: const Icon(SpotubeIcons.edit), + size: ButtonSize.small, + onPressed: () { + showDialog( + context: context, + barrierColor: Colors.black.withValues(alpha: 0.5), + builder: (context) => HookBuilder( + builder: (context) { + final controller = + useShadcnTextEditingController( + text: preferences.invidiousInstance, + ); + final formKey = useMemoized( + () => GlobalKey(), []); + + return Alert( + title: Text(context.l10n.invidious_instance) + .h4(), + content: FormBuilder( + key: formKey, + child: Column( + children: [ + const Gap(10), + TextFormBuilderField( + name: "url", + controller: controller, + placeholder: Text(context + .l10n.invidious_instance), + validator: + FormBuilderValidators.url(), + ), + const Gap(10), + Row( + children: [ + Expanded( + child: Button.secondary( + onPressed: () { + Navigator.of(context).pop(); + }, + child: + Text(context.l10n.cancel), + ), + ), + const Gap(10), + Expanded( + child: Button.primary( + onPressed: () { + if (!formKey.currentState! + .saveAndValidate()) { + return; + } + preferencesNotifier + .setInvidiousInstance( + controller.text, + ); + Navigator.of(context).pop(); + }, + child: + Text(context.l10n.save), + ), + ), + ], + ) + ], + ), + ), + ); + }, + ), + ); + }, + ), + ) + ], + value: preferences.invidiousInstance, + showValueWhenUnfolded: false, + options: [ + if (data.none((e) => + e.details.uri == preferences.invidiousInstance)) + SelectItemButton( + value: preferences.invidiousInstance, + child: Text.rich( + TextSpan( + style: theme.typography.xSmall.copyWith( + color: theme.colorScheme.foreground, + ), + children: [ + TextSpan(text: context.l10n.custom), + const TextSpan(text: "\n"), + TextSpan(text: preferences.invidiousInstance), + ], + ), + ), + ), + for (final e in data.sortedBy((e) => e.name)) + SelectItemButton( + value: e.details.uri, + child: RichText( + text: TextSpan( + style: theme.typography.normal.copyWith( + color: theme.colorScheme.foreground, + ), + children: [ + TextSpan( + text: "${e.name.trim()}\n", + ), + TextSpan( + text: countryCodeToEmoji( + e.details.region, + ), + style: GoogleFonts.notoColorEmoji(), + ), + ], + ), + ), + ), + ], + onChanged: (value) { + if (value != null) { + preferencesNotifier.setInvidiousInstance(value); + } + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stackTrace) => Text(error.toString()), + ); + }, + ), + ), + switch (preferences.audioSource) { + AudioSource.youtube => AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.engine), + title: Text(context.l10n.youtube_engine), + value: preferences.youtubeClientEngine, + options: YoutubeClientEngine.values + .where((e) => e.isAvailableForPlatform()) + .map((e) => SelectItemButton( + value: e, + child: Text(e.label), + )) + .toList(), + onChanged: (value) async { + if (value == null) return; + if (value == YoutubeClientEngine.ytDlp) { + final customPath = KVStoreService.getYoutubeEnginePath(value); + if (!await YtDlpEngine.isInstalled() && + (customPath == null || + !await File(customPath).exists()) && + context.mounted) { + final hasInstalled = await showDialog( + context: context, + builder: (context) => + YouTubeEngineNotInstalledDialog(engine: value), + ); + if (hasInstalled != true) return; + } + } + preferencesNotifier.setYoutubeClientEngine(value); + }, + ), + AudioSource.piped || + AudioSource.invidious => + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.search), + title: Text(context.l10n.search_mode), + value: preferences.searchMode, + options: SearchMode.values + .map((e) => SelectItemButton( + value: e, + child: Text(e.label), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + preferencesNotifier.setSearchMode(value); + }, + ), + _ => const SizedBox.shrink(), + }, + AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + crossFadeState: preferences.searchMode == SearchMode.youtube && (preferences.audioSource == AudioSource.piped || preferences.audioSource == AudioSource.youtube || preferences.audioSource == AudioSource.invidious) - ? SwitchListTile( - secondary: const Icon(SpotubeIcons.skip), - title: Text(context.l10n.skip_non_music), - value: preferences.skipNonMusic, - onChanged: (state) { - preferencesNotifier.setSkipNonMusic(state); - }, - ) - : const SizedBox.shrink(), + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: ListTile( + leading: const Icon(SpotubeIcons.skip), + title: Text(context.l10n.skip_non_music), + trailing: Switch( + value: preferences.skipNonMusic, + onChanged: (state) { + preferencesNotifier.setSkipNonMusic(state); + }, + ), + ), + secondChild: const SizedBox.shrink(), ), - SwitchListTile( + ListTile( title: Text(context.l10n.cache_music), subtitle: kIsMobile ? null @@ -253,7 +477,7 @@ class SettingsPlaybackSection extends HookConsumerWidget { text: context.l10n.cache_folder.toLowerCase(), recognizer: TapGestureRecognizer() ..onTap = preferencesNotifier.openCacheFolder, - style: theme.textTheme.bodyMedium?.copyWith( + style: theme.typography.normal.copyWith( color: theme.colorScheme.primary, decoration: TextDecoration.underline, ), @@ -261,38 +485,42 @@ class SettingsPlaybackSection extends HookConsumerWidget { ], ), ), - secondary: const Icon(SpotubeIcons.cache), - value: preferences.cacheMusic, - onChanged: preferencesNotifier.setCacheMusic, + leading: const Icon(SpotubeIcons.cache), + trailing: Switch( + value: preferences.cacheMusic, + onChanged: preferencesNotifier.setCacheMusic, + ), ), ListTile( leading: const Icon(SpotubeIcons.playlistRemove), title: Text(context.l10n.blacklist), subtitle: Text(context.l10n.blacklist_description), onTap: () { - GoRouter.of(context).push("/settings/blacklist"); + context.navigateTo(const BlackListRoute()); }, trailing: const Icon(SpotubeIcons.angleRight), ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.normalize), + ListTile( + leading: const Icon(SpotubeIcons.normalize), title: Text(context.l10n.normalize_audio), - value: preferences.normalizeAudio, - onChanged: preferencesNotifier.setNormalizeAudio, + trailing: Switch( + value: preferences.normalizeAudio, + onChanged: preferencesNotifier.setNormalizeAudio, + ), ), if (preferences.audioSource != AudioSource.jiosaavn) ...[ - const Gap(5), AdaptiveSelectTile( + popupConstraints: const BoxConstraints(maxWidth: 300), secondary: const Icon(SpotubeIcons.stream), title: Text(context.l10n.streaming_music_codec), value: preferences.streamMusicCodec, showValueWhenUnfolded: false, options: SourceCodecs.values - .map((e) => DropdownMenuItem( + .map((e) => SelectItemButton( value: e, child: Text( e.label, - style: theme.textTheme.labelMedium, + style: theme.typography.small, ), )) .toList(), @@ -301,18 +529,18 @@ class SettingsPlaybackSection extends HookConsumerWidget { preferencesNotifier.setStreamMusicCodec(value); }, ), - const Gap(5), AdaptiveSelectTile( + popupConstraints: const BoxConstraints(maxWidth: 300), secondary: const Icon(SpotubeIcons.file), title: Text(context.l10n.download_music_codec), value: preferences.downloadMusicCodec, showValueWhenUnfolded: false, options: SourceCodecs.values - .map((e) => DropdownMenuItem( + .map((e) => SelectItemButton( value: e, child: Text( e.label, - style: theme.textTheme.labelMedium, + style: theme.typography.small, ), )) .toList(), @@ -320,20 +548,23 @@ class SettingsPlaybackSection extends HookConsumerWidget { if (value == null) return; preferencesNotifier.setDownloadMusicCodec(value); }, - ) + ), ], - SwitchListTile( - secondary: const Icon(SpotubeIcons.repeat), - title: Text(context.l10n.endless_playback), - value: preferences.endlessPlayback, - onChanged: preferencesNotifier.setEndlessPlayback, - ), - SwitchListTile( + ListTile( + leading: const Icon(SpotubeIcons.repeat), + title: Text(context.l10n.endless_playback), + trailing: Switch( + value: preferences.endlessPlayback, + onChanged: preferencesNotifier.setEndlessPlayback, + )), + ListTile( title: Text(context.l10n.enable_connect), subtitle: Text(context.l10n.enable_connect_description), - secondary: const Icon(SpotubeIcons.connect), - value: preferences.enableConnect, - onChanged: preferencesNotifier.setEnableConnect, + leading: const Icon(SpotubeIcons.connect), + trailing: Switch( + value: preferences.enableConnect, + onChanged: preferencesNotifier.setEnableConnect, + ), ), ], ); diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index 8bce4bcf..0948bdeb 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -1,7 +1,8 @@ import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show Material, MaterialType; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/settings/sections/about.dart'; @@ -14,7 +15,9 @@ import 'package:spotube/pages/settings/sections/language_region.dart'; import 'package:spotube/pages/settings/sections/playback.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class SettingsPage extends HookConsumerWidget { static const name = "settings"; @@ -28,37 +31,40 @@ class SettingsPage extends HookConsumerWidget { return SafeArea( bottom: false, child: Scaffold( - appBar: PageWindowTitleBar( - title: Text(context.l10n.settings), - centerTitle: true, - automaticallyImplyLeading: true, - ), - body: Scrollbar( + headers: [ + TitleBar( + title: Text(context.l10n.settings), + ) + ], + child: Scrollbar( controller: controller, child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 1366), child: ScrollConfiguration( behavior: const ScrollBehavior().copyWith(scrollbars: false), - child: ListView( - controller: controller, - children: [ - const SettingsAccountSection(), - const SettingsLanguageRegionSection(), - const SettingsAppearanceSection(), - const SettingsPlaybackSection(), - const SettingsDownloadsSection(), - if (kIsDesktop) const SettingsDesktopSection(), - if (!kIsWeb) const SettingsDevelopersSection(), - const SettingsAboutSection(), - Center( - child: FilledButton( - onPressed: preferencesNotifier.reset, - child: Text(context.l10n.restore_defaults), + child: Material( + type: MaterialType.transparency, + child: ListView( + controller: controller, + children: [ + const SettingsAccountSection(), + const SettingsLanguageRegionSection(), + const SettingsAppearanceSection(), + const SettingsPlaybackSection(), + const SettingsDownloadsSection(), + if (kIsDesktop) const SettingsDesktopSection(), + if (!kIsWeb) const SettingsDevelopersSection(), + const SettingsAboutSection(), + Center( + child: Button.destructive( + onPressed: preferencesNotifier.reset, + child: Text(context.l10n.restore_defaults), + ), ), - ), - const SizedBox(height: 10), - ], + const SizedBox(height: 200), + ], + ), ), ), ), diff --git a/lib/pages/stats/albums/albums.dart b/lib/pages/stats/albums/albums.dart index e14a2f32..834837af 100644 --- a/lib/pages/stats/albums/albums.dart +++ b/lib/pages/stats/albums/albums.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; @@ -10,7 +10,9 @@ import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top/albums.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class StatsAlbumsPage extends HookConsumerWidget { static const name = "stats_albums"; const StatsAlbumsPage({super.key}); @@ -24,30 +26,33 @@ class StatsAlbumsPage extends HookConsumerWidget { final albumsData = topAlbums.asData?.value.items ?? []; - return Scaffold( - appBar: PageWindowTitleBar( - automaticallyImplyLeading: true, - centerTitle: false, - title: Text(context.l10n.albums), - ), - body: Skeletonizer( - enabled: topAlbums.isLoading && !topAlbums.isLoadingNextPage, - child: InfiniteList( - onFetchData: () async { - await topAlbumsNotifier.fetchMore(); - }, - hasError: topAlbums.hasError, - isLoading: topAlbums.isLoading && !topAlbums.isLoadingNextPage, - hasReachedMax: topAlbums.asData?.value.hasMore ?? true, - itemCount: albumsData.length, - itemBuilder: (context, index) { - final album = albumsData[index]; - return StatsAlbumItem( - album: album.album, - info: Text(context.l10n - .count_plays(compactNumberFormatter.format(album.count))), - ); - }, + return SafeArea( + bottom: false, + child: Scaffold( + headers: [ + TitleBar( + title: Text(context.l10n.albums), + ) + ], + child: Skeletonizer( + enabled: topAlbums.isLoading && !topAlbums.isLoadingNextPage, + child: InfiniteList( + onFetchData: () async { + await topAlbumsNotifier.fetchMore(); + }, + hasError: topAlbums.hasError, + isLoading: topAlbums.isLoading && !topAlbums.isLoadingNextPage, + hasReachedMax: topAlbums.asData?.value.hasMore ?? true, + itemCount: albumsData.length, + itemBuilder: (context, index) { + final album = albumsData[index]; + return StatsAlbumItem( + album: album.album, + info: Text(context.l10n + .count_plays(compactNumberFormatter.format(album.count))), + ); + }, + ), ), ), ); diff --git a/lib/pages/stats/artists/artists.dart b/lib/pages/stats/artists/artists.dart index 436bbb57..f3d2f0dd 100644 --- a/lib/pages/stats/artists/artists.dart +++ b/lib/pages/stats/artists/artists.dart @@ -1,6 +1,6 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; @@ -11,7 +11,9 @@ import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top/tracks.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class StatsArtistsPage extends HookConsumerWidget { static const name = "stats_artists"; const StatsArtistsPage({super.key}); @@ -27,30 +29,33 @@ class StatsArtistsPage extends HookConsumerWidget { final artistsData = useMemoized( () => topTracks.asData?.value.artists ?? [], [topTracks.asData?.value]); - return Scaffold( - appBar: PageWindowTitleBar( - automaticallyImplyLeading: true, - centerTitle: false, - title: Text(context.l10n.artists), - ), - body: Skeletonizer( - enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, - child: InfiniteList( - onFetchData: () async { - await topTracksNotifier.fetchMore(); - }, - hasError: topTracks.hasError, - isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, - hasReachedMax: topTracks.asData?.value.hasMore ?? true, - itemCount: artistsData.length, - itemBuilder: (context, index) { - final artist = artistsData[index]; - return StatsArtistItem( - artist: artist.artist, - info: Text(context.l10n - .count_plays(compactNumberFormatter.format(artist.count))), - ); - }, + return SafeArea( + bottom: false, + child: Scaffold( + headers: [ + TitleBar( + title: Text(context.l10n.artists), + ) + ], + child: Skeletonizer( + enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, + child: InfiniteList( + onFetchData: () async { + await topTracksNotifier.fetchMore(); + }, + hasError: topTracks.hasError, + isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, + hasReachedMax: topTracks.asData?.value.hasMore ?? true, + itemCount: artistsData.length, + itemBuilder: (context, index) { + final artist = artistsData[index]; + return StatsArtistItem( + artist: artist.artist, + info: Text(context.l10n + .count_plays(compactNumberFormatter.format(artist.count))), + ); + }, + ), ), ), ); diff --git a/lib/pages/stats/fees/fees.dart b/lib/pages/stats/fees/fees.dart index da62fb30..2f1e4107 100644 --- a/lib/pages/stats/fees/fees.dart +++ b/lib/pages/stats/fees/fees.dart @@ -1,6 +1,6 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:sliver_tools/sliver_tools.dart'; import 'package:spotube/collections/formatters.dart'; @@ -12,7 +12,9 @@ import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top/tracks.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class StatsStreamFeesPage extends HookConsumerWidget { static const name = "stats_stream_fees"; @@ -20,7 +22,6 @@ class StatsStreamFeesPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme, :hintColor) = Theme.of(context); final duration = useState(HistoryDuration.days30); final topTracks = ref.watch( @@ -40,98 +41,97 @@ class StatsStreamFeesPage extends HookConsumerWidget { [artistsData], ); - return Scaffold( - appBar: PageWindowTitleBar( - automaticallyImplyLeading: true, - centerTitle: false, - title: Text(context.l10n.streaming_fees_hypothetical), - ), - body: CustomScrollView( - slivers: [ - SliverCrossAxisConstrained( - maxCrossAxisExtent: 600, - alignment: -1, - child: SliverPadding( - padding: const EdgeInsets.all(16.0), - sliver: SliverToBoxAdapter( - child: Text( - context.l10n.spotify_hipotetical_calculation, - style: textTheme.bodySmall?.copyWith( - color: hintColor, - ), + final translations = { + HistoryDuration.days7: context.l10n.this_week, + HistoryDuration.days30: context.l10n.this_month, + HistoryDuration.months6: context.l10n.last_6_months, + HistoryDuration.year: context.l10n.this_year, + HistoryDuration.years2: context.l10n.last_2_years, + HistoryDuration.allTime: context.l10n.all_time, + }; + + return SafeArea( + bottom: false, + child: Scaffold( + headers: [ + TitleBar( + title: Text(context.l10n.streaming_fees_hypothetical), + ) + ], + child: CustomScrollView( + slivers: [ + SliverCrossAxisConstrained( + maxCrossAxisExtent: 600, + alignment: -1, + child: SliverPadding( + padding: const EdgeInsets.all(16.0), + sliver: SliverToBoxAdapter( + child: Text( + context.l10n.spotify_hipotetical_calculation, + ).small().muted(), ), ), ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - context.l10n.total_money(usdFormatter.format(total)), - style: textTheme.titleLarge, - ), - DropdownButton( - value: duration.value, - onChanged: (value) { - if (value == null) return; - duration.value = value; - }, - items: [ - DropdownMenuItem( - value: HistoryDuration.days7, - child: Text(context.l10n.this_week), - ), - DropdownMenuItem( - value: HistoryDuration.days30, - child: Text(context.l10n.this_month), - ), - DropdownMenuItem( - value: HistoryDuration.months6, - child: Text(context.l10n.last_6_months), - ), - DropdownMenuItem( - value: HistoryDuration.year, - child: Text(context.l10n.this_year), - ), - DropdownMenuItem( - value: HistoryDuration.years2, - child: Text(context.l10n.last_2_years), - ), - DropdownMenuItem( - value: HistoryDuration.allTime, - child: Text(context.l10n.all_time), - ), - ], - ), - ], + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.l10n.total_money(usdFormatter.format(total)), + ).semiBold().large(), + Select( + value: duration.value, + onChanged: (value) { + if (value == null) return; + duration.value = value; + }, + itemBuilder: (context, value) => + Text(translations[value]!), + constraints: const BoxConstraints(maxWidth: 150), + popupWidthConstraint: PopoverConstraint.anchorMaxSize, + popup: SelectPopup( + items: SelectItemBuilder( + childCount: translations.length, + builder: (context, index) { + final entry = translations.entries.elementAt(index); + return SelectItemButton( + value: entry.key, + child: Text(entry.value), + ); + }, + ), + ).call, + ), + ], + ), ), ), - ), - SliverSafeArea( - sliver: Skeletonizer.sliver( - enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, - child: SliverInfiniteList( - onFetchData: () async { - await topTracksNotifier.fetchMore(); - }, - hasError: topTracks.hasError, - isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, - hasReachedMax: topTracks.asData?.value.hasMore ?? true, - itemCount: artistsData.length, - itemBuilder: (context, index) { - final artist = artistsData[index]; - return StatsArtistItem( - artist: artist.artist, - info: Text(usdFormatter.format(artist.count * 0.005)), - ); - }, + SliverSafeArea( + sliver: Skeletonizer.sliver( + enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, + child: SliverInfiniteList( + onFetchData: () async { + await topTracksNotifier.fetchMore(); + }, + hasError: topTracks.hasError, + isLoading: + topTracks.isLoading && !topTracks.isLoadingNextPage, + hasReachedMax: topTracks.asData?.value.hasMore ?? true, + itemCount: artistsData.length, + itemBuilder: (context, index) { + final artist = artistsData[index]; + return StatsArtistItem( + artist: artist.artist, + info: Text(usdFormatter.format(artist.count * 0.005)), + ); + }, + ), ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/pages/stats/minutes/minutes.dart b/lib/pages/stats/minutes/minutes.dart index 3ad0984b..2ee4c8d7 100644 --- a/lib/pages/stats/minutes/minutes.dart +++ b/lib/pages/stats/minutes/minutes.dart @@ -1,6 +1,5 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; @@ -11,7 +10,9 @@ import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top/tracks.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class StatsMinutesPage extends HookConsumerWidget { static const name = "stats_minutes"; @@ -27,33 +28,36 @@ class StatsMinutesPage extends HookConsumerWidget { final tracksData = topTracks.asData?.value.items ?? []; - return Scaffold( - appBar: PageWindowTitleBar( - title: Text(context.l10n.minutes_listened), - centerTitle: false, - automaticallyImplyLeading: true, - ), - body: Skeletonizer( - enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, - child: InfiniteList( - separatorBuilder: (context, index) => const Gap(8), - onFetchData: () async { - await topTracksNotifier.fetchMore(); - }, - hasError: topTracks.hasError, - isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, - hasReachedMax: topTracks.asData?.value.hasMore ?? true, - itemCount: tracksData.length, - itemBuilder: (context, index) { - final track = tracksData[index]; - return StatsTrackItem( - track: track.track, - info: Text( - context.l10n.count_mins(compactNumberFormatter - .format(track.count * track.track.duration!.inMinutes)), - ), - ); - }, + return SafeArea( + bottom: false, + child: Scaffold( + headers: [ + TitleBar( + title: Text(context.l10n.minutes_listened), + ) + ], + child: Skeletonizer( + enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, + child: InfiniteList( + separatorBuilder: (context, index) => const Gap(8), + onFetchData: () async { + await topTracksNotifier.fetchMore(); + }, + hasError: topTracks.hasError, + isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, + hasReachedMax: topTracks.asData?.value.hasMore ?? true, + itemCount: tracksData.length, + itemBuilder: (context, index) { + final track = tracksData[index]; + return StatsTrackItem( + track: track.track, + info: Text( + context.l10n.count_mins(compactNumberFormatter + .format(track.count * track.track.duration!.inMinutes)), + ), + ); + }, + ), ), ), ); diff --git a/lib/pages/stats/playlists/playlists.dart b/lib/pages/stats/playlists/playlists.dart index 4e83b0a2..03ea5126 100644 --- a/lib/pages/stats/playlists/playlists.dart +++ b/lib/pages/stats/playlists/playlists.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; @@ -10,7 +10,9 @@ import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top/playlists.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class StatsPlaylistsPage extends HookConsumerWidget { static const name = "stats_playlists"; const StatsPlaylistsPage({super.key}); @@ -25,32 +27,36 @@ class StatsPlaylistsPage extends HookConsumerWidget { final playlistsData = topPlaylists.asData?.value.items ?? []; - return Scaffold( - appBar: PageWindowTitleBar( - automaticallyImplyLeading: true, - centerTitle: false, - title: Text(context.l10n.playlists), - ), - body: Skeletonizer( - enabled: topPlaylists.isLoading && !topPlaylists.isLoadingNextPage, - child: InfiniteList( - onFetchData: () async { - await topPlaylistsNotifier.fetchMore(); - }, - hasError: topPlaylists.hasError, - isLoading: topPlaylists.isLoading && !topPlaylists.isLoadingNextPage, - hasReachedMax: topPlaylists.asData?.value.hasMore ?? true, - itemCount: playlistsData.length, - itemBuilder: (context, index) { - final playlist = playlistsData[index]; - return StatsPlaylistItem( - playlist: playlist.playlist, - info: Text( - context.l10n - .count_plays(compactNumberFormatter.format(playlist.count)), - ), - ); - }, + return SafeArea( + bottom: false, + child: Scaffold( + headers: [ + TitleBar( + title: Text(context.l10n.playlists), + ) + ], + child: Skeletonizer( + enabled: topPlaylists.isLoading && !topPlaylists.isLoadingNextPage, + child: InfiniteList( + onFetchData: () async { + await topPlaylistsNotifier.fetchMore(); + }, + hasError: topPlaylists.hasError, + isLoading: + topPlaylists.isLoading && !topPlaylists.isLoadingNextPage, + hasReachedMax: topPlaylists.asData?.value.hasMore ?? true, + itemCount: playlistsData.length, + itemBuilder: (context, index) { + final playlist = playlistsData[index]; + return StatsPlaylistItem( + playlist: playlist.playlist, + info: Text( + context.l10n.count_plays( + compactNumberFormatter.format(playlist.count)), + ), + ); + }, + ), ), ), ); diff --git a/lib/pages/stats/stats.dart b/lib/pages/stats/stats.dart index b2dc03c2..da7c64f3 100644 --- a/lib/pages/stats/stats.dart +++ b/lib/pages/stats/stats.dart @@ -1,11 +1,13 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/summary/summary.dart'; import 'package:spotube/modules/stats/top/top.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class StatsPage extends HookConsumerWidget { static const name = "stats"; @@ -13,21 +15,30 @@ class StatsPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - return SafeArea( - bottom: false, - child: Scaffold( - appBar: kIsMacOS || kIsMobile ? null : const PageWindowTitleBar(), - body: CustomScrollView( - slivers: [ - if (kIsMacOS) const SliverGap(20), - const StatsPageSummarySection(), - const StatsPageTopSection(), - const SliverToBoxAdapter( - child: SafeArea( - child: SizedBox(), - ), - ) + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + context.navigateTo(const HomeRoute()); + }, + child: SafeArea( + bottom: false, + child: Scaffold( + headers: [ + if (kTitlebarVisible) + const TitleBar(automaticallyImplyLeading: false), ], + child: CustomScrollView( + slivers: [ + if (kIsMacOS) const SliverGap(20), + const StatsPageSummarySection(), + const StatsPageTopSection(), + const SliverToBoxAdapter( + child: SafeArea( + child: SizedBox(), + ), + ) + ], + ), ), ), ); diff --git a/lib/pages/stats/streams/streams.dart b/lib/pages/stats/streams/streams.dart index 059366e0..0d919a44 100644 --- a/lib/pages/stats/streams/streams.dart +++ b/lib/pages/stats/streams/streams.dart @@ -1,6 +1,5 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; @@ -11,7 +10,9 @@ import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top/tracks.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class StatsStreamsPage extends HookConsumerWidget { static const name = "stats_streams"; @@ -27,33 +28,36 @@ class StatsStreamsPage extends HookConsumerWidget { final tracksData = topTracks.asData?.value.items ?? []; - return Scaffold( - appBar: PageWindowTitleBar( - title: Text(context.l10n.streamed_songs), - centerTitle: false, - automaticallyImplyLeading: true, - ), - body: Skeletonizer( - enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, - child: InfiniteList( - separatorBuilder: (context, index) => const Gap(8), - onFetchData: () async { - await topTracksNotifier.fetchMore(); - }, - hasError: topTracks.hasError, - isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, - hasReachedMax: topTracks.asData?.value.hasMore ?? true, - itemCount: tracksData.length, - itemBuilder: (context, index) { - final track = tracksData[index]; - return StatsTrackItem( - track: track.track, - info: Text( - context.l10n - .count_plays(compactNumberFormatter.format(track.count)), - ), - ); - }, + return SafeArea( + bottom: false, + child: Scaffold( + headers: [ + TitleBar( + title: Text(context.l10n.streamed_songs), + ) + ], + child: Skeletonizer( + enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, + child: InfiniteList( + separatorBuilder: (context, index) => const Gap(8), + onFetchData: () async { + await topTracksNotifier.fetchMore(); + }, + hasError: topTracks.hasError, + isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, + hasReachedMax: topTracks.asData?.value.hasMore ?? true, + itemCount: tracksData.length, + itemBuilder: (context, index) { + final track = tracksData[index]; + return StatsTrackItem( + track: track.track, + info: Text( + context.l10n + .count_plays(compactNumberFormatter.format(track.count)), + ), + ); + }, + ), ), ), ); diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index 84c53b74..2918d1d7 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -1,10 +1,10 @@ import 'dart:ui'; -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/fake.dart'; +import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/heart_button/heart_button.dart'; import 'package:spotube/components/image/universal_image.dart'; @@ -20,19 +20,21 @@ import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:auto_route/auto_route.dart'; +@RoutePage() class TrackPage extends HookConsumerWidget { static const name = "track"; final String trackId; const TrackPage({ super.key, - required this.trackId, + @PathParam("id") required this.trackId, }); @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme, :colorScheme) = Theme.of(context); + final ThemeData(:typography, :colorScheme) = Theme.of(context); final mediaQuery = MediaQuery.of(context); final playlist = ref.watch(audioPlayerProvider); @@ -52,182 +54,203 @@ class TrackPage extends HookConsumerWidget { } } - return Scaffold( - appBar: const PageWindowTitleBar( - automaticallyImplyLeading: true, - backgroundColor: Colors.transparent, - ), - extendBodyBehindAppBar: true, - body: Stack( - children: [ - Positioned.fill( - child: Container( - decoration: BoxDecoration( - image: DecorationImage( - image: UniversalImage.imageProvider( - track.album!.images.asUrlString( - placeholder: ImagePlaceholder.albumArt, + return SafeArea( + bottom: false, + child: Scaffold( + headers: const [ + TitleBar( + backgroundColor: Colors.transparent, + surfaceBlur: 0, + ) + ], + floatingHeader: true, + child: Stack( + children: [ + Positioned.fill( + child: Container( + decoration: BoxDecoration( + image: DecorationImage( + image: UniversalImage.imageProvider( + track.album!.images.asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), ), + fit: BoxFit.cover, + colorFilter: ColorFilter.mode( + colorScheme.background.withOpacity(0.5), + BlendMode.srcOver, + ), + alignment: Alignment.topCenter, ), - fit: BoxFit.cover, - colorFilter: ColorFilter.mode( - colorScheme.surface.withOpacity(0.5), - BlendMode.srcOver, - ), - alignment: Alignment.topCenter, ), ), ), - ), - Positioned.fill( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: Skeletonizer( - enabled: trackQuery.isLoading, - child: Container( - alignment: Alignment.topCenter, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - colorScheme.surface, - Colors.transparent, - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - stops: const [0.2, 1], + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Skeletonizer( + enabled: trackQuery.isLoading, + child: Container( + alignment: Alignment.topCenter, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + colorScheme.background, + Colors.transparent, + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.2, 1], + ), ), - ), - child: SafeArea( - child: Wrap( - spacing: 20, - runSpacing: 20, - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - runAlignment: WrapAlignment.center, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: UniversalImage( - path: track.album!.images.asUrlString( - placeholder: ImagePlaceholder.albumArt, + child: SafeArea( + child: Wrap( + spacing: 20, + runSpacing: 20, + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + runAlignment: WrapAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(top: 20), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: UniversalImage( + path: track.album!.images.asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + height: 200, + width: 200, + ), ), - height: 200, - width: 200, ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - crossAxisAlignment: mediaQuery.smAndDown - ? CrossAxisAlignment.center - : CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - track.name!, - style: textTheme.titleLarge, - ), - const Gap(10), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(SpotubeIcons.album), - const Gap(5), - Flexible( - child: LinkText( - track.album!.name!, - '/album/${track.album!.id}', - push: true, - extra: track.album, - ), - ), - ], - ), - const Gap(10), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(SpotubeIcons.artist), - const Gap(5), - Flexible( - child: ArtistLink( - artists: track.artists!, - hideOverflowArtist: false, - ), - ), - ], - ), - const Gap(10), - ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: 350), - child: Row( - mainAxisSize: mediaQuery.smAndDown - ? MainAxisSize.max - : MainAxisSize.min, + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: mediaQuery.smAndDown + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + track.name!, + ).large().semiBold(), + const Gap(10), + Row( + mainAxisSize: MainAxisSize.min, children: [ + const Icon(SpotubeIcons.album), const Gap(5), - if (!isActive && - !playlist.tracks - .containsBy(track, (t) => t.id)) - OutlinedButton.icon( - icon: const Icon(SpotubeIcons.queueAdd), - label: Text(context.l10n.queue), - onPressed: () { - playlistNotifier.addTrack(track); - }, + Flexible( + child: LinkText( + track.album!.name!, + AlbumRoute( + id: track.album!.id!, + album: track.album!, + ), + push: true, ), - const Gap(5), - if (!isActive && - !playlist.tracks - .containsBy(track, (t) => t.id)) - IconButton.outlined( - icon: - const Icon(SpotubeIcons.lightning), - tooltip: context.l10n.play_next, - onPressed: () { - playlistNotifier - .addTracksAtFirst([track]); - }, - ), - const Gap(5), - IconButton.filled( - tooltip: isActive - ? context.l10n.pause_playback - : context.l10n.play, - icon: Icon( - isActive - ? SpotubeIcons.pause - : SpotubeIcons.play, - color: colorScheme.onPrimary, - ), - onPressed: onPlay, ), - const Gap(5), - if (mediaQuery.smAndDown) - const Spacer() - else - const Gap(20), - TrackHeartButton(track: track), - TrackOptions( - track: track, - userPlaylist: false, - ), - const Gap(5), ], ), - ), - ], + const Gap(10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(SpotubeIcons.artist), + const Gap(5), + Flexible( + child: ArtistLink( + artists: track.artists!, + hideOverflowArtist: false, + ), + ), + ], + ), + const Gap(10), + ConstrainedBox( + constraints: + const BoxConstraints(maxWidth: 350), + child: Row( + mainAxisSize: mediaQuery.smAndDown + ? MainAxisSize.max + : MainAxisSize.min, + children: [ + const Gap(5), + if (!isActive && + !playlist.tracks + .containsBy(track, (t) => t.id)) + Button.outline( + leading: + const Icon(SpotubeIcons.queueAdd), + child: Text(context.l10n.queue), + onPressed: () { + playlistNotifier.addTrack(track); + }, + ), + const Gap(5), + if (!isActive && + !playlist.tracks + .containsBy(track, (t) => t.id)) + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.play_next), + ), + child: IconButton.outline( + icon: const Icon( + SpotubeIcons.lightning), + onPressed: () { + playlistNotifier + .addTracksAtFirst([track]); + }, + ), + ), + const Gap(5), + Tooltip( + tooltip: TooltipContainer( + child: Text( + isActive + ? context.l10n.pause_playback + : context.l10n.play, + ), + ), + child: IconButton.primary( + shape: ButtonShape.circle, + icon: Icon( + isActive + ? SpotubeIcons.pause + : SpotubeIcons.play, + ), + onPressed: onPlay, + ), + ), + const Gap(5), + if (mediaQuery.smAndDown) + const Spacer() + else + const Gap(20), + TrackHeartButton(track: track), + TrackOptions( + track: track, + userPlaylist: false, + ), + const Gap(5), + ], + ), + ), + ], + ), ), - ), - ], + ], + ), ), ), ), ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/provider/audio_player/audio_player.dart b/lib/provider/audio_player/audio_player.dart index b4892a0c..aa93bd4f 100644 --- a/lib/provider/audio_player/audio_player.dart +++ b/lib/provider/audio_player/audio_player.dart @@ -45,7 +45,7 @@ class AudioPlayerNotifier extends Notifier { var playlist = await database.select(database.playlistTable).getSingleOrNull(); - var medias = await database.select(database.playlistMediaTable).get(); + final medias = await database.select(database.playlistMediaTable).get(); if (playlist == null) { await database.into(database.playlistTable).insert( @@ -301,7 +301,9 @@ class AudioPlayerNotifier extends Notifier { bool _compareTracks(Track a, Track b) { if ((a is LocalTrack && b is! LocalTrack) || - (a is! LocalTrack && b is LocalTrack)) return false; + (a is! LocalTrack && b is LocalTrack)) { + return false; + } return a is LocalTrack && b is LocalTrack ? (a).path == (b).path @@ -347,7 +349,9 @@ class AudioPlayerNotifier extends Notifier { newIndex < 0 || oldIndex < 0 || newIndex > state.tracks.length - 1 || - oldIndex > state.tracks.length - 1) return; + oldIndex > state.tracks.length - 1) { + return; + } await audioPlayer.moveTrack(oldIndex, newIndex); } diff --git a/lib/provider/audio_player/audio_player_streams.dart b/lib/provider/audio_player/audio_player_streams.dart index e52b6109..54c6d7cd 100644 --- a/lib/provider/audio_player/audio_player_streams.dart +++ b/lib/provider/audio_player/audio_player_streams.dart @@ -1,15 +1,12 @@ import 'dart:async'; +import 'dart:math'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:palette_generator/palette_generator.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/provider/discord_provider.dart'; import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/skip_segments/skip_segments.dart'; import 'package:spotube/provider/scrobbler/scrobbler.dart'; import 'package:spotube/provider/server/sourced_track.dart'; @@ -48,36 +45,12 @@ class AudioPlayerStreamListeners { PlaybackHistoryActions get history => ref.read(playbackHistoryActionsProvider); - Future updatePalette() async { - final palette = ref.read(paletteProvider); - if (!preferences.albumColorSync) { - if (palette != null) ref.read(paletteProvider.notifier).state = null; - return; - } - return Future.microtask(() async { - final activeTrack = ref.read(audioPlayerProvider).activeTrack; - if (activeTrack == null) return; - - final palette = await PaletteGenerator.fromImageProvider( - UniversalImage.imageProvider( - (activeTrack.album?.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - height: 50, - width: 50, - ), - ); - ref.read(paletteProvider.notifier).state = palette; - }); - } - StreamSubscription subscribeToPlaylist() { return audioPlayer.playlistStream.listen((mpvPlaylist) { try { if (audioPlayerState.activeTrack == null) return; notificationService.addTrack(audioPlayerState.activeTrack!); discord.updatePresence(audioPlayerState.activeTrack!); - updatePalette(); } catch (e, stack) { AppLogger.reportError(e, stack); } @@ -131,16 +104,19 @@ class AudioPlayerStreamListeners { StreamSubscription subscribeToPosition() { String lastTrack = ""; // used to prevent multiple calls to the same track return audioPlayer.positionStream.listen((event) async { + final percentProgress = + (event.inSeconds / max(audioPlayer.duration.inSeconds, 1)) * 100; try { - if (event < const Duration(seconds: 3) || + if (percentProgress < 80 || audioPlayerState.playlist.index == -1 || audioPlayerState.playlist.index == audioPlayerState.tracks.length - 1) { return; } - final nextTrack = SpotubeMedia.fromMedia(audioPlayerState - .playlist.medias - .elementAt(audioPlayerState.playlist.index + 1)); + final nextTrack = SpotubeMedia.fromMedia( + audioPlayerState.playlist.medias + .elementAt(audioPlayerState.playlist.index + 1), + ); if (lastTrack == nextTrack.track.id || nextTrack.track is LocalTrack) { return; diff --git a/lib/provider/authentication/authentication.dart b/lib/provider/authentication/authentication.dart index 05a05972..40949e68 100644 --- a/lib/provider/authentication/authentication.dart +++ b/lib/provider/authentication/authentication.dart @@ -118,17 +118,10 @@ class AuthenticationNotifier extends AsyncNotifier { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" }, - validateStatus: (status) => true, ), ); final body = res.data; - if ((res.statusCode ?? 500) >= 400) { - throw Exception( - "Failed to get access token: ${body['error'] ?? res.statusMessage}", - ); - } - return AuthenticationTableCompanion.insert( id: const Value(0), cookie: DecryptedText("${res.headers["set-cookie"]?.join(";")}; $spDc"), diff --git a/lib/provider/glance/glance.dart b/lib/provider/glance/glance.dart new file mode 100644 index 00000000..22faa13f --- /dev/null +++ b/lib/provider/glance/glance.dart @@ -0,0 +1,169 @@ +import 'dart:convert'; + +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:home_widget/home_widget.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; +import 'package:logger/logger.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/server/server.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/logger/logger.dart'; +import 'package:spotube/utils/platform.dart'; + +@pragma("vm:entry-point") +Future glanceBackgroundCallback(Uri? data) async { + final logger = Logger(); + try { + if (data == null || + data.host != "playback" || + data.pathSegments.isEmpty || + data.queryParameters["serverAddress"] == null) { + return; + } + + final command = data.pathSegments.first; + final res = await get( + Uri.parse( + "http://${data.queryParameters["serverAddress"]}/playback/$command", + ), + ); + + if (res.statusCode != 200) { + throw Exception("Failed to execute command: $command\nBody: ${res.body}"); + } + } catch (e) { + logger.e("[GlanceBackgroundCallback] $e"); + } +} + +Future _saveWidgetData(String key, T? value) async { + try { + if (!kIsMobile) return null; + + return await HomeWidget.saveWidgetData(key, value); + } catch (e, stack) { + AppLogger.reportError(e, stack); + return null; + } +} + +Future _updateWidget() async { + try { + if (!kIsMobile) return; + + if (kIsAndroid) { + await HomeWidget.updateWidget( + androidName: 'HomePlayerWidgetReceiver', + qualifiedAndroidName: + 'oss.krtirtho.spotube.glance.HomePlayerWidgetReceiver', + ); + } + if (kIsIOS) { + await HomeWidget.updateWidget( + name: 'HomePlayerWidget', + iOSName: 'HomePlayerWidget', + ); + } + } on Exception catch (e, stack) { + AppLogger.reportError(e, stack); + } +} + +Future _sendActiveTrack(Track? track) async { + if (track == null) { + await _saveWidgetData("activeTrack", null); + await _updateWidget(); + return; + } + + final jsonTrack = track.toJson(); + + final image = track.album?.images?.first; + final cachedImage = await DefaultCacheManager().getSingleFile(image!.url!); + final data = { + ...jsonTrack, + "album": { + ...jsonTrack["album"], + "images": [ + { + ...image.toJson(), + "path": cachedImage.path, + } + ] + } + }; + + await _saveWidgetData("activeTrack", jsonEncode(data)); + + await _updateWidget(); +} + +final glanceProvider = Provider((ref) { + final server = ref.read(serverProvider); + final activeTrack = ref.read(audioPlayerProvider).activeTrack; + + server.whenData( + (value) async { + final (:server, :port) = value; + + await _saveWidgetData( + "playbackServerAddress", + "${server.address.host}:$port", + ); + await _updateWidget(); + }, + ); + + _sendActiveTrack(activeTrack); + + ref.listen(serverProvider, (prev, next) async { + next.whenData( + (value) async { + final (:server, :port) = value; + + await _saveWidgetData( + "playbackServerAddress", + "${server.address.host}:$port", + ); + await _updateWidget(); + }, + ); + }); + + ref.listen( + audioPlayerProvider, + (previous, next) async { + try { + if (previous?.activeTrack != next.activeTrack && + next.activeTrack != null) { + await _sendActiveTrack(next.activeTrack); + } + } catch (e, stack) { + AppLogger.reportError(e, stack); + } + }, + ); + + final subscriptions = [ + audioPlayer.playingStream.listen((playing) async { + await _saveWidgetData("isPlaying", playing); + await _updateWidget(); + }), + audioPlayer.positionStream.listen((position) async { + await _saveWidgetData("position", position.inSeconds); + await _updateWidget(); + }), + audioPlayer.durationStream.listen((duration) async { + await _saveWidgetData("duration", duration.inSeconds); + await _updateWidget(); + }), + ]; + + ref.onDispose(() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + }); +}); diff --git a/lib/provider/history/recent.dart b/lib/provider/history/recent.dart index ef393a17..1ee2a5d6 100644 --- a/lib/provider/history/recent.dart +++ b/lib/provider/history/recent.dart @@ -1,6 +1,7 @@ -import 'package:collection/collection.dart'; -import 'package:drift/drift.dart'; +import 'dart:convert'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/database/database.dart'; @@ -9,48 +10,40 @@ class RecentlyPlayedItemNotifier extends AsyncNotifier> { build() async { final database = ref.watch(databaseProvider); - final uniqueItemIds = await (database.selectOnly( - database.historyTable, - distinct: true, - ) - ..addColumns([database.historyTable.itemId, database.historyTable.id]) - ..where( - database.historyTable.type.isInValues([ - HistoryEntryType.playlist, - HistoryEntryType.album, - ]), - ) - ..limit(10) - ..orderBy([ - OrderingTerm( - expression: database.historyTable.createdAt, - mode: OrderingMode.desc, - ), - ])) - .map( - (row) => row.read(database.historyTable.id), - ) - .get() - .then((value) => value.whereNotNull().toList()); - - final query = database.select(database.historyTable) - ..where( - (tbl) => tbl.id.isIn(uniqueItemIds), + final query = database.customSelect( + """ + WITH RankedHistory AS ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY created_at DESC) AS rn + FROM history_table + WHERE type in ('playlist', 'album') ) - ..orderBy([ - (tbl) => OrderingTerm( - expression: tbl.createdAt, - mode: OrderingMode.desc, - ), - ]); + SELECT * + FROM RankedHistory + WHERE rn = 1 + ORDER BY created_at DESC + LIMIT 10 + """, + readsFrom: {database.historyTable}, + ).map((rows) async { + return await rows.map((row) { + final type = row.read('type'); + return HistoryTableData( + id: row.read('id'), + itemId: row.read('item_id'), + type: HistoryEntryType.values.firstWhere((e) => e.name == type), + createdAt: row.read('created_at'), + data: jsonDecode(row.read('data')) as Map, + ); + }); + }); - final subscription = query.watch().listen((event) { - state = AsyncData(event); + final subscription = query.watch().listen((event) async { + state = AsyncData(await Future.wait(event)); }); ref.onDispose(() => subscription.cancel()); - final items = await query.get(); + final items = await Future.wait(await query.get()); return items; } diff --git a/lib/provider/local_tracks/local_tracks_provider.dart b/lib/provider/local_tracks/local_tracks_provider.dart index 3245ff2d..db8c3401 100644 --- a/lib/provider/local_tracks/local_tracks_provider.dart +++ b/lib/provider/local_tracks/local_tracks_provider.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:io'; -import 'package:collection/collection.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -110,7 +109,7 @@ final localTracksProvider = return null; } }), - ).then((value) => value.whereNotNull().toList()); + ).then((value) => value.nonNulls.toList()); final tracksFromMetadata = filesWithMetadata .map( diff --git a/lib/provider/logs/logs_provider.dart b/lib/provider/logs/logs_provider.dart index b0e95cae..d39059ac 100644 --- a/lib/provider/logs/logs_provider.dart +++ b/lib/provider/logs/logs_provider.dart @@ -5,7 +5,14 @@ import 'package:spotube/services/logger/logger.dart'; final logsProvider = StreamProvider.autoDispose((ref) async* { final file = await AppLogger.getLogsPath(); + // Check if file is empty or non-existent + + if (await file.length() == 0) { + throw StateError("Logs file is empty or non-existent"); + } + final stream = file.openRead().transform(utf8.decoder); + await for (final line in stream) { yield line; } diff --git a/lib/provider/palette_provider.dart b/lib/provider/palette_provider.dart deleted file mode 100644 index 8f0e9e29..00000000 --- a/lib/provider/palette_provider.dart +++ /dev/null @@ -1,4 +0,0 @@ -import 'package:palette_generator/palette_generator.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final paletteProvider = StateProvider((ref) => null); diff --git a/lib/provider/server/router.dart b/lib/provider/server/router.dart index e2a579cc..06ff4a24 100644 --- a/lib/provider/server/router.dart +++ b/lib/provider/server/router.dart @@ -14,6 +14,10 @@ final serverRouterProvider = Provider((ref) { router.get("/stream/", playbackRoutes.getStreamTrackId); + router.get("/playback/toggle-playback", playbackRoutes.togglePlayback); + router.get("/playback/previous", playbackRoutes.previousTrack); + router.get("/playback/next", playbackRoutes.nextTrack); + router.all("/ws", connectRoutes.websocket); return router; diff --git a/lib/provider/server/routes/playback.dart b/lib/provider/server/routes/playback.dart index 34317aa1..1c7d0de7 100644 --- a/lib/provider/server/routes/playback.dart +++ b/lib/provider/server/routes/playback.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:math'; import 'package:dio/dio.dart' hide Response; import 'package:dio/dio.dart' as dio_lib; @@ -22,6 +23,20 @@ import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/service_utils.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; + +final _deviceClients = Set.unmodifiable({ + YoutubeApiClient.ios, + YoutubeApiClient.android, + YoutubeApiClient.mweb, + YoutubeApiClient.safari, +}); + +String? get _randomUserAgent => _deviceClients + .elementAt( + Random().nextInt(_deviceClients.length), + ) + .payload["context"]["client"]["userAgent"]; class ServerPlaybackRoutes { final Ref ref; @@ -47,9 +62,8 @@ class ServerPlaybackRoutes { var options = Options( headers: { ...headers, - "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", - "Cache-Control": "max-age=0", + "user-agent": _randomUserAgent, + "Cache-Control": "max-age=3600", "Connection": "keep-alive", "host": Uri.parse(track.url).host, }, @@ -100,18 +114,30 @@ class ServerPlaybackRoutes { ); } - final res = - await dio.get(track.url, options: options).catchError( - (e, stack) async { - final sourcedTrack = await ref - .read(sourcedTrackProvider(SpotubeMedia(track)).notifier) - .switchToAlternativeSources(); + final res = await dio + .get( + track.url, + options: options.copyWith(headers: { + ...?options.headers, + "user-agent": _randomUserAgent, + }), + ) + .catchError((e, stack) async { + AppLogger.reportError(e, stack); + final sourcedTrack = await ref + .read(sourcedTrackProvider(SpotubeMedia(track)).notifier) + .refreshStreamingUrl(); - ref.read(activeSourcedTrackProvider.notifier).update(sourcedTrack); + ref.read(activeSourcedTrackProvider.notifier).update(sourcedTrack); - return await dio.get(sourcedTrack!.url, options: options); - }, - ); + return await dio.get( + sourcedTrack!.url, + options: options.copyWith(headers: { + ...?options.headers, + "user-agent": _randomUserAgent, + }), + ); + }); final bytes = res.data; @@ -188,6 +214,27 @@ class ServerPlaybackRoutes { return Response.internalServerError(); } } + + /// @get('/playback/toggle-playback') + Future togglePlayback(Request request) async { + audioPlayer.isPlaying + ? await audioPlayer.pause() + : await audioPlayer.resume(); + + return Response.ok("Playback toggled"); + } + + /// @get('/playback/previous') + Future previousTrack(Request request) async { + await audioPlayer.skipToPrevious(); + return Response.ok("Previous track"); + } + + /// @get('/playback/next') + Future nextTrack(Request request) async { + await audioPlayer.skipToNext(); + return Response.ok("Next track"); + } } final serverPlaybackRoutesProvider = diff --git a/lib/provider/server/sourced_track.dart b/lib/provider/server/sourced_track.dart index 58531523..f733f9d6 100644 --- a/lib/provider/server/sourced_track.dart +++ b/lib/provider/server/sourced_track.dart @@ -29,13 +29,14 @@ class SourcedTrackNotifier return sourcedTrack; } - Future switchToAlternativeSources() async { + Future refreshStreamingUrl() async { if (arg == null) { return null; } + return await update((prev) async { - return await SourcedTrack.fetchFromTrackAltSource( - track: arg!.track, + return await SourcedTrack.fetchFromTrack( + track: state.value!, ref: ref, ); }); diff --git a/lib/provider/spotify/artist/following.dart b/lib/provider/spotify/artist/following.dart index 4e6bcfe8..31fa0c5c 100644 --- a/lib/provider/spotify/artist/following.dart +++ b/lib/provider/spotify/artist/following.dart @@ -26,7 +26,10 @@ class FollowedArtistsState extends CursorPaginatedState { class FollowedArtistsNotifier extends CursorPaginatedAsyncNotifier { - FollowedArtistsNotifier() : super(); + final Dio dio; + FollowedArtistsNotifier() + : dio = Dio(), + super(); @override fetch(offset, limit) async { @@ -50,9 +53,44 @@ class FollowedArtistsNotifier ); } + Future _followArtists(List artistIds) async { + try { + final creds = await spotify.getCredentials(); + + await dio.post( + "https://api-partner.spotify.com/pathfinder/v1/query", + data: { + "variables": { + "uris": artistIds.map((id) => "spotify:artist:$id").toList() + }, + "operationName": "addToLibrary", + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": + "a3c1ff58e6a36fec5fe1e3a193dc95d9071d96b9ba53c5ba9c1494fb1ee73915" + } + } + }, + options: Options( + headers: { + "accept": "application/json", + 'app-platform': 'WebPlayer', + 'authorization': 'Bearer ${creds.accessToken}', + 'content-type': 'application/json;charset=UTF-8', + }, + responseType: ResponseType.json, + ), + ); + } on DioException catch (e, stack) { + AppLogger.reportError(e, stack); + } + } + Future saveArtists(List artistIds) async { if (state.value == null) return; - await spotify.me.follow(FollowingType.artist, artistIds); + // await spotify.me.follow(FollowingType.artist, artistIds); + await _followArtists(artistIds); state = await AsyncValue.guard(() async { final artists = await spotify.artists.list(artistIds); diff --git a/lib/provider/spotify/category/playlists.dart b/lib/provider/spotify/category/playlists.dart index 9f1034be..79ac7cd2 100644 --- a/lib/provider/spotify/category/playlists.dart +++ b/lib/provider/spotify/category/playlists.dart @@ -39,7 +39,7 @@ class CategoryPlaylistsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< (json) => PlaylistsFeatured.fromJson(json), ).getPage(limit, offset); - final items = playlists.items?.whereNotNull().toList() ?? []; + final items = playlists.items?.nonNulls.toList() ?? []; return ( items: items, diff --git a/lib/provider/spotify/playlist/playlist.dart b/lib/provider/spotify/playlist/playlist.dart index 0eec3a87..6782fb35 100644 --- a/lib/provider/spotify/playlist/playlist.dart +++ b/lib/provider/spotify/playlist/playlist.dart @@ -104,3 +104,39 @@ final playlistProvider = AsyncNotifierProvider.family( () => PlaylistNotifier(), ); + +final _blendModes = BlendMode.values + .where((e) => switch (e) { + BlendMode.clear || + BlendMode.src || + BlendMode.srcATop || + BlendMode.srcIn || + BlendMode.srcOut || + BlendMode.srcOver || + BlendMode.dstOut || + BlendMode.xor => + false, + _ => true + }) + .toList(); + +typedef PlaylistImageInfo = ({ + Color color, + BlendMode colorBlendMode, + String src, + Alignment placement, +}); + +final playlistImageProvider = Provider.family( + (ref, playlistId) { + final random = Random(); + + return ( + color: Colors.primaries[random.nextInt(Colors.primaries.length)], + colorBlendMode: _blendModes[random.nextInt(_blendModes.length)], + src: Assets + .patterns.values[random.nextInt(Assets.patterns.values.length)].path, + placement: random.nextBool() ? Alignment.topLeft : Alignment.bottomLeft, + ); + }, +); diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart index 8cf60120..d43e34cd 100644 --- a/lib/provider/spotify/spotify.dart +++ b/lib/provider/spotify/spotify.dart @@ -1,17 +1,17 @@ library spotify; import 'dart:async'; +import 'dart:math'; import 'package:drift/drift.dart'; +import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/database/database.dart'; -import 'package:spotube/provider/spotify/utils/json_cast.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:collection/collection.dart'; import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:hive_flutter/hive_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:intl/intl.dart'; import 'package:lrc/lrc.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -70,7 +70,6 @@ part 'views/view.dart'; part 'utils/mixin.dart'; part 'utils/state.dart'; part 'utils/provider.dart'; -part 'utils/persistence.dart'; part 'utils/async.dart'; part 'utils/provider/paginated.dart'; diff --git a/lib/provider/spotify/utils/persistence.dart b/lib/provider/spotify/utils/persistence.dart deleted file mode 100644 index 57f41dec..00000000 --- a/lib/provider/spotify/utils/persistence.dart +++ /dev/null @@ -1,40 +0,0 @@ -part of '../spotify.dart'; - -// ignore: invalid_use_of_internal_member -mixin Persistence on BuildlessAsyncNotifier { - LazyBox get store => Hive.lazyBox("spotube_cache"); - - FutureOr fromJson(Map json); - Map toJson(T data); - - FutureOr onInit() {} - - Future load() async { - final json = await store.get(runtimeType.toString()); - if (json != null || - (json is Map && json.entries.isNotEmpty) || - (json is List && json.isNotEmpty)) { - state = AsyncData( - await fromJson( - castNestedJson(json), - ), - ); - } - - await onInit(); - } - - Future save() async { - await store.put( - runtimeType.toString(), - state.value == null ? null : toJson(state.value as T), - ); - } - - @override - set state(AsyncValue value) { - if (state == value) return; - super.state = value; - save(); - } -} diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index 053f0994..75234241 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -1,14 +1,13 @@ import 'package:drift/drift.dart'; -import 'package:flutter/material.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart' as paths; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide join; import 'package:spotify/spotify.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; -import 'package:spotube/provider/audio_player/audio_player_streams.dart'; import 'package:spotube/provider/database/database.dart'; -import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/sourced_track/enums.dart'; @@ -143,11 +142,11 @@ class UserPreferencesNotifier extends Notifier { void setAlbumColorSync(bool sync) { setData(PreferencesTableCompanion(albumColorSync: Value(sync))); - if (!sync) { - ref.read(paletteProvider.notifier).state = null; - } else { - ref.read(audioPlayerStreamListenersProvider).updatePalette(); - } + // if (!sync) { + // ref.read(paletteProvider.notifier).state = null; + // } else { + // ref.read(audioPlayerStreamListenersProvider).updatePalette(); + // } } void setCheckUpdate(bool check) { @@ -208,6 +207,10 @@ class UserPreferencesNotifier extends Notifier { setData(PreferencesTableCompanion(audioSource: Value(type))); } + void setYoutubeClientEngine(YoutubeClientEngine engine) { + setData(PreferencesTableCompanion(youtubeClientEngine: Value(engine))); + } + void setSystemTitleBar(bool isSystemTitleBar) { setData( PreferencesTableCompanion( diff --git a/lib/provider/youtube_engine/youtube_engine.dart b/lib/provider/youtube_engine/youtube_engine.dart new file mode 100644 index 00000000..0aa37db5 --- /dev/null +++ b/lib/provider/youtube_engine/youtube_engine.dart @@ -0,0 +1,22 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/youtube_engine/newpipe_engine.dart'; +import 'package:spotube/services/youtube_engine/youtube_explode_engine.dart'; +import 'package:spotube/services/youtube_engine/yt_dlp_engine.dart'; + +final youtubeEngineProvider = Provider((ref) { + final engineMode = ref.watch( + userPreferencesProvider.select((value) => value.youtubeClientEngine), + ); + + if (engineMode == YoutubeClientEngine.newPipe && + NewPipeEngine.isAvailableForPlatform) { + return NewPipeEngine(); + } else if (engineMode == YoutubeClientEngine.ytDlp && + YtDlpEngine.isAvailableForPlatform) { + return YtDlpEngine(); + } else { + return YouTubeExplodeEngine(); + } +}); diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index 0b1843c4..060a7f41 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -1,7 +1,8 @@ import 'package:audio_service/audio_service.dart'; -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/env.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; @@ -27,8 +28,14 @@ class AudioServices with WidgetsBindingObserver { ? await AudioService.init( builder: () => MobileAudioService(playback), config: AudioServiceConfig( - androidNotificationChannelId: - kIsLinux ? 'spotube' : 'com.krtirtho.Spotube', + androidNotificationChannelId: switch (( + kIsLinux, + Env.releaseChannel + )) { + (true, _) => "spotube", + (_, ReleaseChannel.stable) => "com.krtirtho.Spotube", + (_, ReleaseChannel.nightly) => "com.krtirtho.Spotube.nightly", + }, androidNotificationChannelName: 'Spotube', androidNotificationOngoing: false, androidStopForegroundOnPause: false, diff --git a/lib/services/connectivity_adapter.dart b/lib/services/connectivity_adapter.dart index 86765671..f6b760c8 100644 --- a/lib/services/connectivity_adapter.dart +++ b/lib/services/connectivity_adapter.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; import 'package:flutter/widgets.dart'; import 'package:spotube/services/logger/logger.dart'; @@ -19,6 +20,7 @@ class ConnectionCheckerService with WidgetsBindingObserver { onConnectivityChanged.listen((connected) { try { if (!connected && timer == null) { + // check every 30 seconds if we are connected when we are not connected timer = Timer.periodic(const Duration(seconds: 30), (timer) async { if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.paused) { @@ -34,6 +36,10 @@ class ConnectionCheckerService with WidgetsBindingObserver { AppLogger.reportError(e, stack); } }); + + Connectivity().onConnectivityChanged.listen((event) async { + await isConnected; + }); } @override @@ -77,8 +83,9 @@ class ConnectionCheckerService with WidgetsBindingObserver { } return interfaces.any( - (interface) => - vpnNames.any((name) => interface.name.toLowerCase().contains(name)), + (interface) => vpnNames.any( + (name) => interface.name.toLowerCase().contains(name), + ), ); } @@ -105,14 +112,14 @@ class ConnectionCheckerService with WidgetsBindingObserver { await isVpnActive(); // when VPN is active that means we are connected } - bool isConnectedSync = false; + bool isConnectedSync = true; Future get isConnected async { final connected = await _isConnected(); - isConnectedSync = connected; if (connected != isConnectedSync /*previous value*/) { _connectionStreamController.add(connected); } + isConnectedSync = connected; return connected; } diff --git a/lib/services/kv_store/kv_store.dart b/lib/services/kv_store/kv_store.dart index efe83abf..e334322e 100644 --- a/lib/services/kv_store/kv_store.dart +++ b/lib/services/kv_store/kv_store.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:encrypt/encrypt.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/services/wm_tools/wm_tools.dart'; import 'package:uuid/uuid.dart'; @@ -87,4 +88,31 @@ abstract class KVStoreService { sharedPreferences.getBool('hasMigratedToDrift') ?? false; static Future setHasMigratedToDrift(bool value) async => await sharedPreferences.setBool('hasMigratedToDrift', value); + + static Map? get _youtubeEnginePaths { + final jsonRaw = sharedPreferences.getString('ytDlpPath'); + + if (jsonRaw == null) { + return null; + } + + return jsonDecode(jsonRaw); + } + + static String? getYoutubeEnginePath(YoutubeClientEngine engine) { + return _youtubeEnginePaths?[engine.name]; + } + + static Future setYoutubeEnginePath( + YoutubeClientEngine engine, + String path, + ) async { + await sharedPreferences.setString( + 'ytDlpPath', + jsonEncode({ + ...?_youtubeEnginePaths, + engine.name: path, + }), + ); + } } diff --git a/lib/services/logger/logger.dart b/lib/services/logger/logger.dart index 1df7b5aa..1f15bf92 100644 --- a/lib/services/logger/logger.dart +++ b/lib/services/logger/logger.dart @@ -3,12 +3,26 @@ import 'dart:io'; import 'dart:isolate'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide join; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:logger/logger.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:logging/logging.dart' as logging; + +final _loggingToLoggerLevel = { + logging.Level.ALL: Level.all, + logging.Level.FINEST: Level.trace, + logging.Level.FINER: Level.debug, + logging.Level.FINE: Level.info, + logging.Level.CONFIG: Level.info, + logging.Level.INFO: Level.info, + logging.Level.WARNING: Level.warning, + logging.Level.SEVERE: Level.error, + logging.Level.SHOUT: Level.fatal, + logging.Level.OFF: Level.off, +}; class AppLogger { static late final Logger log; @@ -20,6 +34,24 @@ class AppLogger { ); } + static void _initInternalPackageLoggers() { + if (!kDebugMode) return; + logging.hierarchicalLoggingEnabled = true; + logging.Logger('YoutubeExplode.StreamsClient') + ..level = logging.Level.SEVERE + ..onRecord.listen( + (record) { + log.log( + _loggingToLoggerLevel[record.level] ?? Level.info, + record.message, + error: record.error, + stackTrace: record.stackTrace, + time: record.time, + ); + }, + ); + } + static R? runZoned(R Function() body) { return runZonedGuarded( () { @@ -46,6 +78,8 @@ class AppLogger { ); } + _initInternalPackageLoggers(); + getLogsPath().then((value) => logFile = value); return body(); diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index 38f01498..bf0b22e6 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -1,15 +1,9 @@ -import 'dart:io'; - -import 'package:http/http.dart'; -import 'package:collection/collection.dart'; -import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/sourced_track/enums.dart'; -import 'package:spotube/services/sourced_track/exceptions.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart'; import 'package:spotube/services/sourced_track/models/source_map.dart'; import 'package:spotube/services/sourced_track/sources/invidious.dart'; @@ -17,7 +11,6 @@ import 'package:spotube/services/sourced_track/sources/jiosaavn.dart'; import 'package:spotube/services/sourced_track/sources/piped.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:youtube_explode_dart/youtube_explode_dart.dart'; abstract class SourcedTrack extends Track { final SourceMap source; @@ -97,11 +90,8 @@ abstract class SourcedTrack extends Track { } static String getSearchTerm(Track track) { - final artists = (track.artists ?? []) - .map((ar) => ar.name) - .toList() - .whereNotNull() - .toList(); + final artists = + (track.artists ?? []).map((ar) => ar.name).toList().nonNulls.toList(); final title = ServiceUtils.getTitle( track.name!, @@ -112,49 +102,6 @@ abstract class SourcedTrack extends Track { return "$title - ${artists.join(", ")}"; } - static fetchFromTrackAltSource({ - required Track track, - required Ref ref, - }) async { - final preferences = ref.read(userPreferencesProvider); - try { - return switch (preferences.audioSource) { - AudioSource.piped || - AudioSource.invidious || - AudioSource.jiosaavn => - await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref), - AudioSource.youtube => - await JioSaavnSourcedTrack.fetchFromTrack(track: track, ref: ref), - }; - } on TrackNotFoundError catch (_) { - return switch (preferences.audioSource) { - AudioSource.piped || - AudioSource.youtube || - AudioSource.invidious => - await JioSaavnSourcedTrack.fetchFromTrack( - track: track, - ref: ref, - weakMatch: true, - ), - AudioSource.jiosaavn => - await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref), - }; - } on HttpClientClosedException catch (_) { - return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); - } on VideoUnplayableException catch (_) { - return await InvidiousSourcedTrack.fetchFromTrack(track: track, ref: ref); - } catch (e) { - if (e is DioException || e is ClientException || e is SocketException) { - return await JioSaavnSourcedTrack.fetchFromTrack( - track: track, - ref: ref, - weakMatch: preferences.audioSource == AudioSource.jiosaavn, - ); - } - rethrow; - } - } - static Future fetchFromTrack({ required Track track, required Ref ref, @@ -162,49 +109,21 @@ abstract class SourcedTrack extends Track { final preferences = ref.read(userPreferencesProvider); try { return switch (preferences.audioSource) { - AudioSource.piped => - await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref), AudioSource.youtube => await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref), - AudioSource.jiosaavn => - await JioSaavnSourcedTrack.fetchFromTrack(track: track, ref: ref), + AudioSource.piped => + await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref), AudioSource.invidious => await InvidiousSourcedTrack.fetchFromTrack(track: track, ref: ref), - }; - } on TrackNotFoundError catch (_) { - return switch (preferences.audioSource) { - AudioSource.piped || - AudioSource.youtube || - AudioSource.invidious => - await JioSaavnSourcedTrack.fetchFromTrack( - track: track, - ref: ref, - weakMatch: true, - ), AudioSource.jiosaavn => - await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref), + await JioSaavnSourcedTrack.fetchFromTrack(track: track, ref: ref), }; - } on HttpClientClosedException catch (_) { - return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); - } on VideoUnplayableException catch (_) { - return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); } catch (e) { - if (e is DioException || e is ClientException || e is SocketException) { - return switch (preferences.audioSource) { - AudioSource.piped || - AudioSource.invidious => - await YoutubeSourcedTrack.fetchFromTrack( - track: track, - ref: ref, - ), - _ => await JioSaavnSourcedTrack.fetchFromTrack( - track: track, - ref: ref, - weakMatch: preferences.audioSource == AudioSource.jiosaavn, - ) - }; + if (preferences.audioSource == AudioSource.youtube) { + rethrow; } - rethrow; + + return await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref); } } diff --git a/lib/services/sourced_track/sources/invidious.dart b/lib/services/sourced_track/sources/invidious.dart index 2ec5068e..4a32ad41 100644 --- a/lib/services/sourced_track/sources/invidious.dart +++ b/lib/services/sourced_track/sources/invidious.dart @@ -50,6 +50,22 @@ class InvidiousSourcedTrack extends SourcedTrack { required Track track, required Ref ref, }) async { + // Indicates a stream url refresh + if (track is InvidiousSourcedTrack) { + final manifest = await ref + .read(invidiousProvider) + .videos + .get(track.sourceInfo.id, local: true); + + return InvidiousSourcedTrack( + ref: ref, + siblings: track.siblings, + source: toSourceMap(manifest), + sourceInfo: track.sourceInfo, + track: track, + ); + } + final database = ref.read(databaseProvider); final cachedSource = await (database.select(database.sourceMatchTable) ..where((s) => s.trackId.equals(track.id!)) diff --git a/lib/services/sourced_track/sources/piped.dart b/lib/services/sourced_track/sources/piped.dart index d24f110f..7ab9df44 100644 --- a/lib/services/sourced_track/sources/piped.dart +++ b/lib/services/sourced_track/sources/piped.dart @@ -50,6 +50,19 @@ class PipedSourcedTrack extends SourcedTrack { required Track track, required Ref ref, }) async { + // Means it wants a refresh of the stream + if (track is PipedSourcedTrack) { + final manifest = + await ref.read(pipedProvider).streams(track.sourceInfo.id); + return PipedSourcedTrack( + ref: ref, + siblings: track.siblings, + sourceInfo: track.sourceInfo, + source: toSourceMap(manifest), + track: track, + ); + } + final database = ref.read(databaseProvider); final cachedSource = await (database.select(database.sourceMatchTable) ..where((s) => s.trackId.equals(track.id!)) @@ -173,7 +186,7 @@ class PipedSourcedTrack extends SourcedTrack { final PipedSearchResult(items: searchResults) = await pipedClient.search( query, preference.searchMode == SearchMode.youtube - ? PipedFilter.video + ? PipedFilter.videos : PipedFilter.musicSongs, ); @@ -183,11 +196,8 @@ class PipedSourcedTrack extends SourcedTrack { : preference.searchMode == SearchMode.youtubeMusic; if (isYouTubeMusic) { - final artists = (track.artists ?? []) - .map((ar) => ar.name) - .toList() - .whereNotNull() - .toList(); + final artists = + (track.artists ?? []).map((ar) => ar.name).toList().nonNulls.toList(); return await Future.wait( searchResults diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart index 0b5ee71b..c4881051 100644 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -1,10 +1,10 @@ import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:http/http.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/provider/youtube_engine/youtube_engine.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/song_link/song_link.dart'; import 'package:spotube/services/sourced_track/enums.dart'; @@ -16,7 +16,6 @@ import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; -final youtubeClient = YoutubeExplode(); final officialMusicRegex = RegExp( r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)", caseSensitive: false, @@ -48,6 +47,25 @@ class YoutubeSourcedTrack extends SourcedTrack { required Track track, required Ref ref, }) async { + // Indicates the track is requesting a stream refresh + if (track is YoutubeSourcedTrack) { + final manifest = await ref + .read(youtubeEngineProvider) + .getStreamManifest(track.sourceInfo.id); + + final sourcedTrack = YoutubeSourcedTrack( + ref: ref, + siblings: track.siblings, + source: toSourceMap(manifest), + sourceInfo: track.sourceInfo, + track: track, + ); + + AppLogger.log.i("Refreshing ${track.name}: ${sourcedTrack.url}"); + + return sourcedTrack; + } + final database = ref.read(databaseProvider); final cachedSource = await (database.select(database.sourceMatchTable) ..where((s) => s.trackId.equals(track.id!)) @@ -81,16 +99,11 @@ class YoutubeSourcedTrack extends SourcedTrack { track: track, ); } - final item = await youtubeClient.videos.get(cachedSource.sourceId); - final manifest = await youtubeClient.videos.streamsClient - .getManifest( - cachedSource.sourceId, - ) - .timeout( - const Duration(seconds: 5), - onTimeout: () => throw ClientException("Timeout"), - ); - return YoutubeSourcedTrack( + final (item, manifest) = await ref + .read(youtubeEngineProvider) + .getVideoWithStreamInfo(cachedSource.sourceId); + + final sourcedTrack = YoutubeSourcedTrack( ref: ref, siblings: [], source: toSourceMap(manifest), @@ -106,6 +119,10 @@ class YoutubeSourcedTrack extends SourcedTrack { ), track: track, ); + + AppLogger.log.i("${track.name}: ${sourcedTrack.url}"); + + return sourcedTrack; } static SourceMap toSourceMap(StreamManifest manifest) { @@ -138,14 +155,13 @@ class YoutubeSourcedTrack extends SourcedTrack { static Future toSiblingType( int index, YoutubeVideoInfo item, + dynamic ref, ) async { + assert(ref is WidgetRef || ref is Ref, "Invalid ref type"); SourceMap? sourceMap; if (index == 0) { final manifest = - await youtubeClient.videos.streamsClient.getManifest(item.id).timeout( - const Duration(seconds: 5), - onTimeout: () => throw ClientException("Timeout"), - ); + await ref.read(youtubeEngineProvider).getStreamManifest(item.id); sourceMap = toSourceMap(manifest); } @@ -168,11 +184,8 @@ class YoutubeSourcedTrack extends SourcedTrack { static List rankResults( List results, Track track) { - final artists = (track.artists ?? []) - .map((ar) => ar.name) - .toList() - .whereNotNull() - .toList(); + final artists = + (track.artists ?? []).map((ar) => ar.name).toList().nonNulls.toList(); return results .sorted((a, b) => b.views.compareTo(a.views)) @@ -239,8 +252,11 @@ class YoutubeSourcedTrack extends SourcedTrack { await toSiblingType( 0, YoutubeVideoInfo.fromVideo( - await youtubeClient.videos.get(ytLink!.url!), + await ref.read(youtubeEngineProvider).getVideo( + Uri.parse(ytLink!.url!).queryParameters["v"]!, + ), ), + ref, ) ]; } on VideoUnplayableException catch (e, stack) { @@ -251,15 +267,13 @@ class YoutubeSourcedTrack extends SourcedTrack { final query = SourcedTrack.getSearchTerm(track); - final searchResults = await youtubeClient.search.search( - "$query - Topic", - filter: TypeFilters.video, - ); + final searchResults = + await ref.read(youtubeEngineProvider).searchVideos(query); if (ServiceUtils.onlyContainsEnglish(query)) { return await Future.wait(searchResults .map(YoutubeVideoInfo.fromVideo) - .mapIndexed(toSiblingType)); + .mapIndexed((index, info) => toSiblingType(index, info, ref))); } final rankedSiblings = rankResults( @@ -267,7 +281,10 @@ class YoutubeSourcedTrack extends SourcedTrack { track, ); - return await Future.wait(rankedSiblings.mapIndexed(toSiblingType)); + return await Future.wait( + rankedSiblings + .mapIndexed((index, info) => toSiblingType(index, info, ref)), + ); } @override @@ -285,12 +302,9 @@ class YoutubeSourcedTrack extends SourcedTrack { final newSiblings = siblings.where((s) => s.id != sibling.id).toList() ..insert(0, sourceInfo); - final manifest = await youtubeClient.videos.streamsClient - .getManifest(newSourceInfo.id) - .timeout( - const Duration(seconds: 5), - onTimeout: () => throw ClientException("Timeout"), - ); + final manifest = await ref + .read(youtubeEngineProvider) + .getStreamManifest(newSourceInfo.id); final database = ref.read(databaseProvider); diff --git a/lib/services/wm_tools/wm_tools.dart b/lib/services/wm_tools/wm_tools.dart index 920e09b5..f60b4ac9 100644 --- a/lib/services/wm_tools/wm_tools.dart +++ b/lib/services/wm_tools/wm_tools.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/utils/platform.dart'; import 'package:window_manager/window_manager.dart'; diff --git a/lib/services/youtube_engine/newpipe_engine.dart b/lib/services/youtube_engine/newpipe_engine.dart new file mode 100644 index 00000000..f58fc333 --- /dev/null +++ b/lib/services/youtube_engine/newpipe_engine.dart @@ -0,0 +1,109 @@ +import 'package:flutter_new_pipe_extractor/flutter_new_pipe_extractor.dart' + hide Engagement; +import 'package:spotube/services/youtube_engine/youtube_engine.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; +import 'package:http_parser/http_parser.dart'; + +class NewPipeEngine implements YouTubeEngine { + static bool get isAvailableForPlatform => kIsAndroid; + + AudioOnlyStreamInfo _parseAudioStream(AudioStream stream, String videoId) { + return AudioOnlyStreamInfo( + VideoId(videoId), + stream.itag, + Uri.parse(stream.content), + StreamContainer.parse(stream.mediaFormat!.mimeType.split("/").last), + FileSize.unknown, + Bitrate(stream.bitrate), + stream.codec, + stream.quality, + [], + MediaType.parse(stream.mediaFormat!.mimeType), + null, + ); + } + + Video _parseVideo(VideoInfo info) { + return Video( + VideoId(info.id), + info.name, + info.uploaderName, + ChannelId(info.uploaderUrl), + info.uploadDate.offsetDateTime, + info.uploadDate.offsetDateTime.toString(), + info.uploadDate.offsetDateTime, + info.description.content ?? "", + Duration(seconds: info.duration), + ThumbnailSet(info.id), + info.tags, + Engagement( + info.viewCount, + info.likeCount, + info.dislikeCount, + ), + !info.streamType.name.toLowerCase().contains("live"), + ); + } + + Video _parseVideoResult(VideoSearchResultItem info) { + final id = Uri.parse(info.url).queryParameters["v"]!; + return Video( + VideoId(id), + info.name, + info.uploaderName, + ChannelId(info.uploaderUrl), + info.uploadDate?.offsetDateTime, + info.uploadDate?.offsetDateTime.toString(), + info.uploadDate?.offsetDateTime, + info.shortDescription ?? "", + Duration(seconds: info.duration), + ThumbnailSet(id), + [], + Engagement(info.viewCount, null, null), + !info.streamType.name.toLowerCase().contains("live"), + ); + } + + @override + Future getStreamManifest(String videoId) async { + final video = await NewPipeExtractor.getVideoInfo(videoId); + + final streams = + video.audioStreams.map((stream) => _parseAudioStream(stream, videoId)); + + return StreamManifest(streams); + } + + @override + Future