diff --git a/.env.example b/.env.example index 920fe826..67d1be8e 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,7 @@ SPOTIFY_SECRETS= # 0 or 1 # 0 = disable # 1 = enable -ENABLE_UPDATE_CHECK= \ No newline at end of file +ENABLE_UPDATE_CHECK= + +LASTFM_API_KEY= +LASTFM_API_SECRET= diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 4b5196ce..d461e296 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -4,7 +4,7 @@ on: inputs: version: description: Version to release (x.x.x) - default: 3.1.2 + default: 3.2.0 required: true channel: type: choice @@ -87,18 +87,22 @@ jobs: make choco mv dist/spotube.*.nupkg dist/Spotube-windows-x86_64.nupkg + + - name: Upload Artifact + uses: actions/upload-artifact@v3 + with: + if-no-files-found: error + name: Spotube-Release-Binaries + path: | + dist/Spotube-windows-x86_64.nupkg + dist/Spotube-windows-x86_64-setup.exe + - name: Debug With SSH When fails if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} uses: mxschmitt/action-tmate@v3 with: limit-access-to-actor: true - - name: Upload Artifact - uses: actions/upload-artifact@v3 - with: - name: Spotube-Release-Binaries - path: dist/ - linux: runs-on: ubuntu-latest steps: @@ -177,16 +181,23 @@ jobs: mv dist/**/spotube-*-linux.rpm dist/Spotube-linux-x86_64.rpm mv dist/**/spotube-*-linux.AppImage dist/Spotube-linux-x86_64.AppImage + + - uses: actions/upload-artifact@v3 + with: + if-no-files-found: error + name: Spotube-Release-Binaries + path: | + dist/Spotube-linux-x86_64.AppImage + dist/Spotube-linux-x86_64.deb + dist/Spotube-linux-x86_64.rpm + dist/spotube-linux-${{ env.BUILD_VERSION }}-x86_64.tar.xz + - name: Debug With SSH When fails if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} uses: mxschmitt/action-tmate@v3 with: limit-access-to-actor: true - - uses: actions/upload-artifact@v3 - with: - name: Spotube-Release-Binaries - path: dist/ android: runs-on: ubuntu-latest @@ -235,10 +246,8 @@ jobs: - name: Build Apk run: | - flutter build apk - flutter build appbundle - mv build/app/outputs/apk/release/app-release.apk build/Spotube-android-all-arch.apk - mv build/app/outputs/bundle/release/app-release.aab build/Spotube-playstore-all-arch.aab + flutter build apk --flavor ${{ inputs.channel }} + mv build/app/outputs/flutter-apk/app-${{ inputs.channel }}-release.apk build/Spotube-android-all-arch.apk - name: Build Playstore AppBundle run: | @@ -247,8 +256,17 @@ jobs: export MANIFEST=android/app/src/main/AndroidManifest.xml xmlstarlet ed -d '//meta-data[@android:name="com.google.android.gms.car.application"]' $MANIFEST > $MANIFEST.tmp mv $MANIFEST.tmp $MANIFEST - flutter build appbundle - mv build/app/outputs/bundle/release/app-release.aab build/Spotube-playstore-all-arch.aab + flutter build appbundle --flavor ${{ inputs.channel }} + mv build/app/outputs/bundle/${{ inputs.channel }}Release/app-${{ inputs.channel }}-release.aab build/Spotube-playstore-all-arch.aab + + + - uses: actions/upload-artifact@v3 + with: + if-no-files-found: error + name: Spotube-Release-Binaries + path: | + build/Spotube-android-all-arch.apk + build/Spotube-playstore-all-arch.aab - name: Debug With SSH When fails if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} @@ -256,14 +274,8 @@ jobs: with: limit-access-to-actor: true - - uses: actions/upload-artifact@v3 - with: - name: Spotube-Release-Binaries - path: | - build/Spotube-android-all-arch.apk - build/Spotube-playstore-all-arch.aab - macos: + runs-on: macos-12 steps: - uses: actions/checkout@v4 @@ -311,38 +323,23 @@ jobs: mkdir -p build/${{ env.BUILD_VERSION }} appdmg appdmg.json build/Spotube-macos-universal.dmg + + - uses: actions/upload-artifact@v3 + with: + if-no-files-found: error + name: Spotube-Release-Binaries + path: | + build/Spotube-macos-universal.dmg + - name: Debug With SSH When fails if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} uses: mxschmitt/action-tmate@v3 with: limit-access-to-actor: true - - - uses: actions/upload-artifact@v3 - with: - name: Spotube-Release-Binaries - path: | - build/Spotube-macos-universal.dmg - - # linux_arm: - # runs-on: ubuntu-latest - # steps: - # - run: | - # sudo apt-get update -y - # sudo apt-get install -y curl - - # - name: Extract branch name - # shell: bash - # run: echo "BRANCH=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_ENV - - # - name: Trigger CircleCI Pipeline - # run: | - # curl -X POST https://circleci.com/api/v2/project/cci-f9azl/spotube/pipeline \ - # --header "Circle-Token: ${{secrets.CCI_TOKEN}}" \ - # --header "content-type: application/json" \ - # --data '{"branch": "${{env.BRANCH}}", "parameters":{"GHA_Action":"true","version":"${{inputs.version}}","channel":"${{inputs.channel}}","dry_run":${{inputs.dry_run}}}}' - + upload: runs-on: ubuntu-latest + needs: - windows - linux @@ -366,6 +363,7 @@ jobs: - uses: actions/upload-artifact@v3 with: + if-no-files-found: error name: Spotube-Release-Binaries path: | RELEASE.md5sum diff --git a/.vscode/launch.json b/.vscode/launch.json index 3c8209ff..9add0735 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,11 @@ "name": "spotube", "type": "dart", "request": "launch", - "program": "lib/main.dart" + "program": "lib/main.dart", + "args": [ + "--flavor", + "dev" + ] }, { "name": "spotube (profile)", diff --git a/.vscode/settings.json b/.vscode/settings.json index c12c492a..0e6a4294 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,6 +6,7 @@ "instrumentalness", "Mpris", "riverpod", + "Scrobblenaut", "speechiness", "Spotube", "winget" @@ -13,7 +14,7 @@ "editor.formatOnSave": true, "explorer.fileNesting.enabled": true, "explorer.fileNesting.patterns": { - "pubspec.yaml": "pubspec.lock,analysis_options.yaml,.packages,.flutter-plugins,.flutter-plugins-dependencies", + "pubspec.yaml": "pubspec.lock,analysis_options.yaml,.packages,.flutter-plugins,.flutter-plugins-dependencies,flutter_launcher_icons*.yaml,flutter_native_splash*.yaml", "README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md", } } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a0f3710f..3710d812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,44 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [3.2.0](https://github.com/KRTirtho/spotube/compare/v3.1.2...v3.2.0) (2023-10-16) + + +### Features + +* ability to select/copy lyrics [#802](https://github.com/KRTirtho/spotube/issues/802) ([0eb9ee8](https://github.com/KRTirtho/spotube/commit/0eb9ee8648bee43a8009e6752674b1be646c0916)) +* add Amoled theme [#724](https://github.com/KRTirtho/spotube/issues/724) ([5c5dbf6](https://github.com/KRTirtho/spotube/commit/5c5dbf69ecea95c92d3c3900ad690a500d75b4e2)) +* add audio normalization [#164](https://github.com/KRTirtho/spotube/issues/164) ([da10ab2](https://github.com/KRTirtho/spotube/commit/da10ab2e291d4ba4d3082b9a6ae535639fb8f1b7)) +* add restore default settings button ([94c3866](https://github.com/KRTirtho/spotube/commit/94c386638f2e5a42d21c8f157835443333ee6d5c)) +* configurable audio normalization switch ([c325911](https://github.com/KRTirtho/spotube/commit/c325911c0d87758a203a52df02179c1513bad3fd)) +* customizable stream/download file formats ([#757](https://github.com/KRTirtho/spotube/issues/757)) ([e54762b](https://github.com/KRTirtho/spotube/commit/e54762be6add6524ab614d103fc3557a101c75f4)) +* improve and unify the logging framework ([#738](https://github.com/KRTirtho/spotube/issues/738)) ([c7432bb](https://github.com/KRTirtho/spotube/commit/c7432bbd986d576a93957f0a22bdbca5c1e87f20)) +* LastFM scrobbling support ([#761](https://github.com/KRTirtho/spotube/issues/761)) ([f5bd907](https://github.com/KRTirtho/spotube/commit/f5bd90731d9abc19d684c8bcb231eff399e73023)) +* loading indicator for genre and personalized pages ([ffe8d9c](https://github.com/KRTirtho/spotube/commit/ffe8d9ca6da25cb3e6fd2c781d5ed3a7b919510e)) +* manual offline detection ([854ab89](https://github.com/KRTirtho/spotube/commit/854ab8910dffb2837c011d3439173a1f0ebe9c6c)) +* show error dialog on failed to login ([101c325](https://github.com/KRTirtho/spotube/commit/101c32523d3be8c05527261f6f63f939d388ad79)) +* sliding up player support ([083319f](https://github.com/KRTirtho/spotube/commit/083319fd2445ab179e3dcda0a6aeaca6f13dda29)) +* swipe to open player view ([#765](https://github.com/KRTirtho/spotube/issues/765)) ([9aee056](https://github.com/KRTirtho/spotube/commit/9aee0568bf42eed9fea8d517e960a010abf0ebf2)) +* thicken the scrollbars & make 'em interactive for mobile ([#764](https://github.com/KRTirtho/spotube/issues/764)) ([84a4bcd](https://github.com/KRTirtho/spotube/commit/84a4bcd948ab459489aaf6f39d6954776c3401d7)) +* **translations:** add Arabic Translations ([#740](https://github.com/KRTirtho/spotube/issues/740)) ([38493f9](https://github.com/KRTirtho/spotube/commit/38493f9dd75303890857a626c0b276ee1ab75bb2)) +* **translations:** add Farsi Translations ([#760](https://github.com/KRTirtho/spotube/issues/760)) ([fe42cfe](https://github.com/KRTirtho/spotube/commit/fe42cfe8430035d9b67dd158fb7b835ee4071497)) + + +### Bug Fixes + +* add libmpv1 for ubuntu-based systems ([#739](https://github.com/KRTirtho/spotube/issues/739)) ([5115e04](https://github.com/KRTirtho/spotube/commit/5115e041e78c20fce798a80f1d844bfc60746958)) +* add xdg-user-dirs as deps ([f3e331e](https://github.com/KRTirtho/spotube/commit/f3e331ecf733995da24c9b907efc5ed4bd02ffdd)) +* **android :** file_selector getDirectoryPath returns unusable content urls [#720](https://github.com/KRTirtho/spotube/issues/720) ([b3cf639](https://github.com/KRTirtho/spotube/commit/b3cf639ee2f970f4df9b394b260c3ad8a5732a9c)) +* **android:** audio doesn't resume on interruption end ([15d466a](https://github.com/KRTirtho/spotube/commit/15d466a04538ec70c3a0c132f2baaaf8690f8d4e)) +* **android:** system navigator back doesn't close player ([20d7092](https://github.com/KRTirtho/spotube/commit/20d70927c909347e84ffa8e456f8fab88d49d179)) +* get rid of overflow errors & status bar dark color ([5bb8231](https://github.com/KRTirtho/spotube/commit/5bb8231782287faf75c778fadb3a03ac774d14f0)) +* keyboard shortcuts changing route but not update sidebar ([2d93441](https://github.com/KRTirtho/spotube/commit/2d934411887bd104d8265236df5bf595c5ad2278)) +* last track repeats ([ed6ca00](https://github.com/KRTirtho/spotube/commit/ed6ca006ce237ed8d509cde9ed47cd6ea3396b63)) +* minor glitches ([e5d0aaf](https://github.com/KRTirtho/spotube/commit/e5d0aaf80d22b2291b6f7e7c5e18dd99ae1a7a82)) +* not fetching all followed artists ([#759](https://github.com/KRTirtho/spotube/issues/759)) ([c09a572](https://github.com/KRTirtho/spotube/commit/c09a5729251d8df820442d55477455f78c19c52e)) +* use audio_service_mpris plugin ([e29cc25](https://github.com/KRTirtho/spotube/commit/e29cc2578cab36729e235b117c1b5489c3452902)) +* valid non-ASCII characters get removed from downloaded file name [#745](https://github.com/KRTirtho/spotube/issues/745) ([a7e102f](https://github.com/KRTirtho/spotube/commit/a7e102ffc726d00df369560ec9a7f742f9d387bb)) + ## [3.1.2](https://github.com/KRTirtho/spotube/compare/v3.1.1...v3.1.2) (2023-09-15) diff --git a/README.md b/README.md index f1982bb6..71589794 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [auto_size_text](https://github.com/leisim/auto_size_text) - Flutter widget that automatically resizes text to fit perfectly within its bounds. 1. [buttons_tabbar](https://afonsoraposo.com) - A Flutter package that implements a TabBar where each label is a toggle button. 1. [cached_network_image](https://github.com/Baseflow/flutter_cached_network_image) - Flutter library to load and cache network images. Can also be used with placeholder and error widgets. +1. [catcher_2](https://github.com/ThexXTURBOXx/catcher_2) - Plugin for error catching which provides multiple handlers for dealing with errors when they are not caught by the developer. 1. [collection](https://pub.dev/packages/collection) - Collections and utilities functions and classes related to collections. 1. [cupertino_icons](https://pub.dev/packages/cupertino_icons) - Default icons asset for Cupertino widgets based on Apple styled icons 1. [curved_navigation_bar](https://github.com/rafalbednarczuk/curved_navigation_bar) - Stunning Animating Curved Shape Navigation Bar. Adjustable color, background color, animation curve, animation duration. @@ -215,9 +216,6 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [duration](https://github.com/desktop-dart/duration) - Utilities to make working with 'Duration's easier. Formats duration in human readable form and also parses duration in human readable form to Dart's Duration. 1. [envied](https://github.com/petercinibulk/envied) - Explicitly reads environment variables into a dart file from a .env file for more security and faster start up times. 1. [file_selector](https://pub.dev/packages/file_selector) - Flutter plugin for opening and saving files, or selecting directories, using native file selection UI. -1. [fl_query](https://fl-query.vercel.app) - Asynchronous data caching, refetching & invalidation library for Flutter -1. [fl_query_hooks](https://fl-query.vercel.app) - Elite flutter_hooks compatible library for fl_query, the Asynchronous data caching, refetching & invalidation library for Flutter -1. [fl_query_devtools](https://fl-query.vercel.app) - Devtools support for Fl-Query 1. [fluentui_system_icons](https://github.com/microsoft/fluentui-system-icons/tree/main) - Fluent UI System Icons are a collection of familiar, friendly and modern icons from Microsoft. 1. [flutter_cache_manager](https://github.com/Baseflow/flutter_cache_manager/tree/develop/flutter_cache_manager) - Generic cache manager for flutter. Saves web files on the storages of the device and saves the cache info using sqflite. 1. [flutter_displaymode](https://github.com/ajinasokan/flutter_displaymode) - A Flutter plugin to set display mode (resolution, refresh rate) on Android platform. Allows to enable high refresh rate on supported devices. @@ -238,7 +236,6 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [html](https://pub.dev/packages/html) - APIs for parsing and manipulating HTML content outside the browser. 1. [http](https://pub.dev/packages/http) - A composable, multi-platform, Future-based API for HTTP requests. 1. [image_picker](https://pub.dev/packages/image_picker) - Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. -1. [internet_connection_checker](https://github.com/RounakTadvi/internet_connection_checker/tree/main) - A pure Dart library that checks for internet by opening a socket to a list of specified addresses, each with individual port and timeout. Defaults are provided for convenience. 1. [intl](https://pub.dev/packages/intl) - Contains code to deal with internationalized/localized messages, date and number formatting and parsing, bi-directional text, and other internationalization issues. 1. [introduction_screen](https://github.com/pyozer/introduction_screen) - Introduction/Onboarding package for flutter app with some customizations possibilities 1. [json_annotation](https://pub.dev/packages/json_annotation) - Classes and helper functions that support JSON code generation via the `json_serializable` package. @@ -265,11 +262,13 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [system_theme](https://pub.dev/packages/system_theme) - A plugin to get the current system theme info. Supports Android, Web, Windows, Linux and macOS 1. [titlebar_buttons](https://github.com/gtk-flutter/titlebar_buttons) - A package which provides most of the titlebar buttons from windows, linux and macos. 1. [url_launcher](https://pub.dev/packages/url_launcher) - Flutter plugin for launching a URL. Supports web, phone, SMS, and email schemes. -1. [uuid](https://github.com/Daegalus/dart-uuid) - RFC4122 (v1, v4, v5, v6, v7, v8) UUID Generator and Parser for Dart +1. [uuid](https://pub.dev/packages/uuid) - RFC4122 (v1, v4, v5, v6, v7, v8) UUID Generator and Parser for Dart 1. [version](https://github.com/dartninja/version) - Provides a simple class for parsing and comparing semantic versions as defined by http://semver.org/ 1. [visibility_detector](https://pub.dev/packages/visibility_detector) - A widget that detects the visibility of its child and notifies a callback. 1. [window_manager](https://github.com/leanflutter/window_manager) - This plugin allows Flutter desktop apps to resizing and repositioning the window. 1. [youtube_explode_dart](https://github.com/Hexer10/youtube_explode_dart) - A port in dart of the youtube explode library. Supports several API functions without the need of Youtube API Key. +1. [simple_icons](https://jlnrrg.github.io/) - The Simple Icon pack available as Flutter Icons. Provides over 1500 Free SVG icons for popular brands. +1. [audio_service_mpris](https://github.com/bdrazhzhov/audio-service-mpris) - audio_service platform interface supporting Media Player Remote Interfacing Specification. 1. [build_runner](https://pub.dev/packages/build_runner) - A build system for Dart code generation and modular compilation. 1. [envied_generator](https://github.com/petercinibulk/envied) - Generator for the Envied package. See https://pub.dev/packages/envied. 1. [flutter_distributor](https://distributor.leanflutter.org) - A complete tool for packaging and publishing your Flutter apps. @@ -280,8 +279,11 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [json_serializable](https://pub.dev/packages/json_serializable) - Automatically generate code for converting to and from JSON by annotating Dart classes. 1. [pub_api_client](https://github.com/leoafarias/pub_api_client) - An API Client for Pub to interact with public package information. 1. [pubspec_parse](https://pub.dev/packages/pubspec_parse) - Simple package for parsing pubspec.yaml files with a type-safe API and rich error reporting. -1. [catcher](https://github.com/jhomlala/catcher) - Plugin for error catching which provides multiple handlers for dealing with errors when they are not caught by the developer. +1. [fl_query](https://fl-query.vercel.app) - Asynchronous data caching, refetching & invalidation library for Flutter +1. [fl_query_hooks](https://fl-query.vercel.app) - Elite flutter_hooks compatible library for fl_query, the Asynchronous data caching, refetching & invalidation library for Flutter +1. [fl_query_devtools](https://fl-query.vercel.app) - Devtools support for Fl-Query 1. [flutter_desktop_tools](https://github.com/KRTirtho/flutter_desktop_tools) - Essential collection of tools for flutter desktop app development +1. [scrobblenaut](https://github.com/Nebulino/Scrobblenaut) - A deadly simple LastFM API Wrapper for Dart. So deadly simple that it's gonna hit the mark. 1. [window_size](https://github.com/google/flutter-desktop-embedding.git) - Allows resizing and repositioning the window containing Flutter. diff --git a/android/app/build.gradle b/android/app/build.gradle index d05a90a1..cd6bc457 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -72,6 +72,28 @@ android { signingConfig signingConfigs.release } } + + flavorDimensions "default" + + productFlavors { + nightly { + dimension "default" + resValue "string", "app_name", "Spotube Nightly" + applicationIdSuffix ".nightly" + versionNameSuffix "-nightly" + } + dev { + dimension "default" + resValue "string", "app_name", "Spotube Dev" + applicationIdSuffix ".dev" + versionNameSuffix "-dev" + } + stable { + dimension "default" + resValue "string", "app_name", "Spotube" + } + } + } flutter { @@ -92,4 +114,4 @@ dependencies { // other deps so just ignore implementation 'com.android.support:multidex:2.0.1' -} +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7891310d..bfb51226 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -18,7 +18,7 @@ + + + + + + + + + + + diff --git a/android/app/src/nightly/res/drawable-xhdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-xhdpi-v31/android12branding.png new file mode 100644 index 00000000..0bcf138d Binary files /dev/null and b/android/app/src/nightly/res/drawable-xhdpi-v31/android12branding.png differ diff --git a/android/app/src/nightly/res/drawable-xhdpi/android12splash.png b/android/app/src/nightly/res/drawable-xhdpi/android12splash.png new file mode 100644 index 00000000..ad3f39d0 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xhdpi/android12splash.png differ diff --git a/android/app/src/nightly/res/drawable-xhdpi/branding.png b/android/app/src/nightly/res/drawable-xhdpi/branding.png new file mode 100644 index 00000000..0bcf138d Binary files /dev/null and b/android/app/src/nightly/res/drawable-xhdpi/branding.png differ diff --git a/android/app/src/nightly/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/nightly/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..4cf86d25 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/nightly/res/drawable-xhdpi/splash.png b/android/app/src/nightly/res/drawable-xhdpi/splash.png new file mode 100644 index 00000000..dbb0ea02 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xhdpi/splash.png differ diff --git a/android/app/src/nightly/res/drawable-xxhdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-xxhdpi-v31/android12branding.png new file mode 100644 index 00000000..c7d01776 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxhdpi-v31/android12branding.png differ diff --git a/android/app/src/nightly/res/drawable-xxhdpi/android12splash.png b/android/app/src/nightly/res/drawable-xxhdpi/android12splash.png new file mode 100644 index 00000000..133fb647 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxhdpi/android12splash.png differ diff --git a/android/app/src/nightly/res/drawable-xxhdpi/branding.png b/android/app/src/nightly/res/drawable-xxhdpi/branding.png new file mode 100644 index 00000000..c7d01776 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxhdpi/branding.png differ diff --git a/android/app/src/nightly/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/nightly/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..95fa3443 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/nightly/res/drawable-xxhdpi/splash.png b/android/app/src/nightly/res/drawable-xxhdpi/splash.png new file mode 100644 index 00000000..12eb5531 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxhdpi/splash.png differ diff --git a/android/app/src/nightly/res/drawable-xxxhdpi-v31/android12branding.png b/android/app/src/nightly/res/drawable-xxxhdpi-v31/android12branding.png new file mode 100644 index 00000000..5477b799 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxxhdpi-v31/android12branding.png differ diff --git a/android/app/src/nightly/res/drawable-xxxhdpi/android12splash.png b/android/app/src/nightly/res/drawable-xxxhdpi/android12splash.png new file mode 100644 index 00000000..fa5a8c92 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxxhdpi/android12splash.png differ diff --git a/android/app/src/nightly/res/drawable-xxxhdpi/branding.png b/android/app/src/nightly/res/drawable-xxxhdpi/branding.png new file mode 100644 index 00000000..5477b799 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxxhdpi/branding.png differ diff --git a/android/app/src/nightly/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/nightly/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..3de8a2ee Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/nightly/res/drawable-xxxhdpi/splash.png b/android/app/src/nightly/res/drawable-xxxhdpi/splash.png new file mode 100644 index 00000000..68e806f4 Binary files /dev/null and b/android/app/src/nightly/res/drawable-xxxhdpi/splash.png differ diff --git a/android/app/src/nightly/res/drawable/background.png b/android/app/src/nightly/res/drawable/background.png new file mode 100644 index 00000000..203fc77a Binary files /dev/null and b/android/app/src/nightly/res/drawable/background.png differ diff --git a/android/app/src/nightly/res/drawable/launch_background.xml b/android/app/src/nightly/res/drawable/launch_background.xml new file mode 100644 index 00000000..52e8749e --- /dev/null +++ b/android/app/src/nightly/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/android/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..5f349f7f --- /dev/null +++ b/android/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/nightly/res/mipmap-hdpi/ic_launcher.png b/android/app/src/nightly/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..a826bb73 Binary files /dev/null and b/android/app/src/nightly/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/nightly/res/mipmap-mdpi/ic_launcher.png b/android/app/src/nightly/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..3743861d Binary files /dev/null and b/android/app/src/nightly/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/nightly/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/nightly/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..1be1daa7 Binary files /dev/null and b/android/app/src/nightly/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/nightly/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/nightly/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..3a8a7832 Binary files /dev/null and b/android/app/src/nightly/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..781c9c1a Binary files /dev/null and b/android/app/src/nightly/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/nightly/res/values-night-v31/styles.xml b/android/app/src/nightly/res/values-night-v31/styles.xml new file mode 100644 index 00000000..96980835 --- /dev/null +++ b/android/app/src/nightly/res/values-night-v31/styles.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/android/app/src/nightly/res/values-night/styles.xml b/android/app/src/nightly/res/values-night/styles.xml new file mode 100644 index 00000000..dbc9ea9f --- /dev/null +++ b/android/app/src/nightly/res/values-night/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/nightly/res/values-v31/styles.xml b/android/app/src/nightly/res/values-v31/styles.xml new file mode 100644 index 00000000..981a07a9 --- /dev/null +++ b/android/app/src/nightly/res/values-v31/styles.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/android/app/src/nightly/res/values/colors.xml b/android/app/src/nightly/res/values/colors.xml new file mode 100644 index 00000000..88247a21 --- /dev/null +++ b/android/app/src/nightly/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #242832 + \ No newline at end of file diff --git a/android/app/src/nightly/res/values/styles.xml b/android/app/src/nightly/res/values/styles.xml new file mode 100644 index 00000000..0d1fa8fc --- /dev/null +++ b/android/app/src/nightly/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/assets/spotube-hero-banner.png b/assets/spotube-hero-banner.png new file mode 100644 index 00000000..c5309b92 Binary files /dev/null and b/assets/spotube-hero-banner.png differ diff --git a/assets/spotube-nightly-logo-foreground.jpg b/assets/spotube-nightly-logo-foreground.jpg new file mode 100644 index 00000000..a0c849b6 Binary files /dev/null and b/assets/spotube-nightly-logo-foreground.jpg differ diff --git a/assets/spotube-nightly-logo.png b/assets/spotube-nightly-logo.png new file mode 100644 index 00000000..ea7a8b20 Binary files /dev/null and b/assets/spotube-nightly-logo.png differ diff --git a/assets/spotube-nightly-logo.svg b/assets/spotube-nightly-logo.svg new file mode 100644 index 00000000..7601108e --- /dev/null +++ b/assets/spotube-nightly-logo.svg @@ -0,0 +1,359 @@ + + diff --git a/assets/spotube-nightly-logo_android12.png b/assets/spotube-nightly-logo_android12.png new file mode 100644 index 00000000..1a5bf4f1 Binary files /dev/null and b/assets/spotube-nightly-logo_android12.png differ diff --git a/assets/spotube-tall-capsule.png b/assets/spotube-tall-capsule.png new file mode 100644 index 00000000..43fb8229 Binary files /dev/null and b/assets/spotube-tall-capsule.png differ diff --git a/assets/spotube-wide-capsule-large.png b/assets/spotube-wide-capsule-large.png new file mode 100644 index 00000000..09a93d83 Binary files /dev/null and b/assets/spotube-wide-capsule-large.png differ diff --git a/assets/spotube-wide-capsule-small.png b/assets/spotube-wide-capsule-small.png new file mode 100644 index 00000000..17566550 Binary files /dev/null and b/assets/spotube-wide-capsule-small.png differ diff --git a/aur-struct/.SRCINFO b/aur-struct/.SRCINFO index 4f50a951..ae0b6d10 100644 --- a/aur-struct/.SRCINFO +++ b/aur-struct/.SRCINFO @@ -10,6 +10,7 @@ pkgbase = spotube-bin depends = libsecret depends = jsoncpp depends = libnotify + depends = xdg-user-dirs source = https://github.com/KRTirtho/spotube/releases/download/v2.3.0/Spotube-linux-x86_64.tar.xz md5sums = 8cd6a7385c5c75d203dccd762f1d63ec diff --git a/aur-struct/PKGBUILD b/aur-struct/PKGBUILD index 313cd308..4663c3ab 100644 --- a/aur-struct/PKGBUILD +++ b/aur-struct/PKGBUILD @@ -8,7 +8,7 @@ arch=(x86_64) url="https://github.com/KRTirtho/spotube/" license=('BSD-4-Clause') groups=() -depends=('mpv' 'libappindicator-gtk3' 'libsecret' 'jsoncpp' 'libnotify') +depends=('mpv' 'libappindicator-gtk3' 'libsecret' 'jsoncpp' 'libnotify' 'xdg-user-dirs') makedepends=() checkdepends=() optdepends=() diff --git a/bin/gen-credits.dart b/bin/gen-credits.dart index 6f3e1b51..43e1e53d 100644 --- a/bin/gen-credits.dart +++ b/bin/gen-credits.dart @@ -68,6 +68,7 @@ void main() async { ), ); + // ignore: avoid_print print( packageInfo .map( @@ -76,6 +77,7 @@ void main() async { ) .join('\n'), ); + // ignore: avoid_print print( gitPubspecs.map( (package) { diff --git a/bin/untranslated_messages.dart b/bin/untranslated_messages.dart index 428314aa..172f218f 100644 --- a/bin/untranslated_messages.dart +++ b/bin/untranslated_messages.dart @@ -35,6 +35,7 @@ void main(List args) { ); } + // ignore: avoid_print print( const JsonEncoder.withIndent(' ').convert( args.isNotEmpty ? messagesWithValues[args.first] : messagesWithValues, diff --git a/flutter_launcher_icons-nightly.yaml b/flutter_launcher_icons-nightly.yaml new file mode 100644 index 00000000..e531efd4 --- /dev/null +++ b/flutter_launcher_icons-nightly.yaml @@ -0,0 +1,5 @@ +flutter_launcher_icons: + android: true + image_path: "assets/spotube-nightly-logo.png" + adaptive_icon_foreground: "assets/spotube-nightly-logo-foreground.jpg" + adaptive_icon_background: "#242832" diff --git a/flutter_native_splash-nightly.yaml b/flutter_native_splash-nightly.yaml new file mode 100644 index 00000000..37da37d9 --- /dev/null +++ b/flutter_native_splash-nightly.yaml @@ -0,0 +1,9 @@ +flutter_native_splash: + background_image: assets/bengali-patterns-bg.jpg + image: assets/spotube-nightly-logo.png + branding: assets/branding.png + android_12: + image: assets/spotube-nightly-logo_android12.png + branding: assets/branding.png + color: "#000000" + icon_background_color: "#000000" diff --git a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage.png b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage.png new file mode 100644 index 00000000..80579983 Binary files /dev/null and b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage.png differ diff --git a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@2x.png b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@2x.png new file mode 100644 index 00000000..0bcf138d Binary files /dev/null and b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@3x.png b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@3x.png new file mode 100644 index 00000000..c7d01776 Binary files /dev/null and b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/BrandingImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/Contents.json b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/Contents.json new file mode 100644 index 00000000..12712275 --- /dev/null +++ b/ios/Runner/Assets.xcassets/BrandingImageNightly.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "BrandingImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "BrandingImage@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "BrandingImage@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/Contents.json new file mode 100644 index 00000000..9f447e1b --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "background.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/background.png b/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/background.png new file mode 100644 index 00000000..203fc77a Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchBackgroundNightly.imageset/background.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/Contents.json new file mode 100644 index 00000000..00cabce8 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "LaunchImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "LaunchImage@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "LaunchImage@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage.png new file mode 100644 index 00000000..86d3fe74 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@2x.png new file mode 100644 index 00000000..dbb0ea02 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@3x.png new file mode 100644 index 00000000..12eb5531 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImageNightly.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Base.lproj/LaunchScreenNightly.storyboard b/ios/Runner/Base.lproj/LaunchScreenNightly.storyboard new file mode 100644 index 00000000..6869214f --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreenNightly.storyboard @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 1f0a5e62..d8b46b96 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,64 +1,64 @@ - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Sptube - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - spotube - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsArbitraryLoadsForMedia - - - CADisableMinimumFrameDurationOnPhone - - UIStatusBarHidden - - NSPhotoLibraryUsageDescription - This app require access to the photo library - NSCameraUsageDescription - This app require access to the device camera - NSMicrophoneUsageDescription - This app does not require access to the device microphone - - \ No newline at end of file + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Sptube + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + spotube + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsForMedia + + + CADisableMinimumFrameDurationOnPhone + + UIStatusBarHidden + + NSPhotoLibraryUsageDescription + This app require access to the photo library + NSCameraUsageDescription + This app require access to the device camera + NSMicrophoneUsageDescription + This app does not require access to the device microphone + + diff --git a/lib/collections/assets.gen.dart b/lib/collections/assets.gen.dart index fa7cfee7..ac39cf68 100644 --- a/lib/collections/assets.gen.dart +++ b/lib/collections/assets.gen.dart @@ -36,6 +36,8 @@ class Assets { static const AssetGenImage emptyBox = AssetGenImage('assets/empty_box.png'); static const AssetGenImage placeholder = AssetGenImage('assets/placeholder.png'); + static const AssetGenImage spotubeHeroBanner = + AssetGenImage('assets/spotube-hero-banner.png'); static const AssetGenImage spotubeLogoForeground = AssetGenImage('assets/spotube-logo-foreground.jpg'); static const String spotubeLogoIco = 'assets/spotube-logo.ico'; @@ -44,8 +46,21 @@ class Assets { static const String spotubeLogoSvg = 'assets/spotube-logo.svg'; static const AssetGenImage spotubeLogoAndroid12 = AssetGenImage('assets/spotube-logo_android12.png'); + static const AssetGenImage spotubeNightlyLogoForeground = + AssetGenImage('assets/spotube-nightly-logo-foreground.jpg'); + static const AssetGenImage spotubeNightlyLogoPng = + AssetGenImage('assets/spotube-nightly-logo.png'); + static const String spotubeNightlyLogoSvg = 'assets/spotube-nightly-logo.svg'; + static const AssetGenImage spotubeNightlyLogoAndroid12 = + AssetGenImage('assets/spotube-nightly-logo_android12.png'); static const AssetGenImage spotubeScreenshot = AssetGenImage('assets/spotube-screenshot.png'); + static const AssetGenImage spotubeTallCapsule = + AssetGenImage('assets/spotube-tall-capsule.png'); + static const AssetGenImage spotubeWideCapsuleLarge = + AssetGenImage('assets/spotube-wide-capsule-large.png'); + static const AssetGenImage spotubeWideCapsuleSmall = + AssetGenImage('assets/spotube-wide-capsule-small.png'); static const AssetGenImage spotubeBanner = AssetGenImage('assets/spotube_banner.png'); static const AssetGenImage success = AssetGenImage('assets/success.png'); @@ -60,12 +75,20 @@ class Assets { branding, emptyBox, placeholder, + spotubeHeroBanner, spotubeLogoForeground, spotubeLogoIco, spotubeLogoPng, spotubeLogoSvg, spotubeLogoAndroid12, + spotubeNightlyLogoForeground, + spotubeNightlyLogoPng, + spotubeNightlyLogoSvg, + spotubeNightlyLogoAndroid12, spotubeScreenshot, + spotubeTallCapsule, + spotubeWideCapsuleLarge, + spotubeWideCapsuleSmall, spotubeBanner, success, userPlaceholder diff --git a/lib/collections/env.dart b/lib/collections/env.dart index a6e1efe3..1b9de3de 100644 --- a/lib/collections/env.dart +++ b/lib/collections/env.dart @@ -13,6 +13,12 @@ abstract class Env { @EnviedField(varName: 'SPOTIFY_SECRETS') static final String rawSpotifySecrets = _Env.rawSpotifySecrets; + @EnviedField(varName: 'LASTFM_API_KEY') + static final String lastFmApiKey = _Env.lastFmApiKey; + + @EnviedField(varName: 'LASTFM_API_SECRET') + static final String lastFmApiSecret = _Env.lastFmApiSecret; + static final spotifySecrets = rawSpotifySecrets.split(',').map((e) { final secrets = e.trim().split(":").map((e) => e.trim()); return { diff --git a/lib/collections/intents.dart b/lib/collections/intents.dart index 4b5deac0..8c7ea73b 100644 --- a/lib/collections/intents.dart +++ b/lib/collections/intents.dart @@ -1,5 +1,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:spotube/components/player/player_controls.dart'; @@ -8,7 +9,6 @@ import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:window_manager/window_manager.dart'; class PlayPauseIntent extends Intent { final WidgetRef ref; @@ -115,7 +115,7 @@ class CloseAppAction extends Action { @override invoke(intent) { if (kIsDesktop) { - windowManager.close(); + DesktopTools.window.close(); } else { SystemNavigator.pop(); } diff --git a/lib/collections/language_codes.dart b/lib/collections/language_codes.dart index a4552d52..2a742f46 100644 --- a/lib/collections/language_codes.dart +++ b/lib/collections/language_codes.dart @@ -36,10 +36,10 @@ abstract class LanguageLocals { // name: "Amharic", // nativeName: "አማርኛ", // ), - // "ar": const ISOLanguageName( - // name: "Arabic", - // nativeName: "العربية", - // ), + "ar": const ISOLanguageName( + name: "Arabic", + nativeName: "العربية", + ), // "an": const ISOLanguageName( // name: "Aragonese", // nativeName: "Aragonés", @@ -508,10 +508,10 @@ abstract class LanguageLocals { // name: "Pāli", // nativeName: "पाऴि", // ), - // "fa": const ISOLanguageName( - // name: "Persian", - // nativeName: "فارسی", - // ), + "fa": const ISOLanguageName( + name: "Persian", + nativeName: "فارسی", + ), "pl": const ISOLanguageName( name: "Polish", nativeName: "polski", @@ -684,10 +684,10 @@ abstract class LanguageLocals { // name: "Uighur, Uyghur", // nativeName: "Uyƣurqə, ئۇيغۇرچە‎", // ), - // "uk": const ISOLanguageName( - // name: "Ukrainian", - // nativeName: "українська", - // ), + "uk": const ISOLanguageName( + name: "Ukrainian", + nativeName: "українська", + ), // "ur": const ISOLanguageName( // name: "Urdu", // nativeName: "اردو", diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index faa63da8..81ebb3e6 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -1,9 +1,10 @@ -import 'package:catcher/catcher.dart'; +import 'package:catcher_2/catcher_2.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:spotify/spotify.dart' hide Search; import 'package:spotube/pages/home/home.dart'; +import 'package:spotube/pages/lastfm_login/lastfm_login.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; import 'package:spotube/pages/lyrics/mini_lyrics.dart'; import 'package:spotube/pages/search/search.dart'; @@ -18,7 +19,6 @@ import 'package:spotube/pages/library/library.dart'; import 'package:spotube/pages/desktop_login/login_tutorial.dart'; import 'package:spotube/pages/desktop_login/desktop_login.dart'; import 'package:spotube/pages/lyrics/lyrics.dart'; -import 'package:spotube/pages/player/player.dart'; import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/pages/root/root_app.dart'; import 'package:spotube/pages/settings/settings.dart'; @@ -26,7 +26,7 @@ import 'package:spotube/pages/mobile_login/mobile_login.dart'; import '../pages/library/playlist_generate/playlist_generate_result.dart'; -final rootNavigatorKey = Catcher.navigatorKey; +final rootNavigatorKey = Catcher2.navigatorKey; final shellRouteNavigatorKey = GlobalKey(); final router = GoRouter( navigatorKey: rootNavigatorKey, @@ -147,13 +147,10 @@ final router = GoRouter( ), ), GoRoute( - path: "/player", + path: "/lastfm-login", parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) { - return const SpotubePage( - child: PlayerView(), - ); - }, + pageBuilder: (context, state) => + const SpotubePage(child: LastFMLoginPage()), ), ], ); diff --git a/lib/collections/spotify_markets.dart b/lib/collections/spotify_markets.dart index a24fd768..514b3f0b 100644 --- a/lib/collections/spotify_markets.dart +++ b/lib/collections/spotify_markets.dart @@ -1,187 +1,189 @@ // Country Codes contributed by momobobe +import 'package:spotify/spotify.dart'; + final spotifyMarkets = [ - ("AL", "Albania (AL)"), - ("DZ", "Algeria (DZ)"), - ("AD", "Andorra (AD)"), - ("AO", "Angola (AO)"), - ("AG", "Antigua and Barbuda (AG)"), - ("AR", "Argentina (AR)"), - ("AM", "Armenia (AM)"), - ("AU", "Australia (AU)"), - ("AT", "Austria (AT)"), - ("AZ", "Azerbaijan (AZ)"), - ("BH", "Bahrain (BH)"), - ("BD", "Bangladesh (BD)"), - ("BB", "Barbados (BB)"), - ("BY", "Belarus (BY)"), - ("BE", "Belgium (BE)"), - ("BZ", "Belize (BZ)"), - ("BJ", "Benin (BJ)"), - ("BT", "Bhutan (BT)"), - ("BO", "Bolivia (BO)"), - ("BA", "Bosnia and Herzegovina (BA)"), - ("BW", "Botswana (BW)"), - ("BR", "Brazil (BR)"), - ("BN", "Brunei Darussalam (BN)"), - ("BG", "Bulgaria (BG)"), - ("BF", "Burkina Faso (BF)"), - ("BI", "Burundi (BI)"), - ("CV", "Cabo Verde / Cape Verde (CV)"), - ("KH", "Cambodia (KH)"), - ("CM", "Cameroon (CM)"), - ("CA", "Canada (CA)"), - ("TD", "Chad (TD)"), - ("CL", "Chile (CL)"), - ("CO", "Colombia (CO)"), - ("KM", "Comoros (KM)"), - ("CR", "Costa Rica (CR)"), - ("HR", "Croatia (HR)"), - ("CW", "Curaçao (CW)"), - ("CY", "Cyprus (CY)"), - ("CZ", "Czech Republic (CZ)"), - ("CI", "Ivory Coast (CI)"), - ("CD", "Congo (CD)"), - ("DK", "Denmark (DK)"), - ("DJ", "Djibouti (DJ)"), - ("DM", "Dominica (DM)"), - ("DO", "Dominican Republic (DO)"), - ("EC", "Ecuador (EC)"), - ("EG", "Egypt (EG)"), - ("SV", "El Salvador (SV)"), - ("GQ", "Equatorial Guinea (GQ)"), - ("EE", "Estonia (EE)"), - ("SZ", "Eswatini (SZ)"), - ("FJ", "Fiji (FJ)"), - ("FI", "Finland (FI)"), - ("FR", "France (FR)"), - ("GA", "Gabon (GA)"), - ("GE", "Georgia (GE)"), - ("DE", "Germany (DE)"), - ("GH", "Ghana (GH)"), - ("GR", "Greece (GR)"), - ("GD", "Grenada (GD)"), - ("GT", "Guatemala (GT)"), - ("GN", "Guinea (GN)"), - ("GW", "Guinea-Bissau (GW)"), - ("GY", "Guyana (GY)"), - ("HT", "Haiti (HT)"), - ("HN", "Honduras (HN)"), - ("HK", "Hong Kong (HK)"), - ("HU", "Hungary (HU)"), - ("IS", "Iceland (IS)"), - ("IN", "India (IN)"), - ("ID", "Indonesia (ID)"), - ("IQ", "Iraq (IQ)"), - ("IE", "Ireland (IE)"), - ("IL", "Israel (IL)"), - ("IT", "Italy (IT)"), - ("JM", "Jamaica (JM)"), - ("JP", "Japan (JP)"), - ("JO", "Jordan (JO)"), - ("KZ", "Kazakhstan (KZ)"), - ("KE", "Kenya (KE)"), - ("KI", "Kiribati (KI)"), - ("XK", "Kosovo (XK)"), - ("KW", "Kuwait (KW)"), - ("KG", "Kyrgyzstan (KG)"), - ("LA", "Laos (LA)"), - ("LV", "Latvia (LV)"), - ("LB", "Lebanon (LB)"), - ("LS", "Lesotho (LS)"), - ("LR", "Liberia (LR)"), - ("LY", "Libya (LY)"), - ("LI", "Liechtenstein (LI)"), - ("LT", "Lithuania (LT)"), - ("LU", "Luxembourg (LU)"), - ("MO", "Macao / Macau (MO)"), - ("MG", "Madagascar (MG)"), - ("MW", "Malawi (MW)"), - ("MY", "Malaysia (MY)"), - ("MV", "Maldives (MV)"), - ("ML", "Mali (ML)"), - ("MT", "Malta (MT)"), - ("MH", "Marshall Islands (MH)"), - ("MR", "Mauritania (MR)"), - ("MU", "Mauritius (MU)"), - ("MX", "Mexico (MX)"), - ("FM", "Micronesia (FM)"), - ("MD", "Moldova (MD)"), - ("MC", "Monaco (MC)"), - ("MN", "Mongolia (MN)"), - ("ME", "Montenegro (ME)"), - ("MA", "Morocco (MA)"), - ("MZ", "Mozambique (MZ)"), - ("NA", "Namibia (NA)"), - ("NR", "Nauru (NR)"), - ("NP", "Nepal (NP)"), - ("NL", "Netherlands (NL)"), - ("NZ", "New Zealand (NZ)"), - ("NI", "Nicaragua (NI)"), - ("NE", "Niger (NE)"), - ("NG", "Nigeria (NG)"), - ("MK", "North Macedonia (MK)"), - ("NO", "Norway (NO)"), - ("OM", "Oman (OM)"), - ("PK", "Pakistan (PK)"), - ("PW", "Palau (PW)"), - ("PS", "Palestine (PS)"), - ("PA", "Panama (PA)"), - ("PG", "Papua New Guinea (PG)"), - ("PY", "Paraguay (PY)"), - ("PE", "Peru (PE)"), - ("PH", "Philippines (PH)"), - ("PL", "Poland (PL)"), - ("PT", "Portugal (PT)"), - ("QA", "Qatar (QA)"), - ("CG", "Congo (CG)"), - ("RO", "Romania (RO)"), - ("RU", "Russia (RU)"), - ("RW", "Rwanda (RW)"), - ("WS", "Samoa (WS)"), - ("SM", "San Marino (SM)"), - ("SA", "Saudi Arabia (SA)"), - ("SN", "Senegal (SN)"), - ("RS", "Serbia (RS)"), - ("SC", "Seychelles (SC)"), - ("SL", "Sierra Leone (SL)"), - ("SG", "Singapore (SG)"), - ("SK", "Slovakia (SK)"), - ("SI", "Slovenia (SI)"), - ("SB", "Solomon Islands (SB)"), - ("ZA", "South Africa (ZA)"), - ("KR", "South Korea (KR)"), - ("ES", "Spain (ES)"), - ("LK", "Sri Lanka (LK)"), - ("KN", "St. Kitts and Nevis (KN)"), - ("LC", "St. Lucia (LC)"), - ("SR", "Suriname (SR)"), - ("SE", "Sweden (SE)"), - ("CH", "Switzerland (CH)"), - ("ST", "São Tomé and Príncipe (ST)"), - ("TW", "Taiwan (TW)"), - ("TJ", "Tajikistan (TJ)"), - ("TZ", "Tanzania (TZ)"), - ("TH", "Thailand (TH)"), - ("BS", "The Bahamas (BS)"), - ("GM", "The Gambia (GM)"), - ("TL", "East Timor (TL)"), - ("TG", "Togo (TG)"), - ("TO", "Tonga (TO)"), - ("TT", "Trinidad and Tobago (TT)"), - ("TN", "Tunisia (TN)"), - ("TR", "Turkey (TR)"), - ("TV", "Tuvalu (TV)"), - ("UG", "Uganda (UG)"), - ("UA", "Ukraine (UA)"), - ("AE", "United Arab Emirates (AE)"), - ("GB", "United Kingdom (GB)"), - ("US", "United States (US)"), - ("UY", "Uruguay (UY)"), - ("UZ", "Uzbekistan (UZ)"), - ("VU", "Vanuatu (VU)"), - ("VE", "Venezuela (VE)"), - ("VN", "Vietnam (VN)"), - ("ZM", "Zambia (ZM)"), - ("ZW", "Zimbabwe (ZW)"), + (Market.AL, "Albania (AL)"), + (Market.DZ, "Algeria (DZ)"), + (Market.AD, "Andorra (AD)"), + (Market.AO, "Angola (AO)"), + (Market.AG, "Antigua and Barbuda (AG)"), + (Market.AR, "Argentina (AR)"), + (Market.AM, "Armenia (AM)"), + (Market.AU, "Australia (AU)"), + (Market.AT, "Austria (AT)"), + (Market.AZ, "Azerbaijan (AZ)"), + (Market.BH, "Bahrain (BH)"), + (Market.BD, "Bangladesh (BD)"), + (Market.BB, "Barbados (BB)"), + (Market.BY, "Belarus (BY)"), + (Market.BE, "Belgium (BE)"), + (Market.BZ, "Belize (BZ)"), + (Market.BJ, "Benin (BJ)"), + (Market.BT, "Bhutan (BT)"), + (Market.BO, "Bolivia (BO)"), + (Market.BA, "Bosnia and Herzegovina (BA)"), + (Market.BW, "Botswana (BW)"), + (Market.BR, "Brazil (BR)"), + (Market.BN, "Brunei Darussalam (BN)"), + (Market.BG, "Bulgaria (BG)"), + (Market.BF, "Burkina Faso (BF)"), + (Market.BI, "Burundi (BI)"), + (Market.CV, "Cabo Verde / Cape Verde (CV)"), + (Market.KH, "Cambodia (KH)"), + (Market.CM, "Cameroon (CM)"), + (Market.CA, "Canada (CA)"), + (Market.TD, "Chad (TD)"), + (Market.CL, "Chile (CL)"), + (Market.CO, "Colombia (CO)"), + (Market.KM, "Comoros (KM)"), + (Market.CR, "Costa Rica (CR)"), + (Market.HR, "Croatia (HR)"), + (Market.CW, "Curaçao (CW)"), + (Market.CY, "Cyprus (CY)"), + (Market.CZ, "Czech Republic (CZ)"), + (Market.CI, "Ivory Coast (CI)"), + (Market.CD, "Congo (CD)"), + (Market.DK, "Denmark (DK)"), + (Market.DJ, "Djibouti (DJ)"), + (Market.DM, "Dominica (DM)"), + (Market.DO, "Dominican Republic (DO)"), + (Market.EC, "Ecuador (EC)"), + (Market.EG, "Egypt (EG)"), + (Market.SV, "El Salvador (SV)"), + (Market.GQ, "Equatorial Guinea (GQ)"), + (Market.EE, "Estonia (EE)"), + (Market.SZ, "Eswatini (SZ)"), + (Market.FJ, "Fiji (FJ)"), + (Market.FI, "Finland (FI)"), + (Market.FR, "France (FR)"), + (Market.GA, "Gabon (GA)"), + (Market.GE, "Georgia (GE)"), + (Market.DE, "Germany (DE)"), + (Market.GH, "Ghana (GH)"), + (Market.GR, "Greece (GR)"), + (Market.GD, "Grenada (GD)"), + (Market.GT, "Guatemala (GT)"), + (Market.GN, "Guinea (GN)"), + (Market.GW, "Guinea-Bissau (GW)"), + (Market.GY, "Guyana (GY)"), + (Market.HT, "Haiti (HT)"), + (Market.HN, "Honduras (HN)"), + (Market.HK, "Hong Kong (HK)"), + (Market.HU, "Hungary (HU)"), + (Market.IS, "Iceland (IS)"), + (Market.IN, "India (IN)"), + (Market.ID, "Indonesia (ID)"), + (Market.IQ, "Iraq (IQ)"), + (Market.IE, "Ireland (IE)"), + (Market.IL, "Israel (IL)"), + (Market.IT, "Italy (IT)"), + (Market.JM, "Jamaica (JM)"), + (Market.JP, "Japan (JP)"), + (Market.JO, "Jordan (JO)"), + (Market.KZ, "Kazakhstan (KZ)"), + (Market.KE, "Kenya (KE)"), + (Market.KI, "Kiribati (KI)"), + (Market.XK, "Kosovo (XK)"), + (Market.KW, "Kuwait (KW)"), + (Market.KG, "Kyrgyzstan (KG)"), + (Market.LA, "Laos (LA)"), + (Market.LV, "Latvia (LV)"), + (Market.LB, "Lebanon (LB)"), + (Market.LS, "Lesotho (LS)"), + (Market.LR, "Liberia (LR)"), + (Market.LY, "Libya (LY)"), + (Market.LI, "Liechtenstein (LI)"), + (Market.LT, "Lithuania (LT)"), + (Market.LU, "Luxembourg (LU)"), + (Market.MO, "Macao / Macau (MO)"), + (Market.MG, "Madagascar (MG)"), + (Market.MW, "Malawi (MW)"), + (Market.MY, "Malaysia (MY)"), + (Market.MV, "Maldives (MV)"), + (Market.ML, "Mali (ML)"), + (Market.MT, "Malta (MT)"), + (Market.MH, "Marshall Islands (MH)"), + (Market.MR, "Mauritania (MR)"), + (Market.MU, "Mauritius (MU)"), + (Market.MX, "Mexico (MX)"), + (Market.FM, "Micronesia (FM)"), + (Market.MD, "Moldova (MD)"), + (Market.MC, "Monaco (MC)"), + (Market.MN, "Mongolia (MN)"), + (Market.ME, "Montenegro (ME)"), + (Market.MA, "Morocco (MA)"), + (Market.MZ, "Mozambique (MZ)"), + (Market.NA, "Namibia (NA)"), + (Market.NR, "Nauru (NR)"), + (Market.NP, "Nepal (NP)"), + (Market.NL, "Netherlands (NL)"), + (Market.NZ, "New Zealand (NZ)"), + (Market.NI, "Nicaragua (NI)"), + (Market.NE, "Niger (NE)"), + (Market.NG, "Nigeria (NG)"), + (Market.MK, "North Macedonia (MK)"), + (Market.NO, "Norway (NO)"), + (Market.OM, "Oman (OM)"), + (Market.PK, "Pakistan (PK)"), + (Market.PW, "Palau (PW)"), + (Market.PS, "Palestine (PS)"), + (Market.PA, "Panama (PA)"), + (Market.PG, "Papua New Guinea (PG)"), + (Market.PY, "Paraguay (PY)"), + (Market.PE, "Peru (PE)"), + (Market.PH, "Philippines (PH)"), + (Market.PL, "Poland (PL)"), + (Market.PT, "Portugal (PT)"), + (Market.QA, "Qatar (QA)"), + (Market.CG, "Congo (CG)"), + (Market.RO, "Romania (RO)"), + (Market.RU, "Russia (RU)"), + (Market.RW, "Rwanda (RW)"), + (Market.WS, "Samoa (WS)"), + (Market.SM, "San Marino (SM)"), + (Market.SA, "Saudi Arabia (SA)"), + (Market.SN, "Senegal (SN)"), + (Market.RS, "Serbia (RS)"), + (Market.SC, "Seychelles (SC)"), + (Market.SL, "Sierra Leone (SL)"), + (Market.SG, "Singapore (SG)"), + (Market.SK, "Slovakia (SK)"), + (Market.SI, "Slovenia (SI)"), + (Market.SB, "Solomon Islands (SB)"), + (Market.ZA, "South Africa (ZA)"), + (Market.KR, "South Korea (KR)"), + (Market.ES, "Spain (ES)"), + (Market.LK, "Sri Lanka (LK)"), + (Market.KN, "St. Kitts and Nevis (KN)"), + (Market.LC, "St. Lucia (LC)"), + (Market.SR, "Suriname (SR)"), + (Market.SE, "Sweden (SE)"), + (Market.CH, "Switzerland (CH)"), + (Market.ST, "São Tomé and Príncipe (ST)"), + (Market.TW, "Taiwan (TW)"), + (Market.TJ, "Tajikistan (TJ)"), + (Market.TZ, "Tanzania (TZ)"), + (Market.TH, "Thailand (TH)"), + (Market.BS, "The Bahamas (BS)"), + (Market.GM, "The Gambia (GM)"), + (Market.TL, "East Timor (TL)"), + (Market.TG, "Togo (TG)"), + (Market.TO, "Tonga (TO)"), + (Market.TT, "Trinidad and Tobago (TT)"), + (Market.TN, "Tunisia (TN)"), + (Market.TR, "Turkey (TR)"), + (Market.TV, "Tuvalu (TV)"), + (Market.UG, "Uganda (UG)"), + (Market.UA, "Ukraine (UA)"), + (Market.AE, "United Arab Emirates (AE)"), + (Market.GB, "United Kingdom (GB)"), + (Market.US, "United States (US)"), + (Market.UY, "Uruguay (UY)"), + (Market.UZ, "Uzbekistan (UZ)"), + (Market.VU, "Vanuatu (VU)"), + (Market.VE, "Venezuela (VE)"), + (Market.VN, "Vietnam (VN)"), + (Market.ZM, "Zambia (ZM)"), + (Market.ZW, "Zimbabwe (ZW)"), ]; diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 4781050d..5c769498 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -1,6 +1,7 @@ import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:simple_icons/simple_icons.dart'; abstract class SpotubeIcons { static const home = FluentIcons.home_12_regular; @@ -97,4 +98,12 @@ abstract class SpotubeIcons { static const user = FeatherIcons.user; static const edit = FeatherIcons.edit; static const web = FeatherIcons.globe; + static const amoled = FeatherIcons.sunset; + static const file = FeatherIcons.file; + static const stream = Icons.stream_rounded; + static const lastFm = SimpleIcons.lastdotfm; + static const spotify = SimpleIcons.spotify; + static const eye = FeatherIcons.eye; + static const noEye = FeatherIcons.eyeOff; + static const normalize = FeatherIcons.barChart2; } diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index b946209a..d8f8d85b 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -11,24 +11,7 @@ import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; -enum AlbumType { - album, - single, - compilation; - - factory AlbumType.from(String? type) { - switch (type) { - case "album": - return AlbumType.album; - case "single": - return AlbumType.single; - case "compilation": - return AlbumType.compilation; - default: - return AlbumType.album; - } - } - +extension FormattedAlbumType on AlbumType { String get formatted => name.replaceFirst(name[0], name[0].toUpperCase()); } @@ -71,7 +54,7 @@ class AlbumCard extends HookConsumerWidget { isLoading: isPlaylistPlaying && playlist.isFetching == true, title: album.name!, description: - "${AlbumType.from(album.albumType!).formatted} • ${TypeConversionUtils.artists_X_String(album.artists ?? [])}", + "${album.albumType?.formatted} • ${TypeConversionUtils.artists_X_String(album.artists ?? [])}", onTap: () { ServiceUtils.push(context, "/album/${album.id}", extra: album); }, diff --git a/lib/components/desktop_login/login_form.dart b/lib/components/desktop_login/login_form.dart index 42b8fb56..b9783f87 100644 --- a/lib/components/desktop_login/login_form.dart +++ b/lib/components/desktop_login/login_form.dart @@ -20,6 +20,8 @@ class TokenLoginForm extends HookConsumerWidget { final keyCodeController = useTextEditingController(); final mounted = useIsMounted(); + final isLoading = useState(false); + return ConstrainedBox( constraints: const BoxConstraints( maxWidth: 400, @@ -45,27 +47,35 @@ class TokenLoginForm extends HookConsumerWidget { ), const SizedBox(height: 20), FilledButton( - onPressed: () async { - if (keyCodeController.text.isEmpty || - directCodeController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.fill_in_all_fields), - behavior: SnackBarBehavior.floating, - ), - ); - return; - } - final cookieHeader = - "sp_dc=${directCodeController.text}; sp_key=${keyCodeController.text}"; + onPressed: isLoading.value + ? null + : () async { + try { + isLoading.value = true; + if (keyCodeController.text.isEmpty || + directCodeController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.fill_in_all_fields), + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + final cookieHeader = + "sp_dc=${directCodeController.text}; sp_key=${keyCodeController.text}"; - authenticationNotifier.setCredentials( - await AuthenticationCredentials.fromCookie(cookieHeader), - ); - if (mounted()) { - onDone?.call(); - } - }, + authenticationNotifier.setCredentials( + await AuthenticationCredentials.fromCookie( + cookieHeader), + ); + if (mounted()) { + onDone?.call(); + } + } finally { + isLoading.value = false; + } + }, child: Text(context.l10n.submit), ) ], diff --git a/lib/components/library/user_albums.dart b/lib/components/library/user_albums.dart index 8df34346..ccde43f9 100644 --- a/lib/components/library/user_albums.dart +++ b/lib/components/library/user_albums.dart @@ -7,6 +7,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/album/album_card.dart'; +import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/shared/waypoint.dart'; @@ -70,39 +71,42 @@ class UserAlbums extends HookConsumerWidget { child: SearchBar( onChanged: (value) => searchText.value = value, leading: const Icon(SpotubeIcons.filter), - hintText: context.l10n.filter_artist, + hintText: context.l10n.filter_albums, ), ), ), ), body: SizedBox.expand( - child: SingleChildScrollView( - padding: const EdgeInsets.all(8.0), + child: InterScrollbar( controller: controller, - child: Wrap( - runSpacing: 20, - alignment: WrapAlignment.center, - runAlignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - if (albums.isEmpty) - Container( - alignment: Alignment.topLeft, - padding: const EdgeInsets.all(16.0), - child: const ShimmerPlaybuttonCard(count: 4), - ), - for (final album in albums) - AlbumCard( - TypeConversionUtils.simpleAlbum_X_Album(album), - ), - if (albumsQuery.hasNextPage) - Waypoint( - controller: controller, - isGrid: true, - onTouchEdge: albumsQuery.fetchNext, - child: const ShimmerPlaybuttonCard(count: 1), - ) - ], + child: SingleChildScrollView( + padding: const EdgeInsets.all(8.0), + controller: controller, + child: Wrap( + runSpacing: 20, + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (albums.isEmpty) + Container( + alignment: Alignment.topLeft, + padding: const EdgeInsets.all(16.0), + child: const ShimmerPlaybuttonCard(count: 4), + ), + for (final album in albums) + AlbumCard( + TypeConversionUtils.simpleAlbum_X_Album(album), + ), + if (albumsQuery.hasNextPage) + Waypoint( + controller: controller, + isGrid: true, + onTouchEdge: albumsQuery.fetchNext, + child: const ShimmerPlaybuttonCard(count: 1), + ) + ], + ), ), ), ), diff --git a/lib/components/library/user_artists.dart b/lib/components/library/user_artists.dart index c90d8010..881451b0 100644 --- a/lib/components/library/user_artists.dart +++ b/lib/components/library/user_artists.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/artist/artist_card.dart'; +import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/services/queries/queries.dart'; @@ -78,18 +79,21 @@ class UserArtists extends HookConsumerWidget { onRefresh: () async { await artistQuery.refresh(); }, - child: SingleChildScrollView( + child: InterScrollbar( controller: controller, - child: SizedBox( - width: double.infinity, - child: SafeArea( - child: Center( - child: Wrap( - spacing: 15, - runSpacing: 5, - children: filteredArtists - .mapIndexed((index, artist) => ArtistCard(artist)) - .toList(), + child: SingleChildScrollView( + controller: controller, + child: SizedBox( + width: double.infinity, + child: SafeArea( + child: Center( + child: Wrap( + spacing: 15, + runSpacing: 5, + children: filteredArtists + .mapIndexed((index, artist) => ArtistCard(artist)) + .toList(), + ), ), ), ), diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index 16692462..50ae64be 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -1,6 +1,6 @@ import 'dart:io'; -import 'package:catcher/catcher.dart'; +import 'package:catcher_2/catcher_2.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -17,6 +17,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; +import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart'; import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; import 'package:spotube/components/shared/track_table/track_tile.dart'; @@ -28,7 +29,8 @@ import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; -import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; +import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' + show FfiException; const supportedAudioTypes = [ "audio/webm", @@ -76,14 +78,14 @@ final localTracksProvider = FutureProvider>((ref) async { final mimetype = lookupMimeType(file.path); return mimetype != null && supportedAudioTypes.contains(mimetype); }).map( - (f) async { + (file) async { try { - final metadata = await MetadataGod.readMetadata(file: f.path); + final metadata = await MetadataGod.readMetadata(file: file.path); final imageFile = File(join( (await getTemporaryDirectory()).path, "spotube", - basenameWithoutExtension(f.path) + + basenameWithoutExtension(file.path) + imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!, )); if (!await imageFile.exists() && metadata.picture != null) { @@ -94,12 +96,12 @@ final localTracksProvider = FutureProvider>((ref) async { ); } - return {"metadata": metadata, "file": f, "art": imageFile.path}; + return {"metadata": metadata, "file": file, "art": imageFile.path}; } catch (e, stack) { if (e is FfiException) { - return {"file": f}; + return {"file": file}; } - Catcher.reportCheckedError(e, stack); + Catcher2.reportCheckedError(e, stack); return {}; } }, @@ -123,7 +125,7 @@ final localTracksProvider = FutureProvider>((ref) async { return tracks; } catch (e, stack) { - Catcher.reportCheckedError(e, stack); + Catcher2.reportCheckedError(e, stack); return []; } }); @@ -286,24 +288,26 @@ class UserLocalTracks extends HookConsumerWidget { onRefresh: () async { ref.refresh(localTracksProvider); }, - child: ListView.builder( - physics: const AlwaysScrollableScrollPhysics(), - itemCount: filteredTracks.length, - itemBuilder: (context, index) { - final track = filteredTracks[index]; - return TrackTile( - index: index, - track: track, - userPlaylist: false, - onTap: () async { - await playLocalTracks( - ref, - sortedTracks, - currentTrack: track, - ); - }, - ); - }, + child: InterScrollbar( + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + itemCount: filteredTracks.length, + itemBuilder: (context, index) { + final track = filteredTracks[index]; + return TrackTile( + index: index, + track: track, + userPlaylist: false, + onTap: () async { + await playLocalTracks( + ref, + sortedTracks, + currentTrack: track, + ); + }, + ); + }, + ), ), ), ); diff --git a/lib/components/library/user_playlists.dart b/lib/components/library/user_playlists.dart index 3f4029fe..8ed3e73d 100644 --- a/lib/components/library/user_playlists.dart +++ b/lib/components/library/user_playlists.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/playlist/playlist_create_dialog.dart'; +import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/playlist/playlist_card.dart'; @@ -79,59 +80,63 @@ class UserPlaylists extends HookConsumerWidget { return RefreshIndicator( onRefresh: playlistsQuery.refresh, - child: SingleChildScrollView( + child: InterScrollbar( controller: controller, - physics: const AlwaysScrollableScrollPhysics(), - child: Waypoint( + child: SingleChildScrollView( controller: controller, - onTouchEdge: () { - if (playlistsQuery.hasNextPage) { - playlistsQuery.fetchNext(); - } - }, - child: SafeArea( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(10), - child: SearchBar( - onChanged: (value) => searchText.value = value, - hintText: context.l10n.filter_playlists, - leading: const Icon(SpotubeIcons.filter), + physics: const AlwaysScrollableScrollPhysics(), + child: Waypoint( + controller: controller, + onTouchEdge: () { + if (playlistsQuery.hasNextPage) { + playlistsQuery.fetchNext(); + } + }, + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(10), + child: SearchBar( + onChanged: (value) => searchText.value = value, + hintText: context.l10n.filter_playlists, + leading: const Icon(SpotubeIcons.filter), + ), ), - ), - AnimatedCrossFade( - duration: const Duration(milliseconds: 300), - crossFadeState: playlistsQuery.isLoadingPage || - !playlistsQuery.hasPageData - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - firstChild: - const Center(child: ShimmerPlaybuttonCard(count: 7)), - secondChild: Wrap( - runSpacing: 10, - alignment: WrapAlignment.center, - children: [ - Row( - children: [ - const SizedBox(width: 10), - const PlaylistCreateDialogButton(), - const SizedBox(width: 10), - ElevatedButton.icon( - icon: const Icon(SpotubeIcons.magic), - label: Text(context.l10n.generate_playlist), - onPressed: () { - GoRouter.of(context).push("/library/generate"); - }, - ), - const SizedBox(width: 10), - ], - ), - ...playlists.map((playlist) => PlaylistCard(playlist)) - ], + AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + crossFadeState: !playlistsQuery.hasPageData && + !playlistsQuery.hasPageError && + !playlistsQuery.isLoadingNextPage + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: + const Center(child: ShimmerPlaybuttonCard(count: 7)), + secondChild: Wrap( + runSpacing: 10, + alignment: WrapAlignment.center, + children: [ + Row( + children: [ + const SizedBox(width: 10), + const PlaylistCreateDialogButton(), + const SizedBox(width: 10), + ElevatedButton.icon( + icon: const Icon(SpotubeIcons.magic), + label: Text(context.l10n.generate_playlist), + onPressed: () { + GoRouter.of(context).push("/library/generate"); + }, + ), + const SizedBox(width: 10), + ], + ), + ...playlists.map((playlist) => PlaylistCard(playlist)) + ], + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/pages/player/player.dart b/lib/components/player/player.dart similarity index 79% rename from lib/pages/player/player.dart rename to lib/components/player/player.dart index e925ba60..811d24c5 100644 --- a/lib/pages/player/player.dart +++ b/lib/components/player/player.dart @@ -15,6 +15,7 @@ import 'package:spotube/components/shared/animated_gradient.dart'; import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/panels/sliding_up_panel.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/use_custom_status_bar_color.dart'; @@ -26,8 +27,10 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerView extends HookConsumerWidget { + final PanelController panelController; const PlayerView({ Key? key, + required this.panelController, }) : super(key: key); @override @@ -45,7 +48,7 @@ class PlayerView extends HookConsumerWidget { useEffect(() { if (mediaQuery.lgAndUp) { WidgetsBinding.instance.addPostFrameCallback((_) { - GoRouter.of(context).pop(); + panelController.close(); }); } return null; @@ -60,64 +63,96 @@ class PlayerView extends HookConsumerWidget { ); final palette = usePaletteGenerator(albumArt); - final bgColor = palette.dominantColor?.color ?? theme.colorScheme.primary; final titleTextColor = palette.dominantColor?.titleTextColor; final bodyTextColor = palette.dominantColor?.bodyTextColor; + final bgColor = palette.dominantColor?.color ?? theme.colorScheme.primary; + + final GlobalKey scaffoldKey = + useMemoized(() => GlobalKey(), []); + + useEffect(() { + WidgetsBinding.instance.renderView.automaticSystemUiAdjustment = false; + + return () { + WidgetsBinding.instance.renderView.automaticSystemUiAdjustment = true; + }; + }, [panelController.isPanelOpen]); + useCustomStatusBarColor( bgColor, - GoRouterState.of(context).matchedLocation == "/player", + panelController.isPanelOpen, noSetBGColor: true, + automaticSystemUiAdjustment: false, ); - return IconTheme( - data: theme.iconTheme.copyWith(color: bodyTextColor), - child: Scaffold( - appBar: PageWindowTitleBar( - backgroundColor: Colors.transparent, - foregroundColor: titleTextColor, - toolbarOpacity: 1, - leading: const BackButton(), - actions: [ - IconButton( - icon: const Icon(SpotubeIcons.info, size: 18), - tooltip: context.l10n.details, - style: IconButton.styleFrom(foregroundColor: bodyTextColor), - onPressed: currentTrack == null - ? null - : () { - showDialog( - context: context, - builder: (context) { - return TrackDetailsDialog( - track: currentTrack, - ); - }); - }, - ) + final topPadding = MediaQueryData.fromView(View.of(context)).padding.top; + + return WillPopScope( + onWillPop: () async { + panelController.close(); + return false; + }, + child: IconTheme( + data: theme.iconTheme.copyWith(color: bodyTextColor), + child: AnimateGradient( + animateAlignments: true, + primaryBegin: Alignment.topLeft, + primaryEnd: Alignment.bottomLeft, + secondaryBegin: Alignment.bottomRight, + secondaryEnd: Alignment.topRight, + duration: const Duration(seconds: 15), + primaryColors: [ + palette.dominantColor?.color ?? theme.colorScheme.primary, + palette.mutedColor?.color ?? theme.colorScheme.secondary, ], - ), - extendBodyBehindAppBar: true, - body: SizedBox( - height: double.infinity, - child: AnimateGradient( - animateAlignments: true, - primaryBegin: Alignment.topLeft, - primaryEnd: Alignment.bottomLeft, - secondaryBegin: Alignment.bottomRight, - secondaryEnd: Alignment.topRight, - duration: const Duration(seconds: 15), - primaryColors: [ - palette.dominantColor?.color ?? theme.colorScheme.primary, - palette.mutedColor?.color ?? theme.colorScheme.secondary, - ], - secondaryColors: [ - (palette.darkVibrantColor ?? palette.lightVibrantColor)?.color ?? - theme.colorScheme.primaryContainer, - (palette.darkMutedColor ?? palette.lightMutedColor)?.color ?? - theme.colorScheme.secondaryContainer, - ], - child: SingleChildScrollView( + secondaryColors: [ + (palette.darkVibrantColor ?? palette.lightVibrantColor)?.color ?? + theme.colorScheme.primaryContainer, + (palette.darkMutedColor ?? palette.lightMutedColor)?.color ?? + theme.colorScheme.secondaryContainer, + ], + child: Scaffold( + key: scaffoldKey, + backgroundColor: Colors.transparent, + appBar: PreferredSize( + preferredSize: Size.fromHeight( + kToolbarHeight + topPadding, + ), + child: Padding( + padding: EdgeInsets.only(top: topPadding), + child: PageWindowTitleBar( + backgroundColor: Colors.transparent, + foregroundColor: titleTextColor, + toolbarOpacity: 1, + leading: IconButton( + icon: const Icon(SpotubeIcons.angleDown, size: 18), + onPressed: panelController.close, + ), + actions: [ + IconButton( + icon: const Icon(SpotubeIcons.info, size: 18), + tooltip: context.l10n.details, + style: + IconButton.styleFrom(foregroundColor: bodyTextColor), + onPressed: currentTrack == null + ? null + : () { + showDialog( + context: context, + builder: (context) { + return TrackDetailsDialog( + track: currentTrack, + ); + }); + }, + ) + ], + ), + ), + ), + extendBodyBehindAppBar: true, + body: SingleChildScrollView( child: Container( alignment: Alignment.center, width: double.infinity, @@ -190,7 +225,7 @@ class PlayerView extends HookConsumerWidget { color: bodyTextColor, ), onRouteChange: (route) { - GoRouter.of(context).pop(); + panelController.close(); GoRouter.of(context).push(route); }, ), diff --git a/lib/components/player/player_actions.dart b/lib/components/player/player_actions.dart index 78fb53b7..b3a1e340 100644 --- a/lib/components/player/player_actions.dart +++ b/lib/components/player/player_actions.dart @@ -13,7 +13,6 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/logger.dart'; -import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; diff --git a/lib/components/player/player_overlay.dart b/lib/components/player/player_overlay.dart index 889e6609..354d1a36 100644 --- a/lib/components/player/player_overlay.dart +++ b/lib/components/player/player_overlay.dart @@ -2,16 +2,17 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/player/player_track_details.dart'; +import 'package:spotube/components/root/spotube_navigation_bar.dart'; +import 'package:spotube/components/shared/panels/sliding_up_panel.dart'; +import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/intents.dart'; import 'package:spotube/hooks/use_progress.dart'; +import 'package:spotube/components/player/player.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/utils/service_utils.dart'; class PlayerOverlay extends HookConsumerWidget { final String albumArt; @@ -39,22 +40,32 @@ class PlayerOverlay extends HookConsumerWidget { topRight: Radius.circular(10), ); - return GestureDetector( - onVerticalDragEnd: (details) { - int sensitivity = 8; - if (details.primaryVelocity != null && - details.primaryVelocity! < -sensitivity) { - ServiceUtils.push(context, "/player"); - } + final mediaQuery = MediaQuery.of(context); + + final panelController = useMemoized(() => PanelController(), []); + + useEffect(() { + return () { + panelController.dispose(); + }; + }, []); + + return SlidingUpPanel( + maxHeight: mediaQuery.size.height, + backdropEnabled: false, + minHeight: canShow ? 53 : 0, + onPanelSlide: (position) { + final invertedPosition = 1 - position; + ref.read(navigationPanelHeight.notifier).state = 50 * invertedPosition; }, - child: ClipRRect( + controller: panelController, + collapsed: ClipRRect( borderRadius: radius, child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), child: AnimatedContainer( duration: const Duration(milliseconds: 250), - width: MediaQuery.of(context).size.width, - height: canShow ? 53 : 0, + width: mediaQuery.size.width, decoration: BoxDecoration( color: theme.colorScheme.secondaryContainer.withOpacity(.8), borderRadius: radius, @@ -95,18 +106,16 @@ class PlayerOverlay extends HookConsumerWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => - GoRouter.of(context).push("/player"), - child: Container( - width: double.infinity, - color: Colors.transparent, - child: PlayerTrackDetails( - albumArt: albumArt, - color: textColor, - ), + child: GestureDetector( + onTap: () { + panelController.open(); + }, + child: Container( + width: double.infinity, + color: Colors.transparent, + child: PlayerTrackDetails( + albumArt: albumArt, + color: textColor, ), ), ), @@ -165,6 +174,26 @@ class PlayerOverlay extends HookConsumerWidget { ), ), ), + 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: HorizontalScrollableWidget( + child: PlayerView(panelController: panelController), + ), + ), + ); + }, ); } } diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index a5dee8c9..725af22b 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -10,6 +10,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/fallbacks/not_found.dart'; +import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/track_table/track_tile.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; @@ -196,21 +197,55 @@ class PlayerQueue extends HookConsumerWidget { const SizedBox(height: 10), if (!isSearching.value && searchText.value.isEmpty) Flexible( - child: ReorderableListView.builder( - onReorder: (oldIndex, newIndex) { - playlistNotifier.moveTrack(oldIndex, newIndex); - }, - scrollController: controller, - itemCount: tracks.length, - shrinkWrap: true, - buildDefaultDragHandles: false, - itemBuilder: (context, i) { - final track = tracks.elementAt(i); - return AutoScrollTag( - key: ValueKey(i), - controller: controller, - index: i, - child: Padding( + child: InterScrollbar( + controller: controller, + child: ReorderableListView.builder( + onReorder: (oldIndex, newIndex) { + playlistNotifier.moveTrack(oldIndex, newIndex); + }, + scrollController: controller, + itemCount: tracks.length, + shrinkWrap: true, + buildDefaultDragHandles: false, + itemBuilder: (context, i) { + final track = tracks.elementAt(i); + return AutoScrollTag( + key: ValueKey(i), + controller: controller, + index: i, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8.0), + child: TrackTile( + index: i, + track: track, + onTap: () async { + if (playlist.activeTrack?.id == track.id) { + return; + } + await playlistNotifier.jumpToTrack(track); + }, + leadingActions: [ + ReorderableDragStartListener( + index: i, + child: const Icon(SpotubeIcons.dragHandle), + ), + ], + ), + ), + ); + }, + ), + ), + ) + else + Flexible( + child: InterScrollbar( + child: ListView.builder( + itemCount: filteredTracks.length, + itemBuilder: (context, i) { + final track = filteredTracks.elementAt(i); + return Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: TrackTile( @@ -222,38 +257,10 @@ class PlayerQueue extends HookConsumerWidget { } await playlistNotifier.jumpToTrack(track); }, - leadingActions: [ - ReorderableDragStartListener( - index: i, - child: const Icon(SpotubeIcons.dragHandle), - ), - ], ), - ), - ); - }, - ), - ) - else - Flexible( - child: ListView.builder( - itemCount: filteredTracks.length, - itemBuilder: (context, i) { - final track = filteredTracks.elementAt(i); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: TrackTile( - index: i, - track: track, - onTap: () async { - if (playlist.activeTrack?.id == track.id) { - return; - } - await playlistNotifier.jumpToTrack(track); - }, - ), - ); - }, + ); + }, + ), ), ), ], diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index d4857853..6587b8b3 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -7,6 +7,7 @@ import 'package:spotify/spotify.dart' hide Offset; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; @@ -17,7 +18,6 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/provider/youtube_provider.dart'; import 'package:spotube/services/youtube/youtube.dart'; -import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -202,32 +202,36 @@ class SiblingTracksSheet extends HookConsumerWidget { duration: const Duration(milliseconds: 300), transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child), - child: switch (isSearching.value) { - false => ListView.builder( - itemCount: siblings.length, - itemBuilder: (context, index) => - itemBuilder(siblings[index]), - ), - true => FutureBuilder( - future: searchRequest, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Center( - child: Text(snapshot.error.toString()), - ); - } else if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator()); - } + child: InterScrollbar( + child: switch (isSearching.value) { + false => ListView.builder( + itemCount: siblings.length, + itemBuilder: (context, index) => + itemBuilder(siblings[index]), + ), + true => FutureBuilder( + future: searchRequest, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text(snapshot.error.toString()), + ); + } else if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator()); + } - return ListView.builder( - itemCount: snapshot.data!.length, - itemBuilder: (context, index) => - itemBuilder(snapshot.data![index]), - ); - }, - ), - }, + return InterScrollbar( + child: ListView.builder( + itemCount: snapshot.data!.length, + itemBuilder: (context, index) => + itemBuilder(snapshot.data![index]), + ), + ); + }, + ), + }, + ), ), ), ), diff --git a/lib/components/playlist/playlist_create_dialog.dart b/lib/components/playlist/playlist_create_dialog.dart index 53424914..2e11a209 100644 --- a/lib/components/playlist/playlist_create_dialog.dart +++ b/lib/components/playlist/playlist_create_dialog.dart @@ -170,30 +170,27 @@ class PlaylistCreateDialog extends HookConsumerWidget { return null; }, builder: (field) { - return Center( - child: Stack( - children: [ - UniversalImage( - path: field.value?.path ?? - TypeConversionUtils.image_X_UrlString( - updatingPlaylist?.images, - placeholder: - ImagePlaceholder.collection, - ), - height: 200, - ), - Positioned( - bottom: 20, - right: 20, - child: IconButton.filled( + return Column( + children: [ + UniversalImage( + path: field.value?.path ?? + TypeConversionUtils.image_X_UrlString( + updatingPlaylist?.images, + placeholder: ImagePlaceholder.collection, + ), + height: 200, + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FilledButton.icon( icon: const Icon(SpotubeIcons.edit), - style: IconButton.styleFrom( - backgroundColor: - theme.colorScheme.surface, - foregroundColor: - theme.colorScheme.primary, - elevation: 2, - shadowColor: theme.colorScheme.onSurface, + label: Text( + field.value?.path != null || + updatingPlaylist?.images != null + ? context.l10n.change_cover + : context.l10n.add_cover, ), onPressed: () async { final imageFile = await ImagePicker() @@ -207,31 +204,32 @@ class PlaylistCreateDialog extends HookConsumerWidget { } }, ), - ), - if (field.hasError) - Positioned( - bottom: 20, - left: 20, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: theme.colorScheme.error, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - field.errorText ?? "", - style: theme.textTheme.bodyMedium! - .copyWith( - color: theme.colorScheme.onError, - ), - ), + 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), diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index dcbc2c39..0dc8b5b4 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -69,8 +69,17 @@ class Sidebar extends HookConsumerWidget { Color.lerp(bg, Colors.black, 0.45)!, ); - final sidebarTileList = - useMemoized(() => getSidebarTileList(context.l10n), [context.l10n]); + final sidebarTileList = useMemoized( + () => getSidebarTileList(context.l10n), + [context.l10n], + ); + + useEffect(() { + if (controller.selectedIndex != selectedIndex) { + controller.selectIndex(selectedIndex); + } + return null; + }, [selectedIndex]); useEffect(() { controller.addListener(() { diff --git a/lib/components/root/spotube_navigation_bar.dart b/lib/components/root/spotube_navigation_bar.dart index ee8e3319..9cea5603 100644 --- a/lib/components/root/spotube_navigation_bar.dart +++ b/lib/components/root/spotube_navigation_bar.dart @@ -13,6 +13,8 @@ import 'package:spotube/hooks/use_brightness_value.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; +final navigationPanelHeight = StateProvider((ref) => 50); + class SpotubeNavigationBar extends HookConsumerWidget { final int selectedIndex; final void Function(int) onSelectedIndexChanged; @@ -41,54 +43,61 @@ class SpotubeNavigationBar extends HookConsumerWidget { final navbarTileList = useMemoized(() => getNavbarTileList(context.l10n), [context.l10n]); + final panelHeight = ref.watch(navigationPanelHeight); + useEffect(() { insideSelectedIndex.value = selectedIndex; return null; }, [selectedIndex]); if (layoutMode == LayoutMode.extended || - (mediaQuery.mdAndUp && layoutMode == LayoutMode.adaptive)) { + (mediaQuery.mdAndUp && layoutMode == LayoutMode.adaptive) || + panelHeight < 10) { return const SizedBox(); } - return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), - child: CurvedNavigationBar( - backgroundColor: - theme.colorScheme.secondaryContainer.withOpacity(0.72), - buttonBackgroundColor: buttonColor, - color: theme.colorScheme.background, - height: 50, - animationDuration: const Duration(milliseconds: 350), - items: navbarTileList.map( - (e) { - /// Using this [Builder] as an workaround for the first item's - /// icon color not updating unless navigating to another page - return Builder(builder: (context) { - return MouseRegion( - cursor: SystemMouseCursors.click, - child: Badge( - isLabelVisible: e.id == "library" && downloadCount > 0, - label: Text(downloadCount.toString()), - child: Icon( - e.icon, - color: Theme.of(context).colorScheme.primary, + return AnimatedContainer( + duration: const Duration(milliseconds: 100), + height: panelHeight, + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), + child: CurvedNavigationBar( + backgroundColor: + theme.colorScheme.secondaryContainer.withOpacity(0.72), + buttonBackgroundColor: buttonColor, + color: theme.colorScheme.background, + height: panelHeight, + animationDuration: const Duration(milliseconds: 350), + items: navbarTileList.map( + (e) { + /// Using this [Builder] as an workaround for the first item's + /// icon color not updating unless navigating to another page + return Builder(builder: (context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: Badge( + isLabelVisible: e.id == "library" && downloadCount > 0, + label: Text(downloadCount.toString()), + child: Icon( + e.icon, + color: Theme.of(context).colorScheme.primary, + ), ), - ), - ); - }); + ); + }); + }, + ).toList(), + index: insideSelectedIndex.value, + onTap: (i) { + insideSelectedIndex.value = i; + if (navbarTileList[i].id == "settings") { + Sidebar.goToSettings(context); + return; + } + onSelectedIndexChanged(i); }, - ).toList(), - index: insideSelectedIndex.value, - onTap: (i) { - insideSelectedIndex.value = i; - if (navbarTileList[i].id == "settings") { - Sidebar.goToSettings(context); - return; - } - onSelectedIndexChanged(i); - }, + ), ), ), ); diff --git a/lib/components/shared/adaptive/adaptive_select_tile.dart b/lib/components/shared/adaptive/adaptive_select_tile.dart index 17e652b7..58666e46 100644 --- a/lib/components/shared/adaptive/adaptive_select_tile.dart +++ b/lib/components/shared/adaptive/adaptive_select_tile.dart @@ -42,6 +42,7 @@ class AdaptiveSelectTile extends HookWidget { items: options, value: value, onChanged: onChanged, + menuMaxHeight: mediaQuery.size.height * 0.6, ); final controlPlaceholder = useMemoized( () => options diff --git a/lib/components/shared/heart_button.dart b/lib/components/shared/heart_button.dart index 4a23cc48..81ccffdb 100644 --- a/lib/components/shared/heart_button.dart +++ b/lib/components/shared/heart_button.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/scrobbler_provider.dart'; import 'package:spotube/services/mutations/mutations.dart'; import 'package:spotube/services/queries/queries.dart'; @@ -75,12 +76,12 @@ UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) { final mounted = useIsMounted(); + final scrobblerNotifier = ref.read(scrobblerProvider.notifier); + final toggleTrackLike = useMutations.track.toggleFavorite( ref, track.id!, onMutate: (isLiked) { - print("Toggle Like onMutate: $isLiked"); - if (isLiked) { savedTracks.setData( savedTracks.data @@ -98,12 +99,15 @@ UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) { } return isLiked; }, - onData: (data, recoveryData) async { - print("Toggle Like onData: $data"); + onData: (isLiked, recoveryData) async { await savedTracks.refresh(); + if (isLiked) { + await scrobblerNotifier.love(track); + } else { + await scrobblerNotifier.unlove(track); + } }, onError: (payload, isLiked) { - print("Toggle Like onError: $payload"); if (!mounted()) return; if (isLiked != true) { diff --git a/lib/components/shared/inter_scrollbar/inter_scrollbar.dart b/lib/components/shared/inter_scrollbar/inter_scrollbar.dart new file mode 100644 index 00000000..05eb174a --- /dev/null +++ b/lib/components/shared/inter_scrollbar/inter_scrollbar.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +class InterScrollbar extends HookWidget { + final Widget child; + final ScrollController? controller; + final bool? thumbVisibility; + final bool? trackVisibility; + final double? thickness; + final Radius? radius; + final bool Function(ScrollNotification)? notificationPredicate; + final bool? interactive; + final ScrollbarOrientation? scrollbarOrientation; + + const InterScrollbar({ + super.key, + required this.child, + this.controller, + this.thumbVisibility, + this.trackVisibility, + this.thickness, + this.radius, + this.notificationPredicate, + this.interactive, + this.scrollbarOrientation, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (DesktopTools.platform.isDesktop) return child; + + return ScrollbarTheme( + data: theme.scrollbarTheme.copyWith( + crossAxisMargin: 10, + minThumbLength: 80, + thickness: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.hovered) || + states.contains(MaterialState.dragged) || + states.contains(MaterialState.pressed)) { + return 40; + } + return 20; + }), + radius: const Radius.circular(20), + thumbColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.hovered) || + states.contains(MaterialState.dragged)) { + return theme.colorScheme.onSurface.withOpacity(0.5); + } + return theme.colorScheme.onSurface.withOpacity(0.3); + }), + ), + child: Scrollbar( + controller: controller, + thumbVisibility: thumbVisibility, + trackVisibility: trackVisibility, + thickness: thickness, + radius: radius, + notificationPredicate: notificationPredicate, + interactive: interactive ?? true, + scrollbarOrientation: scrollbarOrientation, + child: child, + ), + ); + } +} diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart index 0d35a428..b1086eed 100644 --- a/lib/components/shared/page_window_title_bar.dart +++ b/lib/components/shared/page_window_title_bar.dart @@ -4,7 +4,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/utils/platform.dart'; import 'package:titlebar_buttons/titlebar_buttons.dart'; -import 'package:window_manager/window_manager.dart'; import 'dart:math'; import 'package:flutter/foundation.dart' show kIsWeb; import 'dart:io' show Platform; @@ -18,7 +17,7 @@ final closeNotification = DesktopTools.createNotification( LocalNotificationAction(text: 'Close The App'), ], )?..onClickAction = (value) { - windowManager.close(); + DesktopTools.window.close(); }; class PageWindowTitleBar extends StatefulHookConsumerWidget @@ -114,16 +113,16 @@ class WindowTitleBarButtons extends HookConsumerWidget { Future onClose() async { if (preferences.closeBehavior == CloseBehavior.close) { - await windowManager.close(); + await DesktopTools.window.close(); } else { - await windowManager.hide(); + await DesktopTools.window.hide(); await closeNotification?.show(); } } useEffect(() { if (kIsDesktop) { - windowManager.isMaximized().then((value) { + DesktopTools.window.isMaximized().then((value) { isMaximized.value = value; }); } @@ -160,14 +159,14 @@ class WindowTitleBarButtons extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ MinimizeWindowButton( - onPressed: windowManager.minimize, + onPressed: DesktopTools.window.minimize, colors: colors, ), if (isMaximized.value != true) MaximizeWindowButton( colors: colors, onPressed: () { - windowManager.maximize(); + DesktopTools.window.maximize(); isMaximized.value = true; }, ) @@ -175,7 +174,7 @@ class WindowTitleBarButtons extends HookConsumerWidget { RestoreWindowButton( colors: colors, onPressed: () { - windowManager.unmaximize(); + DesktopTools.window.unmaximize(); isMaximized.value = false; }, ), @@ -195,16 +194,16 @@ class WindowTitleBarButtons extends HookConsumerWidget { children: [ DecoratedMinimizeButton( type: type, - onPressed: windowManager.minimize, + onPressed: DesktopTools.window.minimize, ), DecoratedMaximizeButton( type: type, onPressed: () async { - if (await windowManager.isMaximized()) { - await windowManager.unmaximize(); + if (await DesktopTools.window.isMaximized()) { + await DesktopTools.window.unmaximize(); isMaximized.value = false; } else { - await windowManager.maximize(); + await DesktopTools.window.maximize(); isMaximized.value = true; } }, diff --git a/lib/components/shared/panels/controller.dart b/lib/components/shared/panels/controller.dart new file mode 100644 index 00000000..a573c06c --- /dev/null +++ b/lib/components/shared/panels/controller.dart @@ -0,0 +1,142 @@ +part of panels; + +class PanelController extends ChangeNotifier { + SlidingUpPanelState? _panelState; + + void _addState(SlidingUpPanelState panelState) { + _panelState = panelState; + notifyListeners(); + } + + bool _forceScrollChange = false; + + /// use this function when scroll change in func + /// Example: + /// panelController.forseScrollChange(scrollController.animateTo(100, duration: Duration(milliseconds: 400), curve: Curves.ease)) + Future forceScrollChange(Future func) async { + _forceScrollChange = true; + _panelState!._scrollingEnabled = true; + await func; + // if (_panelState!._sc.offset == 0) { + // _panelState!._scrollingEnabled = true; + // } + if (panelPosition < 1) { + _panelState!._scMinOffset = _panelState!._scrollController.offset; + } + _forceScrollChange = false; + } + + bool __nowTargetForceDraggable = false; + + bool get _nowTargetForceDraggable => __nowTargetForceDraggable; + + set _nowTargetForceDraggable(bool value) { + __nowTargetForceDraggable = value; + notifyListeners(); + } + + /// Determine if the panelController is attached to an instance + /// of the SlidingUpPanel (this property must return true before any other + /// functions can be used) + bool get isAttached => _panelState != null; + + /// Closes the sliding panel to its collapsed state (i.e. to the minHeight) + Future close() { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._close(); + } + + /// Opens the sliding panel fully + /// (i.e. to the maxHeight) + Future open() { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._open(); + } + + /// Hides the sliding panel (i.e. is invisible) + Future hide() { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._hide(); + } + + /// Shows the sliding panel in its collapsed state + /// (i.e. "un-hide" the sliding panel) + Future show() { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._show(); + } + + /// Animates the panel position to the value. + /// The value must between 0.0 and 1.0 + /// where 0.0 is fully collapsed and 1.0 is completely open. + /// (optional) duration specifies the time for the animation to complete + /// (optional) curve specifies the easing behavior of the animation. + Future animatePanelToPosition(double value, + {Duration? duration, Curve curve = Curves.linear}) { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + assert(0.0 <= value && value <= 1.0); + return _panelState! + ._animatePanelToPosition(value, duration: duration, curve: curve); + } + + /// Animates the panel position to the snap point + /// Requires that the SlidingUpPanel snapPoint property is not null + /// (optional) duration specifies the time for the animation to complete + /// (optional) curve specifies the easing behavior of the animation. + Future animatePanelToSnapPoint( + {Duration? duration, Curve curve = Curves.linear}) { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + assert(_panelState!.widget.snapPoint != null, + "SlidingUpPanel snapPoint property must not be null"); + return _panelState! + ._animatePanelToSnapPoint(duration: duration, curve: curve); + } + + /// Sets the panel position (without animation). + /// The value must between 0.0 and 1.0 + /// where 0.0 is fully collapsed and 1.0 is completely open. + set panelPosition(double value) { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + assert(0.0 <= value && value <= 1.0); + _panelState!._panelPosition = value; + } + + /// Gets the current panel position. + /// Returns the % offset from collapsed state + /// to the open state + /// as a decimal between 0.0 and 1.0 + /// where 0.0 is fully collapsed and + /// 1.0 is full open. + double get panelPosition { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._panelPosition; + } + + /// Returns whether or not the panel is + /// currently animating. + bool get isPanelAnimating { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._isPanelAnimating; + } + + /// Returns whether or not the + /// panel is open. + bool get isPanelOpen { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._isPanelOpen; + } + + /// Returns whether or not the + /// panel is closed. + bool get isPanelClosed { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._isPanelClosed; + } + + /// Returns whether or not the + /// panel is shown/hidden. + bool get isPanelShown { + assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); + return _panelState!._isPanelShown; + } +} diff --git a/lib/components/shared/panels/helpers.dart b/lib/components/shared/panels/helpers.dart new file mode 100644 index 00000000..2e754bdf --- /dev/null +++ b/lib/components/shared/panels/helpers.dart @@ -0,0 +1,96 @@ +part of panels; + +/// if you want to prevent the panel from being dragged using the widget, +/// wrap the widget with this +class IgnoreDraggableWidget extends SingleChildRenderObjectWidget { + const IgnoreDraggableWidget({ + super.key, + required super.child, + }); + + @override + IgnoreDraggableWidgetWidgetRenderBox createRenderObject( + BuildContext context, + ) { + return IgnoreDraggableWidgetWidgetRenderBox(); + } +} + +class IgnoreDraggableWidgetWidgetRenderBox extends RenderPointerListener { + @override + HitTestBehavior get behavior => HitTestBehavior.opaque; +} + +/// if you want to force the panel to be dragged using the widget, +/// wrap the widget with this +/// For example, use [Scrollable] inside to allow the panel to be dragged +/// even if the scroll is not at position 0. +class ForceDraggableWidget extends SingleChildRenderObjectWidget { + const ForceDraggableWidget({ + super.key, + required super.child, + }); + + @override + ForceDraggableWidgetRenderBox createRenderObject( + BuildContext context, + ) { + return ForceDraggableWidgetRenderBox(); + } +} + +class ForceDraggableWidgetRenderBox extends RenderPointerListener { + @override + HitTestBehavior get behavior => HitTestBehavior.opaque; +} + +/// To make [ForceDraggableWidget] work in [Scrollable] widgets +class PanelScrollPhysics extends ScrollPhysics { + final PanelController controller; + const PanelScrollPhysics({required this.controller, ScrollPhysics? parent}) + : super(parent: parent); + @override + PanelScrollPhysics applyTo(ScrollPhysics? ancestor) { + return PanelScrollPhysics( + controller: controller, parent: buildParent(ancestor)); + } + + @override + double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { + if (controller._nowTargetForceDraggable) return 0.0; + return super.applyPhysicsToUserOffset(position, offset); + } + + @override + Simulation? createBallisticSimulation( + ScrollMetrics position, double velocity) { + if (controller._nowTargetForceDraggable) { + return super.createBallisticSimulation(position, 0); + } + return super.createBallisticSimulation(position, velocity); + } + + @override + bool get allowImplicitScrolling => false; +} + +/// if you want to prevent unwanted panel dragging when scrolling widgets [Scrollable] with horizontal axis +/// wrap the widget with this +class HorizontalScrollableWidget extends SingleChildRenderObjectWidget { + const HorizontalScrollableWidget({ + super.key, + required super.child, + }); + + @override + HorizontalScrollableWidgetRenderBox createRenderObject( + BuildContext context, + ) { + return HorizontalScrollableWidgetRenderBox(); + } +} + +class HorizontalScrollableWidgetRenderBox extends RenderPointerListener { + @override + HitTestBehavior get behavior => HitTestBehavior.opaque; +} diff --git a/lib/components/shared/panels/sliding_up_panel.dart b/lib/components/shared/panels/sliding_up_panel.dart new file mode 100644 index 00000000..137d5eb7 --- /dev/null +++ b/lib/components/shared/panels/sliding_up_panel.dart @@ -0,0 +1,686 @@ +/* +Name: Zotov Vladimir +Date: 18/06/22 +Purpose: Defines the package: sliding_up_panel2 +Copyright: © 2022, Zotov Vladimir. All rights reserved. +Licensing: More information can be found here: https://github.com/Zotov-VD/sliding_up_panel/blob/master/LICENSE + +This product includes software developed by Akshath Jain (https://akshathjain.com) +*/ + +library panels; + +import 'dart:math'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/rendering.dart'; + +part 'controller.dart'; +part 'helpers.dart'; + +enum SlideDirection { up, down } + +enum PanelState { open, closed } + +class SlidingUpPanel extends StatefulWidget { + /// Returns the Widget that slides into view. When the + /// panel is collapsed and if [collapsed] is null, + /// then top portion of this Widget will be displayed; + /// otherwise, [collapsed] will be displayed overtop + /// of this Widget. + final Widget? Function(double position)? panelBuilder; + + /// The Widget displayed overtop the [panel] when collapsed. + /// This fades out as the panel is opened. + final Widget? collapsed; + + /// The Widget that lies underneath the sliding panel. + /// This Widget automatically sizes itself + /// to fill the screen. + final Widget? body; + + /// Optional persistent widget that floats above the [panel] and attaches + /// to the top of the [panel]. Content at the top of the panel will be covered + /// by this widget. Add padding to the bottom of the `panel` to + /// avoid coverage. + final Widget? header; + + /// Optional persistent widget that floats above the [panel] and + /// attaches to the bottom of the [panel]. Content at the bottom of the panel + /// will be covered by this widget. Add padding to the bottom of the `panel` + /// to avoid coverage. + final Widget? footer; + + /// The height of the sliding panel when fully collapsed. + final double minHeight; + + /// The height of the sliding panel when fully open. + final double maxHeight; + + /// A point between [minHeight] and [maxHeight] that the panel snaps to + /// while animating. A fast swipe on the panel will disregard this point + /// and go directly to the open/close position. This value is represented as a + /// percentage of the total animation distance ([maxHeight] - [minHeight]), + /// so it must be between 0.0 and 1.0, exclusive. + final double? snapPoint; + + /// The amount to inset the children of the sliding panel sheet. + final EdgeInsetsGeometry? padding; + + /// Empty space surrounding the sliding panel sheet. + final EdgeInsetsGeometry? margin; + + /// Set to false to disable the panel from snapping open or closed. + final bool panelSnapping; + + /// Disable panel draggable on scrolling. Defaults to false. + final bool disableDraggableOnScrolling; + + /// If non-null, this can be used to control the state of the panel. + final PanelController? controller; + + /// If non-null, shows a darkening shadow over the [body] as the panel slides open. + final bool backdropEnabled; + + /// Shows a darkening shadow of this [Color] over the [body] as the panel slides open. + final Color backdropColor; + + /// The opacity of the backdrop when the panel is fully open. + /// This value can range from 0.0 to 1.0 where 0.0 is completely transparent + /// and 1.0 is completely opaque. + final double backdropOpacity; + + /// Flag that indicates whether or not tapping the + /// backdrop closes the panel. Defaults to true. + final bool backdropTapClosesPanel; + + /// If non-null, this callback + /// is called as the panel slides around with the + /// current position of the panel. The position is a double + /// between 0.0 and 1.0 where 0.0 is fully collapsed and 1.0 is fully open. + final void Function(double position)? onPanelSlide; + + /// If non-null, this callback is called when the + /// panel is fully opened + final VoidCallback? onPanelOpened; + + /// If non-null, this callback is called when the panel + /// is fully collapsed. + final VoidCallback? onPanelClosed; + + /// If non-null and true, the SlidingUpPanel exhibits a + /// parallax effect as the panel slides up. Essentially, + /// the body slides up as the panel slides up. + final bool parallaxEnabled; + + /// Allows for specifying the extent of the parallax effect in terms + /// of the percentage the panel has slid up/down. Recommended values are + /// within 0.0 and 1.0 where 0.0 is no parallax and 1.0 mimics a + /// one-to-one scrolling effect. Defaults to a 10% parallax. + final double parallaxOffset; + + /// Allows toggling of the draggability of the SlidingUpPanel. + /// Set this to false to prevent the user from being able to drag + /// the panel up and down. Defaults to true. + final bool isDraggable; + + /// Either SlideDirection.UP or SlideDirection.DOWN. Indicates which way + /// the panel should slide. Defaults to UP. If set to DOWN, the panel attaches + /// itself to the top of the screen and is fully opened when the user swipes + /// down on the panel. + final SlideDirection slideDirection; + + /// The default state of the panel; either PanelState.OPEN or PanelState.CLOSED. + /// This value defaults to PanelState.CLOSED which indicates that the panel is + /// in the closed position and must be opened. PanelState.OPEN indicates that + /// by default the Panel is open and must be swiped closed by the user. + final PanelState defaultPanelState; + + /// To attach to a [Scrollable] on a panel that + /// links the panel's position to the scroll position. Useful for implementing + /// infinite scroll behavior + final ScrollController? scrollController; + + final BoxDecoration? panelDecoration; + + const SlidingUpPanel( + {Key? key, + this.body, + this.collapsed, + this.minHeight = 100.0, + this.maxHeight = 500.0, + this.snapPoint, + this.padding, + this.margin, + this.panelDecoration, + this.panelSnapping = true, + this.disableDraggableOnScrolling = false, + this.controller, + this.backdropEnabled = false, + this.backdropColor = Colors.black, + this.backdropOpacity = 0.5, + this.backdropTapClosesPanel = true, + this.onPanelSlide, + this.onPanelOpened, + this.onPanelClosed, + this.parallaxEnabled = false, + this.parallaxOffset = 0.1, + this.isDraggable = true, + this.slideDirection = SlideDirection.up, + this.defaultPanelState = PanelState.closed, + this.header, + this.footer, + this.scrollController, + this.panelBuilder}) + : assert(panelBuilder != null), + assert(0 <= backdropOpacity && backdropOpacity <= 1.0), + assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0), + super(key: key); + + @override + SlidingUpPanelState createState() => SlidingUpPanelState(); +} + +class SlidingUpPanelState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late final ScrollController _scrollController; + + bool _scrollingEnabled = false; + final VelocityTracker _velocityTracker = + VelocityTracker.withKind(PointerDeviceKind.touch); + + bool _isPanelVisible = true; + + @override + void initState() { + super.initState(); + + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + value: widget.defaultPanelState == PanelState.closed + ? 0.0 + : 1.0 //set the default panel state (i.e. set initial value of _ac) + ) + ..addListener(() { + if (widget.onPanelSlide != null) { + widget.onPanelSlide!(_animationController.value); + } + + if (widget.onPanelOpened != null && + (_animationController.value == 1.0 || + _animationController.value == 0.0)) { + widget.onPanelOpened!(); + } + }); + + // prevent the panel content from being scrolled only if the widget is + // draggable and panel scrolling is enabled + _scrollController = widget.scrollController ?? ScrollController(); + _scrollController.addListener(() { + if (widget.isDraggable && + !widget.disableDraggableOnScrolling && + (!_scrollingEnabled || _panelPosition < 1) && + widget.controller?._forceScrollChange != true) { + _scrollController.jumpTo(_scMinOffset); + } + }); + + widget.controller?._addState(this); + } + + @override + Widget build(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + + return Stack( + alignment: widget.slideDirection == SlideDirection.up + ? Alignment.bottomCenter + : Alignment.topCenter, + children: [ + //make the back widget take up the entire back side + if (widget.body != null) + AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Positioned( + top: widget.parallaxEnabled ? _getParallax() : 0.0, + child: child ?? const SizedBox(), + ); + }, + child: SizedBox( + height: mediaQuery.size.height, + width: mediaQuery.size.width, + child: widget.body, + ), + ), + + //the backdrop to overlay on the body + if (widget.backdropEnabled) + GestureDetector( + onVerticalDragEnd: widget.backdropTapClosesPanel + ? (DragEndDetails details) { + // only trigger a close if the drag is towards panel close position + if ((widget.slideDirection == SlideDirection.up ? 1 : -1) * + details.velocity.pixelsPerSecond.dy > + 0) _close(); + } + : null, + onTap: widget.backdropTapClosesPanel ? () => _close() : null, + child: AnimatedBuilder( + animation: _animationController, + builder: (context, _) { + return Container( + height: mediaQuery.size.height, + width: mediaQuery.size.width, + + //set color to null so that touch events pass through + //to the body when the panel is closed, otherwise, + //if a color exists, then touch events won't go through + color: _animationController.value == 0.0 + ? null + : widget.backdropColor.withOpacity( + widget.backdropOpacity * _animationController.value, + ), + ); + }), + ), + + //the actual sliding part + if (_isPanelVisible) + _gestureHandler( + child: AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Container( + height: _animationController.value * + (widget.maxHeight - widget.minHeight) + + widget.minHeight, + margin: widget.margin, + padding: widget.padding, + decoration: widget.panelDecoration, + child: child, + ); + }, + child: Stack( + children: [ + //open panel + Positioned( + top: + widget.slideDirection == SlideDirection.up ? 0.0 : null, + bottom: widget.slideDirection == SlideDirection.down + ? 0.0 + : null, + width: mediaQuery.size.width - + (widget.margin != null + ? widget.margin!.horizontal + : 0) - + (widget.padding != null + ? widget.padding!.horizontal + : 0), + child: SizedBox( + height: widget.maxHeight, + child: widget.panelBuilder!( + _animationController.value, + ), + ), + ), + + // footer + if (widget.footer != null) + Positioned( + top: widget.slideDirection == SlideDirection.up + ? null + : 0.0, + bottom: widget.slideDirection == SlideDirection.down + ? null + : 0.0, + child: widget.footer ?? const SizedBox()), + + // header + if (widget.header != null) + Positioned( + top: widget.slideDirection == SlideDirection.up + ? 0.0 + : null, + bottom: widget.slideDirection == SlideDirection.down + ? 0.0 + : null, + child: widget.header ?? const SizedBox(), + ), + + // collapsed panel + Positioned( + top: + widget.slideDirection == SlideDirection.up ? 0.0 : null, + bottom: widget.slideDirection == SlideDirection.down + ? 0.0 + : null, + width: mediaQuery.size.width - + (widget.margin != null + ? widget.margin!.horizontal + : 0) - + (widget.padding != null + ? widget.padding!.horizontal + : 0), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + height: widget.minHeight, + child: widget.collapsed == null + ? null + : FadeTransition( + opacity: Tween(begin: 1.0, end: 0.0) + .animate(_animationController), + + // if the panel is open ignore pointers (touch events) on the collapsed + // child so that way touch events go through to whatever is underneath + child: IgnorePointer( + ignoring: _animationController.value == 1.0, + child: widget.collapsed, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + double _getParallax() { + if (widget.slideDirection == SlideDirection.up) { + return -_animationController.value * + (widget.maxHeight - widget.minHeight) * + widget.parallaxOffset; + } else { + return _animationController.value * + (widget.maxHeight - widget.minHeight) * + widget.parallaxOffset; + } + } + + bool _ignoreScrollable = false; + bool _isHorizontalScrollableWidget = false; + Axis? _scrollableAxis; + + // returns a gesture detector if panel is used + // and a listener if panelBuilder is used. + // this is because the listener is designed only for use with linking the scrolling of + // panels and using it for panels that don't want to linked scrolling yields odd results + Widget _gestureHandler({required Widget child}) { + if (!widget.isDraggable) return child; + + return Listener( + onPointerDown: (PointerDownEvent e) { + var rb = context.findRenderObject() as RenderBox; + var result = BoxHitTestResult(); + rb.hitTest(result, position: e.position); + + if (_panelPosition == 1) { + _scMinOffset = 0.0; + } + // if there any widget in the path that must force graggable, + // stop it right here + if (result.path.any((entry) => + entry.target.runtimeType == ForceDraggableWidgetRenderBox)) { + widget.controller?._nowTargetForceDraggable = true; + _scMinOffset = _scrollController.offset; + _isHorizontalScrollableWidget = false; + } else if (result.path.any((entry) => + entry.target.runtimeType == HorizontalScrollableWidgetRenderBox)) { + _isHorizontalScrollableWidget = true; + widget.controller?._nowTargetForceDraggable = false; + } else if (result.path.any((entry) => + entry.target.runtimeType == IgnoreDraggableWidgetWidgetRenderBox)) { + _ignoreScrollable = true; + widget.controller?._nowTargetForceDraggable = false; + _isHorizontalScrollableWidget = false; + return; + } else { + widget.controller?._nowTargetForceDraggable = false; + _isHorizontalScrollableWidget = false; + } + _ignoreScrollable = false; + _velocityTracker.addPosition(e.timeStamp, e.position); + }, + onPointerMove: (PointerMoveEvent e) { + if (_scrollableAxis == null) { + if (e.delta.dx.abs() > e.delta.dy.abs()) { + _scrollableAxis = Axis.horizontal; + } else { + _scrollableAxis = Axis.vertical; + } + } + + if (_isHorizontalScrollableWidget && + _scrollableAxis == Axis.horizontal) { + return; + } + + if (_ignoreScrollable) return; + _velocityTracker.addPosition( + e.timeStamp, + e.position, + ); // add current position for velocity tracking + _onGestureSlide(e.delta.dy); + }, + onPointerUp: (PointerUpEvent e) { + if (_ignoreScrollable) return; + _scrollableAxis = null; + _onGestureEnd(_velocityTracker.getVelocity()); + }, + child: child, + ); + } + + double _scMinOffset = 0.0; + + // handles the sliding gesture + void _onGestureSlide(double dy) { + // only slide the panel if scrolling is not enabled + if (widget.controller?._nowTargetForceDraggable == false && + widget.disableDraggableOnScrolling) { + return; + } + if ((!_scrollingEnabled) || + _panelPosition < 1 || + widget.controller?._nowTargetForceDraggable == true) { + if (widget.slideDirection == SlideDirection.up) { + _animationController.value -= + dy / (widget.maxHeight - widget.minHeight); + } else { + _animationController.value += + dy / (widget.maxHeight - widget.minHeight); + } + } + + // if the panel is open and the user hasn't scrolled, we need to determine + // whether to enable scrolling if the user swipes up, or disable closing and + // begin to close the panel if the user swipes down + if (_isPanelOpen && + _scrollController.hasClients && + _scrollController.offset <= _scMinOffset) { + setState(() { + if (dy < 0) { + _scrollingEnabled = true; + } else { + _scrollingEnabled = false; + } + }); + } + } + + // handles when user stops sliding + void _onGestureEnd(Velocity v) { + if (widget.controller?._nowTargetForceDraggable == false && + widget.disableDraggableOnScrolling) { + return; + } + double minFlingVelocity = 365.0; + double kSnap = 8; + + //let the current animation finish before starting a new one + if (_animationController.isAnimating) return; + + // if scrolling is allowed and the panel is open, we don't want to close + // the panel if they swipe up on the scrollable + if (_isPanelOpen && _scrollingEnabled) return; + + //check if the velocity is sufficient to constitute fling to end + double visualVelocity = + -v.pixelsPerSecond.dy / (widget.maxHeight - widget.minHeight); + + // reverse visual velocity to account for slide direction + if (widget.slideDirection == SlideDirection.down) { + visualVelocity = -visualVelocity; + } + + // get minimum distances to figure out where the panel is at + double d2Close = _animationController.value; + double d2Open = 1 - _animationController.value; + double d2Snap = ((widget.snapPoint ?? 3) - _animationController.value) + .abs(); // large value if null results in not every being the min + double minDistance = min(d2Close, min(d2Snap, d2Open)); + + // check if velocity is sufficient for a fling + if (v.pixelsPerSecond.dy.abs() >= minFlingVelocity) { + // snapPoint exists + if (widget.panelSnapping && widget.snapPoint != null) { + if (v.pixelsPerSecond.dy.abs() >= kSnap * minFlingVelocity || + minDistance == d2Snap) { + _animationController.fling(velocity: visualVelocity); + } else { + _flingPanelToPosition(widget.snapPoint!, visualVelocity); + } + + // no snap point exists + } else if (widget.panelSnapping) { + _animationController.fling(velocity: visualVelocity); + + // panel snapping disabled + } else { + _animationController.animateTo( + _animationController.value + visualVelocity * 0.16, + duration: const Duration(milliseconds: 410), + curve: Curves.decelerate, + ); + } + + return; + } + + // check if the controller is already halfway there + if (widget.panelSnapping) { + if (minDistance == d2Close) { + _close(); + } else if (minDistance == d2Snap) { + _flingPanelToPosition(widget.snapPoint!, visualVelocity); + } else { + _open(); + } + } + } + + void _flingPanelToPosition(double targetPos, double velocity) { + final Simulation simulation = SpringSimulation( + SpringDescription.withDampingRatio( + mass: 1.0, + stiffness: 500.0, + ratio: 1.0, + ), + _animationController.value, + targetPos, + velocity); + + _animationController.animateWith(simulation); + } + + //--------------------------------- + //PanelController related functions + //--------------------------------- + + //close the panel + Future _close() { + return _animationController.fling(velocity: -1.0); + } + + //open the panel + Future _open() { + return _animationController.fling(velocity: 1.0); + } + + //hide the panel (completely offscreen) + Future _hide() { + return _animationController.fling(velocity: -1.0).then((x) { + setState(() { + _isPanelVisible = false; + }); + }); + } + + //show the panel (in collapsed mode) + Future _show() { + return _animationController.fling(velocity: -1.0).then((x) { + setState(() { + _isPanelVisible = true; + }); + }); + } + + //animate the panel position to value - must + //be between 0.0 and 1.0 + Future _animatePanelToPosition(double value, + {Duration? duration, Curve curve = Curves.linear}) { + assert(0.0 <= value && value <= 1.0); + return _animationController.animateTo(value, + duration: duration, curve: curve); + } + + //animate the panel position to the snap point + //REQUIRES that widget.snapPoint != null + Future _animatePanelToSnapPoint( + {Duration? duration, Curve curve = Curves.linear}) { + assert(widget.snapPoint != null); + return _animationController.animateTo(widget.snapPoint!, + duration: duration, curve: curve); + } + + //set the panel position to value - must + //be between 0.0 and 1.0 + set _panelPosition(double value) { + assert(0.0 <= value && value <= 1.0); + _animationController.value = value; + } + + //get the current panel position + //returns the % offset from collapsed state + //as a decimal between 0.0 and 1.0 + double get _panelPosition => _animationController.value; + + //returns whether or not + //the panel is still animating + bool get _isPanelAnimating => _animationController.isAnimating; + + //returns whether or not the + //panel is open + bool get _isPanelOpen => _animationController.value == 1.0; + + //returns whether or not the + //panel is closed + bool get _isPanelClosed => _animationController.value == 0.0; + + //returns whether or not the + //panel is shown/hidden + bool get _isPanelShown => _isPanelVisible; +} diff --git a/lib/components/shared/track_table/track_collection_view/track_collection_heading.dart b/lib/components/shared/track_table/track_collection_view/track_collection_heading.dart index a8a60109..6436f7cd 100644 --- a/lib/components/shared/track_table/track_collection_view/track_collection_heading.dart +++ b/lib/components/shared/track_table/track_collection_view/track_collection_heading.dart @@ -121,7 +121,7 @@ class TrackCollectionHeading extends HookConsumerWidget { ), if (album != null) Text( - "${AlbumType.from(album?.albumType).formatted} • ${context.l10n.released} • ${DateTime.tryParse( + "${album?.albumType?.formatted} • ${context.l10n.released} • ${DateTime.tryParse( album?.releaseDate ?? "", )?.year}", style: theme.textTheme.titleMedium!.copyWith( diff --git a/lib/components/shared/track_table/track_collection_view/track_collection_view.dart b/lib/components/shared/track_table/track_collection_view/track_collection_view.dart index 14d9598f..dcf01dd2 100644 --- a/lib/components/shared/track_table/track_collection_view/track_collection_view.dart +++ b/lib/components/shared/track_table/track_collection_view/track_collection_view.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/playlist/playlist_create_dialog.dart'; +import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart'; @@ -139,131 +140,134 @@ class TrackCollectionView extends HookConsumerWidget { onRefresh: () async { await tracksSnapshot.refresh(); }, - child: CustomScrollView( + child: InterScrollbar( controller: controller, - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - SliverAppBar( - actions: [ - AnimatedScale( - duration: const Duration(milliseconds: 200), - scale: collapsed.value ? 1 : 0, - child: Row( - mainAxisSize: MainAxisSize.min, - children: buttons, - ), - ), - AnimatedScale( - duration: const Duration(milliseconds: 200), - scale: collapsed.value ? 1 : 0, - child: IconButton( - tooltip: context.l10n.shuffle, - icon: const Icon(SpotubeIcons.shuffle), - onPressed: playingState == PlayButtonState.playing - ? null - : onShuffledPlay, - ), - ), - AnimatedScale( - duration: const Duration(milliseconds: 200), - scale: collapsed.value ? 1 : 0, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - shape: const CircleBorder(), - backgroundColor: theme.colorScheme.inversePrimary, + child: CustomScrollView( + controller: controller, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverAppBar( + actions: [ + AnimatedScale( + duration: const Duration(milliseconds: 200), + scale: collapsed.value ? 1 : 0, + child: Row( + mainAxisSize: MainAxisSize.min, + children: buttons, ), - onPressed: tracksSnapshot.data != null ? onPlay : null, - child: switch (playingState) { - PlayButtonState.playing => - const Icon(SpotubeIcons.pause), - PlayButtonState.notPlaying => - const Icon(SpotubeIcons.play), - PlayButtonState.loading => const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: .7, - ), - ), - }, ), - ), - ], - floating: false, - pinned: true, - expandedHeight: 400, - automaticallyImplyLeading: kIsMobile, - leading: - kIsMobile ? const BackButton(color: Colors.white) : null, - iconTheme: IconThemeData(color: color?.titleTextColor), - primary: true, - backgroundColor: color?.color.withOpacity(.8), - title: collapsed.value - ? Text( - title, - style: theme.textTheme.titleMedium!.copyWith( - color: color?.titleTextColor, - fontWeight: FontWeight.w600, + AnimatedScale( + duration: const Duration(milliseconds: 200), + scale: collapsed.value ? 1 : 0, + child: IconButton( + tooltip: context.l10n.shuffle, + icon: const Icon(SpotubeIcons.shuffle), + onPressed: playingState == PlayButtonState.playing + ? null + : onShuffledPlay, + ), + ), + AnimatedScale( + duration: const Duration(milliseconds: 200), + scale: collapsed.value ? 1 : 0, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + shape: const CircleBorder(), + backgroundColor: theme.colorScheme.inversePrimary, ), - ) - : null, - centerTitle: true, - flexibleSpace: FlexibleSpaceBar( - background: TrackCollectionHeading( - color: color, - title: title, - description: description, - titleImage: titleImage, - playingState: playingState, - onPlay: onPlay, - onShuffledPlay: onShuffledPlay, - tracksSnapshot: tracksSnapshot, - buttons: buttons, - album: album, + onPressed: tracksSnapshot.data != null ? onPlay : null, + child: switch (playingState) { + PlayButtonState.playing => + const Icon(SpotubeIcons.pause), + PlayButtonState.notPlaying => + const Icon(SpotubeIcons.play), + PlayButtonState.loading => const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: .7, + ), + ), + }, + ), + ), + ], + floating: false, + pinned: true, + expandedHeight: 400, + automaticallyImplyLeading: kIsMobile, + leading: + kIsMobile ? const BackButton(color: Colors.white) : null, + iconTheme: IconThemeData(color: color?.titleTextColor), + primary: true, + backgroundColor: color?.color.withOpacity(.8), + title: collapsed.value + ? Text( + title, + style: theme.textTheme.titleMedium!.copyWith( + color: color?.titleTextColor, + fontWeight: FontWeight.w600, + ), + ) + : null, + centerTitle: true, + flexibleSpace: FlexibleSpaceBar( + background: TrackCollectionHeading( + color: color, + title: title, + description: description, + titleImage: titleImage, + playingState: playingState, + onPlay: onPlay, + onShuffledPlay: onShuffledPlay, + tracksSnapshot: tracksSnapshot, + buttons: buttons, + album: album, + ), ), ), - ), - HookBuilder( - builder: (context) { - if (tracksSnapshot.isLoading || !tracksSnapshot.hasData) { - return const ShimmerTrackTile(); - } else if (tracksSnapshot.hasError) { - return SliverToBoxAdapter( - child: Text( - context.l10n.error(tracksSnapshot.error ?? ""), - ), - ); - } - - return TracksTableView( - (tracksSnapshot.data ?? []).map( - (track) { - if (track is Track) { - return track; - } else { - return TypeConversionUtils.simpleTrack_X_Track( - track, - album!, - ); - } - }, - ).toList(), - onTrackPlayButtonPressed: onPlay, - playlistId: id, - userPlaylist: isOwned, - onFiltering: () { - // scroll the flexible space - // to allow more space for search results - controller.animateTo( - 330, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, + HookBuilder( + builder: (context) { + if (tracksSnapshot.isLoading || !tracksSnapshot.hasData) { + return const ShimmerTrackTile(); + } else if (tracksSnapshot.hasError) { + return SliverToBoxAdapter( + child: Text( + context.l10n.error(tracksSnapshot.error ?? ""), + ), ); - }, - ); - }, - ) - ], + } + + return TracksTableView( + (tracksSnapshot.data ?? []).map( + (track) { + if (track is Track) { + return track; + } else { + return TypeConversionUtils.simpleTrack_X_Track( + track, + album!, + ); + } + }, + ).toList(), + onTrackPlayButtonPressed: onPlay, + playlistId: id, + userPlaylist: isOwned, + onFiltering: () { + // scroll the flexible space + // to allow more space for search results + controller.animateTo( + 330, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + }, + ); + }, + ) + ], + ), ), )); } diff --git a/lib/components/shared/track_table/tracks_table_view.dart b/lib/components/shared/track_table/tracks_table_view.dart index 2ad6d384..d03e92d7 100644 --- a/lib/components/shared/track_table/tracks_table_view.dart +++ b/lib/components/shared/track_table/tracks_table_view.dart @@ -71,6 +71,8 @@ class TracksTableView extends HookConsumerWidget { final searchController = useTextEditingController(); final searchFocus = useFocusNode(); + final controller = useScrollController(); + // this will trigger update on each change in searchController useValueListenable(searchController); @@ -210,14 +212,16 @@ class TracksTableView extends HookConsumerWidget { } case "add-to-playlist": { - await showDialog( - context: context, - builder: (context) { - return PlaylistAddTrackDialog( - tracks: selectedTracks.toList(), - ); - }, - ); + if (context.mounted) { + await showDialog( + context: context, + builder: (context) { + return PlaylistAddTrackDialog( + tracks: selectedTracks.toList(), + ); + }, + ); + } break; } case "play-next": @@ -348,11 +352,16 @@ class TracksTableView extends HookConsumerWidget { if (isSliver) { return SliverSafeArea( top: false, - sliver: SliverList(delegate: SliverChildListDelegate(children)), + sliver: SliverList( + delegate: SliverChildListDelegate(children), + ), ); } return SafeArea( - child: ListView(children: children), + child: ListView( + controller: controller, + children: children, + ), ); } } diff --git a/lib/extensions/album_simple.dart b/lib/extensions/album_simple.dart index a717bf88..00db4dca 100644 --- a/lib/extensions/album_simple.dart +++ b/lib/extensions/album_simple.dart @@ -3,7 +3,7 @@ import 'package:spotify/spotify.dart'; extension AlbumJson on AlbumSimple { Map toJson() { return { - "albumType": albumType, + "albumType": albumType?.name, "id": id, "name": name, "images": images diff --git a/lib/extensions/duration.dart b/lib/extensions/duration.dart index c8612425..ff670b1a 100644 --- a/lib/extensions/duration.dart +++ b/lib/extensions/duration.dart @@ -37,3 +37,13 @@ extension DurationToHumanReadableString on Duration { abbreviated: abbreviated, ); } + +extension ParseDuration on Duration { + static Duration fromString(String duration) { + final parts = duration.split(':').reversed.toList(); + final seconds = int.parse(parts[0]); + final minutes = parts.length > 1 ? int.parse(parts[1]) : 0; + final hours = parts.length > 2 ? int.parse(parts[2]) : 0; + return Duration(hours: hours, minutes: minutes, seconds: seconds); + } +} diff --git a/lib/extensions/list.dart b/lib/extensions/list.dart index c9d502b0..6ecf6cf6 100644 --- a/lib/extensions/list.dart +++ b/lib/extensions/list.dart @@ -1,4 +1,7 @@ import 'package:collection/collection.dart'; +import 'package:spotube/models/logger.dart'; + +final logger = getLogger("List"); extension MultiSortListMap on List { /// [preference] - List of properties in which you want to sort the list @@ -18,7 +21,7 @@ extension MultiSortListMap on List { return this; } if (preference.length != criteria.length) { - print('Criteria length is not equal to preference'); + logger.d('Criteria length is not equal to preference'); return this; } @@ -66,7 +69,7 @@ extension MultiSortListTupleMap on List<(Map, V)> { return this; } if (preference.length != criteria.length) { - print('Criteria length is not equal to preference'); + logger.d('Criteria length is not equal to preference'); return this; } diff --git a/lib/extensions/track.dart b/lib/extensions/track.dart index 19935e89..e17e851e 100644 --- a/lib/extensions/track.dart +++ b/lib/extensions/track.dart @@ -7,7 +7,7 @@ extension TrackJson on Track { return { "album": album?.toJson(), "artists": artists?.map((artist) => artist.toJson()).toList(), - "availableMarkets": availableMarkets, + "availableMarkets": availableMarkets?.map((e) => e.name).toList(), "discNumber": discNumber, "duration": duration.toString(), "durationMs": durationMs, diff --git a/lib/hooks/use_custom_status_bar_color.dart b/lib/hooks/use_custom_status_bar_color.dart index 92f845cf..d1266fe2 100644 --- a/lib/hooks/use_custom_status_bar_color.dart +++ b/lib/hooks/use_custom_status_bar_color.dart @@ -6,6 +6,7 @@ void useCustomStatusBarColor( Color color, bool isCurrentRoute, { bool noSetBGColor = false, + bool? automaticSystemUiAdjustment, }) { final context = useContext(); final backgroundColor = Theme.of(context).scaffoldBackgroundColor; @@ -21,20 +22,30 @@ void useCustomStatusBarColor( final statusBarColor = SystemChrome.latestStyle?.statusBarColor; useEffect(() { - if (isCurrentRoute && statusBarColor != color) { - SystemChrome.setSystemUIOverlayStyle( - SystemUiOverlayStyle( - statusBarColor: - noSetBGColor ? Colors.transparent : color, // status bar color - statusBarIconBrightness: color.computeLuminance() > 0.179 - ? Brightness.dark - : Brightness.light, - ), - ); - } else if (!isCurrentRoute && statusBarColor == color) { - resetStatusbar(); - } - return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (automaticSystemUiAdjustment != null) { + WidgetsBinding.instance.renderView.automaticSystemUiAdjustment = + automaticSystemUiAdjustment; + } + if (isCurrentRoute && statusBarColor != color) { + final isLight = color.computeLuminance() > 0.179; + SystemChrome.setSystemUIOverlayStyle( + SystemUiOverlayStyle( + statusBarColor: + noSetBGColor ? Colors.transparent : color, // status bar color + statusBarIconBrightness: + isLight ? Brightness.dark : Brightness.light, + ), + ); + } else if (!isCurrentRoute && statusBarColor == color) { + resetStatusbar(); + } + }); + return () { + if (automaticSystemUiAdjustment != null) { + WidgetsBinding.instance.renderView.automaticSystemUiAdjustment = false; + } + }; }, [color, isCurrentRoute, statusBarColor]); useEffect(() { diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index e0765c9f..f587710c 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -138,7 +138,6 @@ "skip_non_music": "تخطي المقاطع غير الموسيقية (SponsorBlock)", "blacklist_description": "المقطوعات والفنانون المدرجون في القائمة السوداء", "wait_for_download_to_finish": "يرجى الانتظار حتى انتهاء التنزيل الحالي", - "download_lyrics": "تحميل الكلمات مع المقطوعات", "desktop": "سطح المكتب", "close_behavior": "إغلاق التصرف", "close": "إغلاق", @@ -227,7 +226,7 @@ "selected_count_tracks": "مقطوعات {count} مختارة", "download_warning": "إذا قمت بتنزيل جميع المقاطع الصوتية بكميات كبيرة، فمن الواضح أنك تقوم بقرصنة الموسيقى وتسبب الضرر للمجتمع الإبداعي للموسيقى. أتمنى أن تكون على علم بهذا. حاول دائمًا احترام ودعم العمل الجاد للفنان", "download_ip_ban_warning": "بالمناسبة، يمكن أن يتم حظر عنوان IP الخاص بك على YouTube بسبب طلبات التنزيل الزائدة عن المعتاد. يعني حظر IP أنه لا يمكنك استخدام YouTube (حتى إذا قمت بتسجيل الدخول) لمدة تتراوح بين شهرين إلى ثلاثة أشهر على الأقل من جهاز IP هذا. ولا يتحمل Spotube أي مسؤولية إذا حدث هذا على الإطلاق", - "by_clicking_accept_terms": "بالنقر على "قبول"، فإنك توافق على الشروط التالية:", + "by_clicking_accept_terms": "بالنقر على \"قبول\"، فإنك توافق على الشروط التالية:", "download_agreement_1": "أعلم أنني أقوم بقرصنة الموسيقى. انا سيئ", "download_agreement_2": "سأدعم الفنان أينما أستطيع، وأنا أفعل هذا فقط لأنني لا أملك المال لشراء أعمالهم الفنية", "download_agreement_3": "أدرك تمامًا أنه يمكن حظر عنوان IP الخاص بي على YouTube ولا أحمل Spotube أو مالكيه/مساهميه المسؤولية عن أي حوادث ناجمة عن الإجراء الحالي الخاص بي", @@ -263,5 +262,22 @@ "connection_restored": "تمت استعادة اتصالك بالإنترنت", "use_system_title_bar": "استخدم شريط عنوان النظام", "crunching_results": "تدمير النتائج", - "search_to_get_results": "إبحث للحصول على النتائج" -} + "search_to_get_results": "إبحث للحصول على النتائج", + "use_amoled_mode": "استخدم وضع AMOLED", + "pitch_dark_theme": "موضوع دارت الأسود الفحمي", + "normalize_audio": "تطبيع الصوت", + "change_cover": "تغيير الغلاف", + "add_cover": "إضافة غلاف", + "restore_defaults": "استعادة الإعدادات الافتراضية", + "download_music_codec": "تنزيل ترميز الموسيقى", + "streaming_music_codec": "ترميز الموسيقى بالتدفق", + "login_with_lastfm": "تسجيل الدخول باستخدام Last.fm", + "connect": "اتصال", + "disconnect_lastfm": "قطع الاتصال بـ Last.fm", + "disconnect": "قطع الاتصال", + "username": "اسم المستخدم", + "password": "كلمة المرور", + "login": "تسجيل الدخول", + "login_with_your_lastfm": "تسجيل الدخول باستخدام حساب Last.fm الخاص بك", + "scrobble_to_lastfm": "تسجيل الاستماع على Last.fm" +} \ No newline at end of file diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index df83f11f..02402179 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -136,7 +136,6 @@ "skip_non_music": "গানের নন-মিউজিক সেগমেন্ট এড়িয়ে যান (SponsorBlock)", "blacklist_description": "কালো তালিকাভুক্ত গানের ট্র্যাক এবং শিল্পী", "wait_for_download_to_finish": "ডাউনলোড শেষ হওয়ার জন্য অপেক্ষা করুন", - "download_lyrics": "গানের সাথে লিরিক্স ডাউনলোড করুন", "desktop": "ডেস্কটপ", "close_behavior": "বন্ধ করার প্রক্রিয়া", "close": "বন্ধ করুন", @@ -263,5 +262,22 @@ "update_playlist": "প্লেলিস্ট আপডেট করুন", "update": "আপডেট", "crunching_results": "ফলাফল বিশ্লেষণ করা হচ্ছে...", - "search_to_get_results": "ফলাফল পেতে খোঁজ করুন" + "search_to_get_results": "ফলাফল পেতে খোঁজ করুন", + "use_amoled_mode": "AMOLED মোড ব্যবহার করুন", + "pitch_dark_theme": "পিচ ব্ল্যাক ডার্ট থিম", + "normalize_audio": "অডিও স্তরমান করুন", + "change_cover": "কভার পরিবর্তন করুন", + "add_cover": "কভার যোগ করুন", + "restore_defaults": "ডিফল্ট সেটিংস পুনরুদ্ধার করুন", + "download_music_codec": "সঙ্গীত কোডেক ডাউনলোড করুন", + "streaming_music_codec": "স্ট্রিমিং সঙ্গীত কোডেক", + "login_with_lastfm": "Last.fm দিয়ে লগইন করুন", + "connect": "সংযোগ করুন", + "disconnect_lastfm": "Last.fm সংযোগ বিচ্ছিন্ন করুন", + "disconnect": "সংযোগ বিচ্ছিন্ন করুন", + "username": "ব্যবহারকারীর নাম", + "password": "পাসওয়ার্ড", + "login": "লগইন", + "login_with_your_lastfm": "আপনার Last.fm অ্যাকাউন্ট দিয়ে লগইন করুন", + "scrobble_to_lastfm": "Last.fm এ স্ক্রবল করুন" } \ No newline at end of file diff --git a/lib/l10n/app_ca.arb b/lib/l10n/app_ca.arb index ab7d0817..81a11082 100644 --- a/lib/l10n/app_ca.arb +++ b/lib/l10n/app_ca.arb @@ -136,7 +136,6 @@ "skip_non_music": "Ometre segments que no son música (SponsorBlock)", "blacklist_description": "Cançons i artistes de la llista negra", "wait_for_download_to_finish": "Si us plau, esperi que acabi la descàrrega actual", - "download_lyrics": "Descarregar lletres amb les cançons", "desktop": "Escriptori", "close_behavior": "Comportament al tancar", "close": "Tancar", @@ -263,5 +262,22 @@ "update_playlist": "Actualitzar la llista de reproducció", "update": "Actualitzar", "crunching_results": "Processant resultats...", - "search_to_get_results": "Cerca per obtenir resultats" + "search_to_get_results": "Cerca per obtenir resultats", + "use_amoled_mode": "Utilitza el mode AMOLED", + "pitch_dark_theme": "Tema de dart negre intens", + "normalize_audio": "Normalitza l'àudio", + "change_cover": "Canvia la coberta", + "add_cover": "Afegeix una coberta", + "restore_defaults": "Restaura els valors per defecte", + "download_music_codec": "Descarrega el codec de música", + "streaming_music_codec": "Codec de música en streaming", + "login_with_lastfm": "Inicia la sessió amb Last.fm", + "connect": "Connecta", + "disconnect_lastfm": "Desconnecta de Last.fm", + "disconnect": "Desconnecta", + "username": "Nom d'usuari", + "password": "Contrasenya", + "login": "Inicia la sessió", + "login_with_your_lastfm": "Inicia la sessió amb el teu compte de Last.fm", + "scrobble_to_lastfm": "Scrobble a Last.fm" } \ No newline at end of file diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index ef2e78a2..339a8d65 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -136,7 +136,6 @@ "skip_non_music": "Überspringe Nicht-Musik-Segmente (SponsorBlock)", "blacklist_description": "Gesperrte Titel und Künstler", "wait_for_download_to_finish": "Bitte warten Sie, bis der aktuelle Download abgeschlossen ist", - "download_lyrics": "Songtexte zusammen mit den Tracks herunterladen", "desktop": "Desktop", "close_behavior": "Verhalten beim Schließen", "close": "Schließen", @@ -263,5 +262,22 @@ "update_playlist": "Wiedergabeliste aktualisieren", "update": "Aktualisieren", "crunching_results": "Ergebnisse werden verarbeitet...", - "search_to_get_results": "Suche, um Ergebnisse zu erhalten" + "search_to_get_results": "Suche, um Ergebnisse zu erhalten", + "use_amoled_mode": "AMOLED-Modus verwenden", + "pitch_dark_theme": "Pitch Black Dart Theme", + "normalize_audio": "Audio normalisieren", + "change_cover": "Cover ändern", + "add_cover": "Cover hinzufügen", + "restore_defaults": "Standardeinstellungen wiederherstellen", + "download_music_codec": "Musik-Codec herunterladen", + "streaming_music_codec": "Streaming-Musik-Codec", + "login_with_lastfm": "Mit Last.fm anmelden", + "connect": "Verbinden", + "disconnect_lastfm": "Last.fm trennen", + "disconnect": "Trennen", + "username": "Benutzername", + "password": "Passwort", + "login": "Anmelden", + "login_with_your_lastfm": "Mit Ihrem Last.fm-Konto anmelden", + "scrobble_to_lastfm": "Auf Last.fm scrobbeln" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fa81450b..9d5be6bb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -138,7 +138,6 @@ "skip_non_music": "Skip non-music segments (SponsorBlock)", "blacklist_description": "Blacklisted tracks and artists", "wait_for_download_to_finish": "Please wait for the current download to finish", - "download_lyrics": "Download lyrics along with tracks", "desktop": "Desktop", "close_behavior": "Close Behavior", "close": "Close", @@ -263,5 +262,22 @@ "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" + "search_to_get_results": "Search to get results", + "use_amoled_mode": "Use AMOLED mode", + "pitch_dark_theme": "Pitch black dart theme", + "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" } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index f6918bc8..f617705e 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -136,7 +136,6 @@ "skip_non_music": "Omitir segmentos que no son música (SponsorBlock)", "blacklist_description": "Canciones y artistas en la lista negra", "wait_for_download_to_finish": "Por favor, espera a que termine la descarga actual", - "download_lyrics": "Descargar letras junto con las canciones", "desktop": "Escritorio", "close_behavior": "Comportamiento al cerrar", "close": "Cerrar", @@ -263,5 +262,22 @@ "update_playlist": "Actualizar lista de reproducción", "update": "Actualizar", "crunching_results": "Procesando resultados...", - "search_to_get_results": "Buscar para obtener resultados" + "search_to_get_results": "Buscar para obtener resultados", + "use_amoled_mode": "Usar modo AMOLED", + "pitch_dark_theme": "Tema oscuro de dart", + "normalize_audio": "Normalizar audio", + "change_cover": "Cambiar portada", + "add_cover": "Agregar portada", + "restore_defaults": "Restaurar valores predeterminados", + "download_music_codec": "Descargar códec de música", + "streaming_music_codec": "Códec de música en streaming", + "login_with_lastfm": "Iniciar sesión con Last.fm", + "connect": "Conectar", + "disconnect_lastfm": "Desconectar de Last.fm", + "disconnect": "Desconectar", + "username": "Nombre de usuario", + "password": "Contraseña", + "login": "Iniciar sesión", + "login_with_your_lastfm": "Iniciar sesión con tu cuenta de Last.fm", + "scrobble_to_lastfm": "Scrobble a Last.fm" } \ No newline at end of file diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb new file mode 100644 index 00000000..5454b13b --- /dev/null +++ b/lib/l10n/app_fa.arb @@ -0,0 +1,283 @@ +{ + "guest": "مهمان", + "browse": "مرور", + "search": "جستجو", + "library": "مجموعه", + "lyrics": "متن", + "settings": "تنظیمات", + "genre_categories_filter": "دسته ها یا ژانر ها را فیلتر کنید", + "genre": "ژانر", + "personalized": " شخصی سازی شده", + "featured": "ویژه", + "new_releases": "آخرین انتشارات", + "songs": "آهنگ ها", + "playing_track": "درحال پخش {track}", + "queue_clear_alert": "با این کار صف فعلی پاک می شود. {track_length} آهنگ از صف حذف میشود\n؟آیا ادامه میدهید", + "load_more": "بارگذاری بیشتر", + "playlists": "لیست های پخش", + "artists": "هنرمندان", + "albums": "آلبوم ها", + "tracks": "آهنگ ها", + "downloads": "بارگیری شده ها", + "filter_playlists": "لیست پخش خود را فیلتر کنید...", + "liked_tracks": "آهنگ های مورد علاقه", + "liked_tracks_description": "همه آهنگ های دوست داشتنی شما", + "create_playlist": "ساخت لیست پخش", + "create_a_playlist": "ساخت لیست پخش", + "update_playlist": "بروز کردن لیست پخش", + "create": "ساختن", + "cancel": "لغو", + "update": "بروز رسانی", + "playlist_name": "نام لیست پخش", + "name_of_playlist": "نام لیست پخش", + "description": "توضیحات", + "public": "عمومی", + "collaborative": "مبتنی بر همکاری", + "search_local_tracks": "جستجوی آهنگ های محلی...", + "play": "پخش", + "delete": "حذف", + "none": "هیچ کدام", + "sort_a_z": "مرتب سازی بر اساس حروف الفبا", + "sort_z_a": "مرتب سازی برعکس حروف الفبا", + "sort_artist": "مرتب سازی بر اساس هنرمند", + "sort_album": "مرتب سازی بر اساس آلبوم", + "sort_tracks": "مرتب سازی آهنگ ها", + "currently_downloading": "در حال بارگیری ({tracks_length})", + "cancel_all": "لغو همه", + "filter_artist": "فیلتر کردن هنرمند...", + "followers": "{followers} دنبال کننده", + "add_artist_to_blacklist": "اضافه کردن هنرمند به لیست سیاه", + "top_tracks": "بهترین آهنگ ها", + "fans_also_like": "طرفداران هم دوست داشتند", + "loading": "بارگزاری...", + "artist": "هنرمند", + "blacklisted": "در لیست سیاه قرار گرفته است", + "following": "دنبال کننده", + "follow": "دنبال کردن", + "artist_url_copied": "لینک هنرمند در کلیپ بورد کپی شد", + "added_to_queue": "تعداد {tracks} آهنگ به صف اضافه شد", + "filter_albums": "فیلتر کردن آلبوم...", + "synced": "همگام سازی شد", + "plain": "ساده", + "shuffle": "تصادفی", + "search_tracks": "جستجوی آهنگ ها...", + "released": "منتشر شده", + "error": "خطا {error}", + "title": "عنوان", + "time": "زمان", + "more_actions": "اقدامات بیشتر", + "download_count": "دانلود ({count})", + "add_count_to_playlist": "اضافه کردن ({count}) به لیست پخش", + "add_count_to_queue": "اضافه کردن ({count}) به صف", + "play_count_next": "پخش ({count}) بعدی", + "album": "آلبوم", + "copied_to_clipboard": "{data} در کلیپ بورد کپی شد", + "add_to_following_playlists": "اضافه کردن {track} به لیست پخش زیر", + "add": "اضافه کردن", + "added_track_to_queue": "{track} به لیست پخش اضافه شد", + "add_to_queue": "اضافه کردن به صف", + "track_will_play_next": "{track} پخش خواهد شد", + "play_next": "پخش آهنگ بعدی", + "removed_track_from_queue": "{track} از لیست پخش حذف شد", + "remove_from_queue": "از لیست پخش حذف شد", + "remove_from_favorites": "از علاقمندی ها حدف شد", + "save_as_favorite": "ذخیره به عنوان علاقمندی ها", + "add_to_playlist": "به لیست پخش اضافه کردن", + "remove_from_playlist": "از لیست پخش حذف کردن", + "add_to_blacklist": "به لیست سیاه اضافه کردن", + "remove_from_blacklist": "از لیست سیاه حذف کردن", + "share": "اشتراک گذاری", + "mini_player": "پخش کننده ", + "slide_to_seek": "برای جستجو عقب یا جلو بکشید", + "shuffle_playlist": "پخش تصادفی", + "unshuffle_playlist": "خاموش کردن پخش تصادفی", + "previous_track": "آهنگ قبلی", + "next_track": "آهنگ بعدی", + "pause_playback": "توقف آهنگ", + "resume_playback": "ادامه آهنگ", + "loop_track": "تکرار آهنگ", + "repeat_playlist": "تکرار لیست پخش", + "queue": "صف", + "alternative_track_sources": " منبع آهنگ را جاگزین کردن ", + "download_track": "بارگیری آهنگ", + "tracks_in_queue": "{tracks} آهنگ در صف", + "clear_all": "همه را حدف کن", + "show_hide_ui_on_hover": "نمایش/پنهان رابط کاربری در حالت شناور", + "always_on_top": "همیشه روشن", + "exit_mini_player": "از پخش کننده خارج شوید", + "download_location": "محل بارگیری", + "account": "حساب کاربری", + "login_with_spotify": "با حساب اسپوتیفای خود وارد شوید", + "connect_with_spotify": "متصل شدن به اسپوتیفای", + "logout": "خارج شدن", + "logout_of_this_account": "از حساب کاربری خارج شوید", + "language_region": "زبان و منطقه ", + "language": "زبان ", + "system_default": "پیش فرض سیستم", + "market_place_region": "منطقه", + "recommendation_country": "کشور های پیشنهادی", + "appearance": "ظاهر", + "layout_mode": "حالت چیدمان", + "override_layout_settings": "تنطیمات حالت واکنشگرای چیدمان را لغو کن", + "adaptive": "قابل تطبیق", + "compact": "فشرده", + "extended": "گسترده", + "theme": "تم", + "dark": "تاریک", + "light": "روشن", + "system": "سیستم", + "accent_color": "رنگ تاکیدی", + "sync_album_color": "هنگام سازی رنگ البوم", + "sync_album_color_description": "از رنگ البوم هنرمند به عنوان رنگ تاکیدی استفاده میکند", + "playback": "پخش", + "audio_quality": "کیفیت صدا", + "high": "زیاد", + "low": "کم", + "pre_download_play": "دانلود و پخش کنید", + "pre_download_play_description": "به جای پخش جریانی صدا، بایت ها را دانلود کنید و به جای آن پخش کنید (برای کاربران با پهنای باند بالاتر توصیه می شود)", + "skip_non_music": "رد شدن از پخش های غیر موسیقی (SponsorBlock)", + "blacklist_description": "آهنگ ها و هنرمند های در لیست سیاه", + "wait_for_download_to_finish": "لطفا صبر کنید تا دانلود آهنگ جاری تمام شود", + "desktop": "میز کار", + "close_behavior": "رفتار نزدیک", + "close": "بستن", + "minimize_to_tray": "پتجره را کوچک کنید", + "show_tray_icon": "نماد را نمایش بده", + "about": "درباره", + "u_love_spotube": "دوست داریدSpotubeما میدانیم شما ", + "check_for_updates": "بروزرسانی را بررسی کنید", + "about_spotube": "Spotube درباره", + "blacklist": "لیست سیاه", + "please_sponsor": "لطفا کمک/حمایت کنید", + "spotube_description": "یک برنامه سبک و مولتی پلتفرم و رایگان برای همه استSpotube", + "version": "نسخه", + "build_number": "شماره ساخت", + "founder": "بنیانگذار", + "repository": "مخزن", + "bug_issues": "اشکال+مسایل", + "made_with": "🇧🇩ساخته شده با ❤️ در بنگلادش", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "مجوز", + "add_spotify_credentials": "برای شروع اعتبار اسپوتیفای خود را اضافه کنید", + "credentials_will_not_be_shared_disclaimer": "نگران نباشید هیچ کدوما از اعتبارات شما جمع اوری نمیشود یا با کسی اشتراک گزاشته نمیشود", + "know_how_to_login": "نمیدانی چگونه این کار را انجام بدهی؟", + "follow_step_by_step_guide": "راهنما را گام به گام دنبال کنید", + "spotify_cookie": "Spotify {name} کوکی", + "cookie_name_cookie": "{name} کوکی", + "fill_in_all_fields": "لطفا تمام فلید ها را پر کنید", + "submit": "ثبت", + "exit": "خروج", + "previous": "قبلی", + "next": "بعدی ", + "done": "اتمام", + "step_1": "گام 1", + "first_go_to": "اول برو داخل ", + "login_if_not_logged_in": "و اگر وارد نشده اید، وارد/ثبت نام کنید", + "step_2": "گام 2", + "step_2_steps": "1. پس از ورود به سیستم، F12 یا کلیک راست ماوس > Inspect را فشار دهید تا ابزارهای توسعه مرورگر باز شود..\n2. سپس به تب \"Application\" (Chrome, Edge, Brave etc..) یا \"Storage\" Tab (Firefox, Palemoon etc..)\n3. به قسمت \"Cookies\" و به پخش \"https://accounts.spotify.com\" بروید", + "step_3": "گام 3", + "step_3_steps": "کپی کردن مقادیر \"sp_dc\" و \"sp_key\" (یا sp_gaid) کوکی", + "success_emoji": "موفقیت🥳", + "success_message": "اکنون با موفقیت با حساب اسپوتیفای خود وارد شده اید", + "step_4": "مرحله 4", + "step_4_steps": "مقدار کپی شده را \"sp_dc\" and \"sp_key\" (یا sp_gaid) در فیلد مربوط پر کنید", + "something_went_wrong": "اشتباهی رخ داده", + "piped_instance": "مشکل در ارتباط با سرور", + "piped_description": "مشکل در ارتباط با سرور در دریافت آهنگ ها", + "piped_warning": "برخی از آنها ممکن است خوب کارنکند.بنابراین با مسولیت خود استفاده کنید", + "generate_playlist": "ساخت لیست پخش", + "track_exists": "آهنگ {track} وجود دارد", + "replace_downloaded_tracks": "همه ی آهنگ های دانلود شده را جایگزین کنید", + "skip_download_tracks": "همه ی آهنگ های دانلود شده را رد کنید", + "do_you_want_to_replace": "ایا میخواهید آهنگ های موجود جایگزین کنید؟", + "replace": "جایگزین کردن", + "skip": "رد کردن", + "select_up_to_count_type": "انتخاب کنید تا {count} {type}", + "select_genres": "ژانر ها را انتخاب کنید", + "add_genres": "ژانر را اطافه کنید", + "country": "کشور", + "number_of_tracks_generate": "تعداد آهنگ های ساخته شده", + "acousticness": "آکوستیک", + "danceability": "رقصیدن", + "energy": "انرژی", + "instrumentalness": "بی کلام", + "liveness": "حس زندگی", + "loudness": "صدای بلند", + "speechiness": "دکلمه", + "valence": "ظرفیت", + "popularity": "محبوبیت", + "key": "کلید", + "duration": "مدت زمان (ثانیه)", + "tempo": "تمپو (BPM)", + "mode": "حالت", + "time_signature": "امضای زمان", + "short": "کوتاه", + "medium": "متوسط", + "long": "بلند", + "min": "حداقل", + "max": "حداکثر", + "target": "هدف", + "moderate": "حد وسط", + "deselect_all": "همه را لغو انتخاب کنید", + "select_all": "همه را انتخاب کنید", + "are_you_sure": "ایا مطمعن هستید؟", + "generating_playlist": " درحال ایجاد لیست پخش سفارشی شما", + "selected_count_tracks": "آهنگ انتخاب شده {count}", + "download_warning": "اگر همه ی آهنگ ها را به صورت انبو دانلود کنید به وضوح در حال دزدی موسقی هستید و در حال اسیب وارد کردن به جامه ی خلاق هنری می باشید .امیدوارم که از این موضوع اگاه باشید .همیشه سعی کنید به کار سخت هنرمند اخترام بگذارید.", + "download_ip_ban_warning": "راستی آی پی شما می تواند در یوتوب به دلیل درخواست های دانلود بیش از حد معمول مسدود شود. بلوک آی پی به این معنی است که شما نمی توانید از یوتوب (حتی اگر وارد سیستم شده باشید) حداقل 2-3 ماه از آن دستگاه آی پی استفاده کنید. و Spotube هیچ مسئولیتی در صورت وقوع این اتفاق ندارد", + "by_clicking_accept_terms": "با کلیک بر روی قبول با شرایط زیر موافقت می کنید:", + "download_agreement_1": "من میدانم در حال دزدی هستم .من بد هستم", + "download_agreement_2": "من هر کجا ک بتوانم از هنرمندان حمایت میکنم اما این کارا فقط به دلیل اینکه توانایی مالی ندارم انجام میدهم", + "download_agreement_3": "من کاملا میدانم که از طرف یوتوب بلاک میشم و این برنامه و مالکان را مسول این حادثه نمیدانم.", + "decline": "قبول نکردن", + "accept": "قبول", + "details": "جزئیات", + "youtube": "یوتیوب", + "channel": "کانال", + "likes": "دوست داشتن", + "dislikes": "دوست نداشتن", + "views": "بازدید", + "streamUrl": "لینک اثر", + "stop": "توقف", + "sort_newest": "مرتب سازی بر اساس جدید ترین اضافه شده", + "sort_oldest": "مرتب سازی بر اساس قدیمی ترین اضافه شده", + "sleep_timer": "زمان خواب", + "mins": "{minutes} دقیقه", + "hours": "{hours} ساعت", + "hour": "{hours} ساعت", + "custom_hours": "ساعت سفارشی", + "logs": "رسید خطا", + "developers": "توسعه دهنده ها", + "not_logged_in": "شما وارد نشده اید ", + "search_mode": "حالت جستجو", + "youtube_api_type": "API نوع", + "ok": "باشد", + "failed_to_encrypt": "رمز گذاری نشده", + "encryption_failed_warning": "Spotube از رمزگذاری برای ذخیره ایمن داده های شما استفاده می کند. اما موفق به انجام این کار نشد. بنابراین به فضای ذخیره‌سازی ناامن تبدیل می‌شود\nاگر از لینوکس استفاده می‌کنید، لطفاً مطمئن شوید که سرویس مخفی (gnome-keyring، kde-wallet، keepassxc و غیره) را نصب کرده‌اید.", + "querying_info": "جستجو درباره ", + "piped_api_down": "ایراد در سرور", + "piped_down_error_instructions": "به دلیل مشکل {pipedInstance} ارتباط با سرور مقدور نیست\n\nنمونه را تغییر دهید یا «نوع API» را به API رسمی YouTube تغییر دهید\n\nحتماً پس از تغییر، برنامه را دوباره راه‌اندازی کنید", + "you_are_offline": "شما در حال حاضر افلاین هستید ", + "connection_restored": "اتصال به اینترنت شما بازیابی شد ", + "use_system_title_bar": "از نوار عنوان سیستم استفاده کنید ", + "crunching_results": "نتایج خرد کردن...", + "search_to_get_results": "جستجو کنید تا به نتیجه برسید", + "use_amoled_mode": "استفاده از حالت AMOLED", + "pitch_dark_theme": "تم تیره دارت", + "normalize_audio": "نرمال کردن صدا", + "change_cover": "تغییر جلد", + "add_cover": "افزودن جلد", + "restore_defaults": "بازیابی پیش فرض ها", + "download_music_codec": "دانلود کدک موسیقی", + "streaming_music_codec": "کدک موسیقی استریمینگ", + "login_with_lastfm": "ورود با Last.fm", + "connect": "اتصال", + "disconnect_lastfm": "قطع ارتباط با Last.fm", + "disconnect": "قطع ارتباط", + "username": "نام کاربری", + "password": "رمز عبور", + "login": "ورود", + "login_with_your_lastfm": "ورود با حساب کاربری Last.fm خود", + "scrobble_to_lastfm": "Scrobble به Last.fm" +} \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index d1a891ed..fbe5c335 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -136,7 +136,6 @@ "skip_non_music": "Ignorer les segments non musicaux (SponsorBlock)", "blacklist_description": "Pistes et artistes en liste noire", "wait_for_download_to_finish": "Veuillez attendre la fin du téléchargement en cours", - "download_lyrics": "Télécharger les paroles avec les pistes", "desktop": "Bureau", "close_behavior": "Comportement de fermeture", "close": "Fermer", @@ -263,5 +262,22 @@ "update_playlist": "Mettre à jour la playlist", "update": "Mettre à jour", "crunching_results": "Traitement des résultats...", - "search_to_get_results": "Recherche pour obtenir des résultats" + "search_to_get_results": "Recherche pour obtenir des résultats", + "use_amoled_mode": "Utiliser le mode AMOLED", + "pitch_dark_theme": "Thème Dart noir intense", + "normalize_audio": "Normaliser l'audio", + "change_cover": "Changer de couverture", + "add_cover": "Ajouter une couverture", + "restore_defaults": "Restaurer les valeurs par défaut", + "download_music_codec": "Télécharger le codec musical", + "streaming_music_codec": "Codec de musique en streaming", + "login_with_lastfm": "Se connecter avec Last.fm", + "connect": "Connecter", + "disconnect_lastfm": "Déconnecter de Last.fm", + "disconnect": "Déconnecter", + "username": "Nom d'utilisateur", + "password": "Mot de passe", + "login": "Se connecter", + "login_with_your_lastfm": "Se connecter avec votre compte Last.fm", + "scrobble_to_lastfm": "Scrobble à Last.fm" } \ No newline at end of file diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 72d2c505..d33f41dc 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -136,7 +136,6 @@ "skip_non_music": "गाने के अलावा सेगमेंट्स को छोड़ें (स्पॉन्सरब्लॉक)", "blacklist_description": "ब्लैकलिस्ट में शामिल ट्रैक और कलाकार", "wait_for_download_to_finish": "वर्तमान डाउनलोड समाप्त होने तक कृपया प्रतीक्षा करें", - "download_lyrics": "गानों के साथ लिरिक्स डाउनलोड करें", "desktop": "डेस्कटॉप", "close_behavior": "बंद करने का व्यवहार", "close": "बंद करें", @@ -263,5 +262,22 @@ "update_playlist": "प्लेलिस्ट अपडेट करें", "update": "अपडेट करें", "crunching_results": "परिणाम को प्रसंस्कृत किया जा रहा है...", - "search_to_get_results": "परिणाम प्राप्त करने के लिए खोजें" + "search_to_get_results": "परिणाम प्राप्त करने के लिए खोजें", + "use_amoled_mode": "AMOLED मोड का उपयोग करें", + "pitch_dark_theme": "पिच ब्लैक डार्ट थीम", + "normalize_audio": "ऑडियो को सामान्य करें", + "change_cover": "कवर बदलें", + "add_cover": "कवर जोड़ें", + "restore_defaults": "डिफ़ॉल्ट सेटिंग्स को बहाल करें", + "download_music_codec": "संगीत कोडेक डाउनलोड करें", + "streaming_music_codec": "स्ट्रीमिंग संगीत कोडेक", + "login_with_lastfm": "Last.fm से लॉगिन करें", + "connect": "कनेक्ट करें", + "disconnect_lastfm": "Last.fm से डिस्कनेक्ट करें", + "disconnect": "डिस्कनेक्ट करें", + "username": "उपयोगकर्ता नाम", + "password": "पासवर्ड", + "login": "लॉग इन करें", + "login_with_your_lastfm": "अपने Last.fm अकाउंट से लॉगिन करें", + "scrobble_to_lastfm": "Last.fm पर स्क्रॉबल करें" } \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 30a35ef1..50c9369f 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -136,7 +136,6 @@ "skip_non_music": "音楽でない部分をスキップ (SponsorBlock)", "blacklist_description": "曲とアーティストのブラックリスト", "wait_for_download_to_finish": "現在のダウンロードが完了するまでお待ちください", - "download_lyrics": "曲と共に歌詞もダウンロード", "desktop": "デスクトップ", "close_behavior": "閉じた時の動作", "close": "閉じる", @@ -263,5 +262,22 @@ "update_playlist": "プレイリストを更新", "update": "更新", "crunching_results": "結果を処理中...", - "search_to_get_results": "結果を取得するために検索" + "search_to_get_results": "結果を取得するために検索", + "use_amoled_mode": "AMOLEDモードを使用する", + "pitch_dark_theme": "ピッチブラックダートテーマ", + "normalize_audio": "オーディオを正規化する", + "change_cover": "カバーを変更する", + "add_cover": "カバーを追加する", + "restore_defaults": "デフォルト値に戻す", + "download_music_codec": "音楽コーデックをダウンロードする", + "streaming_music_codec": "ストリーミング音楽コーデック", + "login_with_lastfm": "Last.fmでログインする", + "connect": "接続する", + "disconnect_lastfm": "Last.fmから切断する", + "disconnect": "切断する", + "username": "ユーザー名", + "password": "パスワード", + "login": "ログインする", + "login_with_your_lastfm": "あなたのLast.fmアカウントでログインする", + "scrobble_to_lastfm": "Last.fmにスクロブルする" } \ No newline at end of file diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 7d6db657..1a946615 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -136,7 +136,6 @@ "skip_non_music": "Pomiń nie-muzyczne segmenty (SponsorBlock)", "blacklist_description": "Czarna lista utworów i artystów", "wait_for_download_to_finish": "Proszę poczekać na zakończenie obecnego pobierania.", - "download_lyrics": "Pobierz utwory razem z tekstem", "desktop": "Pulpit", "close_behavior": "Zamknij", "close": "Zamknij", @@ -263,5 +262,22 @@ "update_playlist": "Zaktualizuj playlistę", "update": "Aktualizuj", "crunching_results": "Przetwarzanie wyników...", - "search_to_get_results": "Szukaj, aby uzyskać wyniki" + "search_to_get_results": "Szukaj, aby uzyskać wyniki", + "use_amoled_mode": "Tryb AMOLED", + "pitch_dark_theme": "Ciemny motyw", + "normalize_audio": "Normalizuj dźwięk", + "change_cover": "Zmień okładkę", + "add_cover": "Dodaj okładkę", + "restore_defaults": "Przywróć domyślne", + "download_music_codec": "Pobierz kodek muzyczny", + "streaming_music_codec": "Kodek strumieniowy muzyki", + "login_with_lastfm": "Zaloguj się z Last.fm", + "connect": "Połącz", + "disconnect_lastfm": "Rozłącz z Last.fm", + "disconnect": "Rozłącz", + "username": "Nazwa użytkownika", + "password": "Hasło", + "login": "Zaloguj", + "login_with_your_lastfm": "Zaloguj się na swoje konto Last.fm", + "scrobble_to_lastfm": "Scrobbluj do Last.fm" } \ No newline at end of file diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index a9cd3f32..97df3db3 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -136,7 +136,6 @@ "skip_non_music": "Pular segmentos não musicais (SponsorBlock)", "blacklist_description": "Faixas e artistas na lista negra", "wait_for_download_to_finish": "Aguarde o download atual ser concluído", - "download_lyrics": "Baixar letras junto com as faixas", "desktop": "Desktop", "close_behavior": "Comportamento de Fechamento", "close": "Fechar", @@ -263,5 +262,22 @@ "update_playlist": "Atualizar lista de reprodução", "update": "Atualizar", "crunching_results": "Processando resultados...", - "search_to_get_results": "Pesquisar para obter resultados" + "search_to_get_results": "Pesquisar para obter resultados", + "use_amoled_mode": "Modo AMOLED", + "pitch_dark_theme": "Tema escuro", + "normalize_audio": "Normalizar áudio", + "change_cover": "Alterar capa", + "add_cover": "Adicionar capa", + "restore_defaults": "Restaurar padrões", + "download_music_codec": "Descarregar codec de música", + "streaming_music_codec": "Codec de streaming de música", + "login_with_lastfm": "Iniciar sessão com o Last.fm", + "connect": "Ligar", + "disconnect_lastfm": "Desligar do Last.fm", + "disconnect": "Desligar", + "username": "Nome de utilizador", + "password": "Palavra-passe", + "login": "Iniciar sessão", + "login_with_your_lastfm": "Inicie sessão na sua conta Last.fm", + "scrobble_to_lastfm": "Scrobble para o Last.fm" } \ No newline at end of file diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 4daf0e92..098e73c7 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -136,7 +136,6 @@ "skip_non_music": "Пропускать немузыкальные сегменты (SponsorBlock)", "blacklist_description": "Черный список треков и артистов", "wait_for_download_to_finish": "Пожалуйста, дождитесь завершения текущей загрузки", - "download_lyrics": "Скачивать тексты вместе с треками", "desktop": "Компьютер", "close_behavior": "Поведение при закрытии", "close": "Закрыть", @@ -263,5 +262,22 @@ "update_playlist": "Обновить плейлист", "update": "Обновить", "crunching_results": "Обработка результатов...", - "search_to_get_results": "Поиск для получения результатов" + "search_to_get_results": "Поиск для получения результатов", + "use_amoled_mode": "Режим AMOLED", + "pitch_dark_theme": "Темная тема", + "normalize_audio": "Нормализовать звук", + "change_cover": "Изменить обложку", + "add_cover": "Добавить обложку", + "restore_defaults": "Восстановить настройки по умолчанию", + "download_music_codec": "Загрузить кодек для музыки", + "streaming_music_codec": "Кодек потоковой передачи музыки", + "login_with_lastfm": "Войти с помощью Last.fm", + "connect": "Подключить", + "disconnect_lastfm": "Отключиться от Last.fm", + "disconnect": "Отключить", + "username": "Имя пользователя", + "password": "Пароль", + "login": "Войти", + "login_with_your_lastfm": "Войти в свою учетную запись Last.fm", + "scrobble_to_lastfm": "Скробблинг на Last.fm" } \ No newline at end of file diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb new file mode 100644 index 00000000..fa0877d1 --- /dev/null +++ b/lib/l10n/app_uk.arb @@ -0,0 +1,283 @@ +{ + "guest": "Гість", + "browse": "Огляд", + "search": "Пошук", + "library": "Медіатека", + "lyrics": "Тексти пісень", + "settings": "Налаштування", + "genre_categories_filter": "Фільтрувати категорії або жанри...", + "genre": "Жанр", + "personalized": "Персоналізовані", + "featured": "Рекомендовані", + "new_releases": "Нові релізи", + "songs": "Пісні", + "playing_track": "Відтворюється {track}", + "queue_clear_alert": "Це очистить поточну чергу. Буде видалено {track_length} треків\nПродовжити?", + "load_more": "Завантажити більше", + "playlists": "Плейлисти", + "artists": "Виконавці", + "albums": "Альбоми", + "tracks": "Треки", + "downloads": "Завантаження", + "filter_playlists": "Фільтрувати плейлисти...", + "liked_tracks": "Сподобалися треки", + "liked_tracks_description": "Усі ваші сподобалися треки", + "create_playlist": "Створити плейлист", + "create_a_playlist": "Створити плейлист", + "update_playlist": "Оновити плейлист", + "create": "Створити", + "cancel": "Скасувати", + "update": "Оновити", + "playlist_name": "Назва плейлиста", + "name_of_playlist": "Назва плейлиста", + "description": "Опис", + "public": "Публічний", + "collaborative": "Спільний", + "search_local_tracks": "Пошук локальних треків...", + "play": "Відтворити", + "delete": "Видалити", + "none": "Немає", + "sort_a_z": "Сортувати за алфавітом A-Я", + "sort_z_a": "Сортувати за алфавітом Я-А", + "sort_artist": "Сортувати за виконавцем", + "sort_album": "Сортувати за альбомом", + "sort_tracks": "Сортувати треки", + "currently_downloading": "Завантажується ({tracks_length})", + "cancel_all": "Скасувати все", + "filter_artist": "Фільтрувати виконавців...", + "followers": "{followers} підписників", + "add_artist_to_blacklist": "Додати виконавця до чорного списку", + "top_tracks": "Топ треки", + "fans_also_like": "Шанувальникам також подобається", + "loading": "Завантаження...", + "artist": "Виконавець", + "blacklisted": "У чорному списку", + "following": "Стежу", + "follow": "Стежити", + "artist_url_copied": "URL виконавця скопійовано до буфера обміну", + "added_to_queue": "Додано {tracks} треків до черги", + "filter_albums": "Фільтрувати альбоми...", + "synced": "Синхронізовано", + "plain": "Звичайний", + "shuffle": "Випадковий порядок", + "search_tracks": "Пошук треків...", + "released": "Випущено", + "error": "Помилка {error}", + "title": "Назва", + "time": "Час", + "more_actions": "Більше дій", + "download_count": "Завантажено ({count})", + "add_count_to_playlist": "Додати ({count}) до плейлиста", + "add_count_to_queue": "Додати ({count}) до черги", + "play_count_next": "Відтворити ({count}) наступними", + "album": "Альбом", + "copied_to_clipboard": "Скопійовано {data} до буфера обміну", + "add_to_following_playlists": "Додати {track} до наступних плейлистів", + "add": "Додати", + "added_track_to_queue": "Додано {track} до черги", + "add_to_queue": "Додати до черги", + "track_will_play_next": "{track} буде відтворено наступним", + "play_next": "Відтворити наступним", + "removed_track_from_queue": "Видалено {track} з черги", + "remove_from_queue": "Видалити з черги", + "remove_from_favorites": "Видалити з обраних", + "save_as_favorite": "Зберегти як обране", + "add_to_playlist": "Додати до плейлиста", + "remove_from_playlist": "Видалити з плейлиста", + "add_to_blacklist": "Додати до чорного списку", + "remove_from_blacklist": "Видалити з чорного списку", + "share": "Поділитися", + "mini_player": "Міні-плеєр", + "slide_to_seek": "Проведіть пальцем, щоб перемотати вперед або назад", + "shuffle_playlist": "Випадковий порядок відтворення плейлиста", + "unshuffle_playlist": "Відключити випадковий порядок відтворення плейлиста", + "previous_track": "Попередній трек", + "next_track": "Наступний трек", + "pause_playback": "Призупинити відтворення", + "resume_playback": "Відновити відтворення", + "loop_track": "Повторювати трек", + "repeat_playlist": "Повторювати плейлист", + "queue": "Черга", + "alternative_track_sources": "Альтернативні джерела треків", + "download_track": "Завантажити трек", + "tracks_in_queue": "{tracks} треків у черзі", + "clear_all": "Очистити все", + "show_hide_ui_on_hover": "Показувати/приховувати інтерфейс при наведенні курсору", + "always_on_top": "Завжди зверху", + "exit_mini_player": "Вийти з міні-плеєра", + "download_location": "Шлях завантаження", + "account": "Обліковий запис", + "login_with_spotify": "Увійти за допомогою облікового запису Spotify", + "connect_with_spotify": "Підключитися до Spotify", + "logout": "Вийти", + "logout_of_this_account": "Вийти з цього облікового запису", + "language_region": "Мова та регіон", + "language": "Мова", + "system_default": "Системна мова", + "market_place_region": "Регіон маркетплейсу", + "recommendation_country": "Країна рекомендацій", + "appearance": "Зовнішній вигляд", + "layout_mode": "Режим макета", + "override_layout_settings": "Перезаписати налаштування адаптивного режиму макета", + "adaptive": "Адаптивний", + "compact": "Компактний", + "extended": "Розширений", + "theme": "Тема", + "dark": "Темна", + "light": "Світла", + "system": "Системна", + "accent_color": "Колір акценту", + "sync_album_color": "Синхронізувати колір альбому", + "sync_album_color_description": "Використовує домінуючий колір обкладинки альбому як колір акценту", + "playback": "Відтворення", + "audio_quality": "Якість аудіо", + "high": "Висока", + "low": "Низька", + "pre_download_play": "Попереднє завантаження та відтворення", + "pre_download_play_description": "Замість потокового відтворення аудіо завантажте байти та відтворіть їх (рекомендовано для користувачів з високою пропускною здатністю)", + "skip_non_music": "Пропустити не музичні сегменти", + "blacklist_description": "Треки та виконавці в чорному списку", + "wait_for_download_to_finish": "Зачекайте, поки завершиться поточна загрузка", + "desktop": "Робочий стіл", + "close_behavior": "Поведінка при закритті", + "close": "Закрити", + "minimize_to_tray": "Згорнути в трей", + "show_tray_icon": "Показувати значок у системному треї", + "about": "Про", + "u_love_spotube": "Ми знаємо, що ви любите Spotube", + "check_for_updates": "Перевірити наявність оновлень", + "about_spotube": "Про Spotube", + "blacklist": "Чорний список", + "please_sponsor": "Будь ласка, станьте спонсором/зробіть пожертву", + "spotube_description": "Spotube, легкий, кросплатформовий, безкоштовний клієнт Spotify", + "version": "Версія", + "build_number": "Номер збірки", + "founder": "Засновник", + "repository": "Репозиторій", + "bug_issues": "Помилки та проблеми", + "made_with": "Зроблено з ❤️ в Бангладеш 🇧🇩", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "Ліцензія", + "add_spotify_credentials": "Додайте свої облікові дані Spotify, щоб почати", + "credentials_will_not_be_shared_disclaimer": "Не хвилюйтеся, жодні ваші облікові дані не будуть зібрані або передані кому-небудь", + "know_how_to_login": "Не знаєте, як це зробити?", + "follow_step_by_step_guide": "Дотримуйтесь покрокової інструкції", + "spotify_cookie": "Кукі-файл Spotify {name}", + "cookie_name_cookie": "Кукі-файл {name}", + "fill_in_all_fields": "Будь ласка, заповніть усі поля", + "submit": "Надіслати", + "exit": "Вийти", + "previous": "Попередній", + "next": "Наступний", + "done": "Готово", + "step_1": "Крок 1", + "first_go_to": "Спочатку перейдіть на", + "login_if_not_logged_in": "та Увійдіть/Зареєструйтесь, якщо ви не ввійшли", + "step_2": "Крок 2", + "step_2_steps": "1. Після входу натисніть F12 або клацніть правою кнопкою миші > Інспектувати, щоб відкрити інструменти розробки браузера.\n2. Потім перейдіть на вкладку 'Програма' (Chrome, Edge, Brave тощо) або вкладку 'Сховище' (Firefox, Palemoon тощо).\n3. Перейдіть до розділу 'Кукі-файли', а потім до підрозділу 'https://accounts.spotify.com'", + "step_3": "Крок 3", + "step_3_steps": "Скопіюйте значення кукі-файлів 'sp_dc' та 'sp_key' (або sp_gaid)", + "success_emoji": "Успіх🥳", + "success_message": "Тепер ви успішно ввійшли у свій обліковий запис Spotify. Гарна робота, друже!", + "step_4": "Крок 4", + "step_4_steps": "Вставте скопійовані значення 'sp_dc' та 'sp_key' (або sp_gaid) у відповідні поля", + "something_went_wrong": "Щось пішло не так", + "piped_instance": "Примірник сервера Piped", + "piped_description": "Примірник сервера Piped, який використовуватиметься для зіставлення треків", + "piped_warning": "Деякі з них можуть працювати неправильно. Тому використовуйте на свій страх і ризик", + "generate_playlist": "Створити плейлист", + "track_exists": "Трек {track} вже існує", + "replace_downloaded_tracks": "Замінити всі завантажені треки", + "skip_download_tracks": "Пропустити завантаження всіх завантажених треків", + "do_you_want_to_replace": "Ви хочете замінити існуючий трек?", + "replace": "Замінити", + "skip": "Пропустити", + "select_up_to_count_type": "Виберіть до {count} {type}", + "select_genres": "Виберіть жанри", + "add_genres": "Додати жанри", + "country": "Країна", + "number_of_tracks_generate": "Кількість треків для створення", + "acousticness": "Акустичність", + "danceability": "Танцювальність", + "energy": "Енергія", + "instrumentalness": "Інструментальність", + "liveness": "Живість", + "loudness": "Гучність", + "speechiness": "Розмовність", + "valence": "Валентність", + "popularity": "Популярність", + "key": "Тональність", + "duration": "Тривалість (с)", + "tempo": "Темп (BPM)", + "mode": "Режим", + "time_signature": "Розмір", + "short": "Короткий", + "medium": "Середній", + "long": "Довгий", + "min": "Мін", + "max": "Макс", + "target": "Цільовий", + "moderate": "Помірний", + "deselect_all": "Зняти вибір з усіх", + "select_all": "Вибрати всі", + "are_you_sure": "Ви впевнені?", + "generating_playlist": "Створення вашого персонального плейлиста...", + "selected_count_tracks": "Вибрано {count} треків", + "download_warning": "Якщо ви завантажуєте всі треки масово, ви явно піратствуєте і завдаєте шкоди музичному творчому співтовариству. Сподіваюся, ви усвідомлюєте це. Завжди намагайтеся поважати і підтримувати важку працю артиста", + "download_ip_ban_warning": "До речі, ваш IP може бути заблокований на YouTube через надмірну кількість запитів на завантаження, ніж зазвичай. Блокування IP-адреси означає, що ви не зможете користуватися YouTube (навіть якщо ви увійшли в систему) протягом щонайменше 2-3 місяців з цього пристрою. І Spotube не несе жодної відповідальності, якщо це станеться", + "by_clicking_accept_terms": "Натискаючи 'прийняти', ви погоджуєтеся з наступними умовами:", + "download_agreement_1": "Я знаю, що краду музику. Я поганий.", + "download_agreement_2": "Я підтримаю автора, де тільки зможу, і роблю це лише тому, що не маю грошей, щоб купити його роботи.", + "download_agreement_3": "Я повністю усвідомлюю, що мій IP може бути заблокований на YouTube, і я не покладаю на Spotube або його власників/контрибуторів відповідальність за будь-які нещасні випадки, спричинені моїми діями.", + "decline": "Відхилити", + "accept": "Прийняти", + "details": "Деталі", + "youtube": "YouTube", + "channel": "Канал", + "likes": "Подобається", + "dislikes": "Не подобається", + "views": "Переглядів", + "streamUrl": "Посилання на стрімінг", + "stop": "Зупинити", + "sort_newest": "Сортувати за датою додавання (новіші першими)", + "sort_oldest": "Сортувати за датою додавання (старіші першими)", + "sleep_timer": "Таймер сну", + "mins": "{minutes} хвилин", + "hours": "{hours} годин", + "hour": "{hours} година", + "custom_hours": "Кількість годин на замовлення", + "logs": "Логи", + "developers": "Розробники", + "not_logged_in": "Ви не ввійшли в обліковий запис", + "search_mode": "Режим пошуку", + "youtube_api_type": "Тип API", + "ok": "Гаразд", + "failed_to_encrypt": "Не вдалося зашифрувати", + "encryption_failed_warning": "Spotube використовує шифрування для безпечного зберігання ваших даних. Але не вдалося цього зробити. Тому він перейде до небезпечного зберігання\nЯкщо ви використовуєте Linux, переконайтеся, що у вас встановлено будь-який секретний сервіс (gnome-keyring, kde-wallet, keepassxc тощо)", + "querying_info": "Запит інформації...", + "piped_api_down": "API Piped не працює", + "piped_down_error_instructions": "Поточний екземпляр Piped {pipedInstance} не працює\n\nЗмініть екземпляр або змініть 'Тип API' на офіційний YouTube API\n\nОбов'язково перезапустіть програму після зміни", + "you_are_offline": "Ви зараз не в мережі", + "connection_restored": "Ваше інтернет-з'єднання відновлено", + "use_system_title_bar": "Використовувати системний заголовок", + "crunching_results": "Опрацювання результатів...", + "search_to_get_results": "Почніть пошук, щоб отримати результати", + "use_amoled_mode": "Режим AMOLED", + "pitch_dark_theme": "Темна тема", + "normalize_audio": "Нормалізувати звук", + "change_cover": "Змінити обкладинку", + "add_cover": "Додати обкладинку", + "restore_defaults": "Відновити налаштування за замовчуванням", + "download_music_codec": "Завантажити кодек для музики", + "streaming_music_codec": "Кодек потокової передачі музики", + "login_with_lastfm": "Увійти з Last.fm", + "connect": "Підключити", + "disconnect_lastfm": "Відключитися від Last.fm", + "disconnect": "Відключити", + "username": "Ім'я користувача", + "password": "Пароль", + "login": "Увійти", + "login_with_your_lastfm": "Увійти в свій обліковий запис Last.fm", + "scrobble_to_lastfm": "Скробблінг на Last.fm" +} \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index fe440ccf..9936c812 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -136,7 +136,6 @@ "skip_non_music": "跳过非音乐片段(屏蔽赞助商)", "blacklist_description": "已屏蔽的歌曲与艺人", "wait_for_download_to_finish": "请等待当前下载任务完成", - "download_lyrics": "下载歌曲时同时下载歌词", "desktop": "桌面端设置", "close_behavior": "点击关闭按钮行为", "close": "关闭", @@ -263,5 +262,22 @@ "update_playlist": "更新播放列表", "update": "更新", "crunching_results": "处理结果中...", - "search_to_get_results": "搜索以获取结果" + "search_to_get_results": "搜索以获取结果", + "use_amoled_mode": "使用 AMOLED 模式", + "pitch_dark_theme": "深色主题", + "normalize_audio": "标准化音频", + "change_cover": "更改封面", + "add_cover": "添加封面", + "restore_defaults": "恢复默认值", + "download_music_codec": "下载音乐编解码器", + "streaming_music_codec": "流媒体音乐编解码器", + "login_with_lastfm": "使用 Last.fm 登录", + "connect": "连接", + "disconnect_lastfm": "断开 Last.fm 连接", + "disconnect": "断开连接", + "username": "用户名", + "password": "密码", + "login": "登录", + "login_with_your_lastfm": "使用您的 Last.fm 帐户登录", + "scrobble_to_lastfm": "在 Last.fm 上记录播放" } \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 133f763d..daa6f131 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -5,6 +5,7 @@ /// maboroshin@github => Japanese /// iceyear@github => Simplified Chinese /// TexturedPolak@github => Polish +/// yuri-val@github => Ukrainian import 'package:flutter/material.dart'; class L10n { @@ -14,6 +15,7 @@ class L10n { const Locale('de', 'GE'), const Locale('ca', 'AD'), const Locale('es', 'ES'), + const Locale("fa", "IR"), const Locale('fr', 'FR'), const Locale('hi', 'IN'), const locale('it', 'IT'), @@ -22,5 +24,7 @@ class L10n { const Locale('pl', 'PL'), const Locale('ru', 'RU'), const Locale('pt', 'PT'), + const Locale('uk', 'UA'), + const Locale('ar', 'SA'), ]; } diff --git a/lib/main.dart b/lib/main.dart index ee6fdf17..f8c3aa8c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,6 @@ -import 'package:catcher/catcher.dart'; +import 'package:catcher_2/catcher_2.dart'; import 'package:device_preview/device_preview.dart'; import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_devtools/fl_query_devtools.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -91,9 +90,9 @@ Future main(List rawArgs) async { path: hiveCacheDir, ); - Catcher( + Catcher2( enableLogger: arguments["verbose"], - debugConfig: CatcherOptions( + debugConfig: Catcher2Options( SilentReportMode(), [ ConsoleHandler( @@ -103,7 +102,7 @@ Future main(List rawArgs) async { if (!kIsWeb) FileHandler(await getLogsPath(), printLogs: false), ], ), - releaseConfig: CatcherOptions( + releaseConfig: Catcher2Options( SilentReportMode(), [ if (arguments["verbose"] ?? false) ConsoleHandler(), @@ -163,6 +162,8 @@ class SpotubeState extends ConsumerState { ref.watch(userPreferencesProvider.select((s) => s.themeMode)); final accentMaterialColor = ref.watch(userPreferencesProvider.select((s) => s.accentColorScheme)); + final isAmoledTheme = + ref.watch(userPreferencesProvider.select((s) => s.amoledDarkTheme)); final locale = ref.watch(userPreferencesProvider.select((s) => s.locale)); final paletteColor = ref.watch(paletteProvider.select((s) => s?.dominantColor?.color)); @@ -182,12 +183,16 @@ class SpotubeState extends ConsumerState { useDisableBatteryOptimizations(); final lightTheme = useMemoized( - () => theme(paletteColor ?? accentMaterialColor, Brightness.light), + () => theme(paletteColor ?? accentMaterialColor, Brightness.light, false), [paletteColor, accentMaterialColor], ); final darkTheme = useMemoized( - () => theme(paletteColor ?? accentMaterialColor, Brightness.dark), - [paletteColor, accentMaterialColor], + () => theme( + paletteColor ?? accentMaterialColor, + Brightness.dark, + isAmoledTheme, + ), + [paletteColor, accentMaterialColor, isAmoledTheme], ); return MaterialApp.router( @@ -199,9 +204,7 @@ class SpotubeState extends ConsumerState { GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], - routeInformationParser: router.routeInformationParser, - routerDelegate: router.routerDelegate, - routeInformationProvider: router.routeInformationProvider, + routerConfig: router, debugShowCheckedModeBanner: false, title: 'Spotube', builder: (context, child) { @@ -224,22 +227,22 @@ class SpotubeState extends ConsumerState { LogicalKeySet(LogicalKeyboardKey.comma, LogicalKeyboardKey.control): NavigationIntent(router, "/settings"), LogicalKeySet( - LogicalKeyboardKey.keyB, + LogicalKeyboardKey.digit1, LogicalKeyboardKey.control, LogicalKeyboardKey.shift, ): HomeTabIntent(ref, tab: HomeTabs.browse), LogicalKeySet( - LogicalKeyboardKey.keyS, + LogicalKeyboardKey.digit2, LogicalKeyboardKey.control, LogicalKeyboardKey.shift, ): HomeTabIntent(ref, tab: HomeTabs.search), LogicalKeySet( - LogicalKeyboardKey.keyL, + LogicalKeyboardKey.digit3, LogicalKeyboardKey.control, LogicalKeyboardKey.shift, ): HomeTabIntent(ref, tab: HomeTabs.library), LogicalKeySet( - LogicalKeyboardKey.keyY, + LogicalKeyboardKey.digit4, LogicalKeyboardKey.control, LogicalKeyboardKey.shift, ): HomeTabIntent(ref, tab: HomeTabs.lyrics), diff --git a/lib/models/local_track.dart b/lib/models/local_track.dart index b973cdbb..e297e974 100644 --- a/lib/models/local_track.dart +++ b/lib/models/local_track.dart @@ -40,7 +40,7 @@ class LocalTrack extends Track { return { "album": album?.toJson(), "artists": artists?.map((artist) => artist.toJson()).toList(), - "availableMarkets": availableMarkets, + "availableMarkets": availableMarkets?.map((m) => m.name), "discNumber": discNumber, "duration": duration.toString(), "durationMs": durationMs, diff --git a/lib/models/logger.dart b/lib/models/logger.dart index d4199173..4f687d09 100644 --- a/lib/models/logger.dart +++ b/lib/models/logger.dart @@ -37,7 +37,8 @@ class SpotubeLogger extends Logger { SpotubeLogger([this.owner]) : super(filter: _SpotubeLogFilter()); @override - void log(Level level, message, [error, StackTrace? stackTrace]) async { + void log(Level level, dynamic message, + {Object? error, StackTrace? stackTrace, DateTime? time}) async { if (!kIsWeb) { if (level == Level.error) { String dir = (await getApplicationDocumentsDirectory()).path; @@ -56,7 +57,7 @@ class SpotubeLogger extends Logger { } } - super.log(level, "[$owner] $message", error, stackTrace); + super.log(level, "[$owner] $message", error: error, stackTrace: stackTrace); } } @@ -64,7 +65,7 @@ class _SpotubeLogFilter extends DevelopmentFilter { @override bool shouldLog(LogEvent event) { if ((logEnv["DEBUG"] == "true" && event.level == Level.debug) || - (logEnv["VERBOSE"] == "true" && event.level == Level.verbose) || + (logEnv["VERBOSE"] == "true" && event.level == Level.trace) || (logEnv["ERROR"] == "true" && event.level == Level.error)) { return true; } diff --git a/lib/models/spotube_track.dart b/lib/models/spotube_track.dart index 6ef240df..a8b94ef5 100644 --- a/lib/models/spotube_track.dart +++ b/lib/models/spotube_track.dart @@ -23,6 +23,7 @@ class TrackNotFoundException implements Exception { class SpotubeTrack extends Track { final YoutubeVideoInfo ytTrack; final String ytUri; + final MusicCodec codec; final List siblings; @@ -30,6 +31,7 @@ class SpotubeTrack extends Track { this.ytTrack, this.ytUri, this.siblings, + this.codec, ) : super(); SpotubeTrack.fromTrack({ @@ -37,6 +39,7 @@ class SpotubeTrack extends Track { required this.ytTrack, required this.ytUri, required this.siblings, + required this.codec, }) : super() { album = track.album; artists = track.artists; @@ -149,6 +152,7 @@ class SpotubeTrack extends Track { static Future fetchFromTrack( Track track, YoutubeEndpoints client, + MusicCodec codec, ) async { final matchedCachedTrack = await MatchedTrack.box.get(track.id!); var siblings = []; @@ -157,16 +161,17 @@ class SpotubeTrack extends Track { if (matchedCachedTrack != null && matchedCachedTrack.searchMode == client.preferences.searchMode) { (ytVideo, ytStreamUrl) = await client.video( - matchedCachedTrack.youtubeId, - matchedCachedTrack.searchMode, - ); + matchedCachedTrack.youtubeId, matchedCachedTrack.searchMode, codec); } else { siblings = await fetchSiblings(track, client); if (siblings.isEmpty) { throw TrackNotFoundException(track); } - (ytVideo, ytStreamUrl) = - await client.video(siblings.first.id, siblings.first.searchMode); + (ytVideo, ytStreamUrl) = await client.video( + siblings.first.id, + siblings.first.searchMode, + codec, + ); await MatchedTrack.box.put( track.id!, @@ -183,6 +188,7 @@ class SpotubeTrack extends Track { ytTrack: ytVideo, ytUri: ytStreamUrl, siblings: siblings, + codec: codec, ); } @@ -193,8 +199,12 @@ class SpotubeTrack extends Track { // sibling tracks that were manually searched and swapped final isStepSibling = siblings.none((element) => element.id == video.id); - final (ytVideo, ytStreamUrl) = - await client.video(video.id, siblings.first.searchMode); + final (ytVideo, ytStreamUrl) = await client.video( + video.id, + siblings.first.searchMode, + // siblings are always swapped when streaming + client.preferences.streamMusicCodec, + ); if (!isStepSibling) { await MatchedTrack.box.put( @@ -215,6 +225,7 @@ class SpotubeTrack extends Track { video, ...siblings.where((element) => element.id != video.id), ], + codec: client.preferences.streamMusicCodec, ); } @@ -226,6 +237,10 @@ class SpotubeTrack extends Track { siblings: List.castFrom>(map["siblings"]) .map((sibling) => YoutubeVideoInfo.fromJson(sibling)) .toList(), + codec: MusicCodec.values.firstWhere( + (element) => element.name == map["codec"], + orElse: () => MusicCodec.m4a, + ), ); } @@ -242,6 +257,7 @@ class SpotubeTrack extends Track { ytTrack: ytTrack, ytUri: ytUri, siblings: siblings, + codec: codec, ); } @@ -250,7 +266,7 @@ class SpotubeTrack extends Track { // super values "album": album?.toJson(), "artists": artists?.map((artist) => artist.toJson()).toList(), - "availableMarkets": availableMarkets, + "availableMarkets": availableMarkets?.map((m) => m.name), "discNumber": discNumber, "duration": duration.toString(), "durationMs": durationMs, @@ -268,6 +284,7 @@ class SpotubeTrack extends Track { "ytTrack": ytTrack.toJson(), "ytUri": ytUri, "siblings": siblings.map((sibling) => sibling.toJson()).toList(), + "codec": codec.name, }; } } diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index e1bbefcb..67a99d86 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/shimmers/shimmer_artist_profile.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/track_table/track_tile.dart'; @@ -90,365 +91,372 @@ class ArtistPage extends HookConsumerWidget { BlacklistedElement.artist(artistId, data.name!), ); - return SingleChildScrollView( + return InterScrollbar( controller: parentScrollController, - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - runAlignment: WrapAlignment.center, - children: [ - const SizedBox(width: 50), - Padding( - padding: const EdgeInsets.all(16), - child: CircleAvatar( - radius: avatarWidth, - backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - data.images, - placeholder: ImagePlaceholder.artist, + child: SingleChildScrollView( + controller: parentScrollController, + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + runAlignment: WrapAlignment.center, + children: [ + const SizedBox(width: 50), + Padding( + padding: const EdgeInsets.all(16), + child: CircleAvatar( + radius: avatarWidth, + backgroundImage: UniversalImage.imageProvider( + TypeConversionUtils.image_X_UrlString( + data.images, + placeholder: ImagePlaceholder.artist, + ), ), ), ), - ), - Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.blue, - borderRadius: - BorderRadius.circular(50)), - child: Text( - data.type!.toUpperCase(), - style: chipTextVariant.copyWith( - color: Colors.white, - ), - ), - ), - if (isBlackListed) ...[ - const SizedBox(width: 5), + Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 5), decoration: BoxDecoration( - color: Colors.red[400], + color: Colors.blue, borderRadius: BorderRadius.circular(50)), child: Text( - context.l10n.blacklisted, + data.type!.toUpperCase(), style: chipTextVariant.copyWith( color: Colors.white, ), ), ), - ] - ], - ), - Text( - data.name!, - style: mediaQuery.smAndDown - ? textTheme.headlineSmall - : textTheme.headlineMedium, - ), - Text( - context.l10n.followers( - PrimitiveUtils.toReadableNumber( - data.followers!.total!.toDouble(), + if (isBlackListed) ...[ + const SizedBox(width: 5), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.red[400], + borderRadius: + BorderRadius.circular(50)), + child: Text( + context.l10n.blacklisted, + style: chipTextVariant.copyWith( + color: Colors.white, + ), + ), + ), + ] + ], + ), + Text( + data.name!, + style: mediaQuery.smAndDown + ? textTheme.headlineSmall + : textTheme.headlineMedium, + ), + Text( + context.l10n.followers( + PrimitiveUtils.toReadableNumber( + data.followers!.total!.toDouble(), + ), + ), + style: textTheme.bodyMedium?.copyWith( + fontWeight: mediaQuery.mdAndUp + ? FontWeight.bold + : null, ), ), - style: textTheme.bodyMedium?.copyWith( - fontWeight: mediaQuery.mdAndUp - ? FontWeight.bold - : null, - ), - ), - const SizedBox(height: 20), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (auth != null) - HookBuilder( - builder: (context) { - final isFollowingQuery = useQueries - .artist - .doIFollow(ref, artistId); + const SizedBox(height: 20), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (auth != null) + HookBuilder( + builder: (context) { + final isFollowingQuery = useQueries + .artist + .doIFollow(ref, artistId); - final followUnfollow = - useCallback(() async { - try { - isFollowingQuery.data! - ? await spotify.me.unfollow( - FollowingType.artist, - [artistId], - ) - : await spotify.me.follow( - FollowingType.artist, - [artistId], - ); - await isFollowingQuery.refresh(); + final followUnfollow = + useCallback(() async { + try { + isFollowingQuery.data! + ? await spotify.me.unfollow( + FollowingType.artist, + [artistId], + ) + : await spotify.me.follow( + FollowingType.artist, + [artistId], + ); + await isFollowingQuery.refresh(); - queryClient - .refreshInfiniteQueryAllPages( - "user-following-artists"); - } finally { - queryClient.refreshQuery( - "user-follows-artists-query/$artistId", + queryClient + .refreshInfiniteQueryAllPages( + "user-following-artists"); + } finally { + queryClient.refreshQuery( + "user-follows-artists-query/$artistId", + ); + } + }, [isFollowingQuery]); + + if (isFollowingQuery.isLoading || + !isFollowingQuery.hasData) { + return const SizedBox( + height: 20, + width: 20, + child: + CircularProgressIndicator(), ); } - }, [isFollowingQuery]); - if (isFollowingQuery.isLoading || - !isFollowingQuery.hasData) { - return const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(), - ); - } + if (isFollowingQuery.data!) { + return OutlinedButton( + onPressed: followUnfollow, + child: + Text(context.l10n.following), + ); + } - if (isFollowingQuery.data!) { - return OutlinedButton( + return FilledButton( onPressed: followUnfollow, - child: Text(context.l10n.following), + child: Text(context.l10n.follow), + ); + }, + ), + const SizedBox(width: 5), + IconButton( + tooltip: + context.l10n.add_artist_to_blacklist, + icon: Icon( + SpotubeIcons.userRemove, + color: !isBlackListed + ? Colors.red[400] + : Colors.white, + ), + style: IconButton.styleFrom( + backgroundColor: isBlackListed + ? Colors.red[400] + : null, + ), + onPressed: () async { + if (isBlackListed) { + ref + .read(BlackListNotifier + .provider.notifier) + .remove( + BlacklistedElement.artist( + data.id!, data.name!), + ); + } else { + ref + .read(BlackListNotifier + .provider.notifier) + .add( + BlacklistedElement.artist( + data.id!, data.name!), + ); + } + }, + ), + IconButton( + icon: const Icon(SpotubeIcons.share), + onPressed: () async { + if (data.externalUrls?.spotify != + null) { + await Clipboard.setData( + ClipboardData( + text: data.externalUrls!.spotify!, + ), ); } - return FilledButton( - onPressed: followUnfollow, - child: Text(context.l10n.follow), + if (!context.mounted) return; + + scaffoldMessenger.showSnackBar( + SnackBar( + width: 300, + behavior: SnackBarBehavior.floating, + content: Text( + context.l10n.artist_url_copied, + textAlign: TextAlign.center, + ), + ), + ); + }, + ) + ], + ) + ], + ), + ), + ], + ), + const SizedBox(height: 50), + HookBuilder( + builder: (context) { + final topTracksQuery = useQueries.artist.topTracksOf( + ref, + artistId, + ); + + final isPlaylistPlaying = playlist.containsTracks( + topTracksQuery.data ?? [], + ); + + if (topTracksQuery.isLoading || + !topTracksQuery.hasData) { + return const CircularProgressIndicator(); + } else if (topTracksQuery.hasError) { + return Center( + child: Text(topTracksQuery.error.toString()), + ); + } + + final topTracks = topTracksQuery.data!; + + void playPlaylist(List tracks, + {Track? currentTrack}) async { + currentTrack ??= tracks.first; + if (!isPlaylistPlaying) { + playlistNotifier.load( + tracks, + initialIndex: tracks.indexWhere( + (s) => s.id == currentTrack?.id), + autoPlay: true, + ); + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != playlist.activeTrack?.id) { + await playlistNotifier.jumpToTrack(currentTrack); + } + } + + return Column( + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + context.l10n.top_tracks, + style: theme.textTheme.headlineSmall, + ), + ), + if (!isPlaylistPlaying) + IconButton( + icon: const Icon( + SpotubeIcons.queueAdd, + ), + onPressed: () { + playlistNotifier + .addTracks(topTracks.toList()); + scaffoldMessenger.showSnackBar( + SnackBar( + width: 300, + behavior: SnackBarBehavior.floating, + content: Text( + context.l10n.added_to_queue( + topTracks.length, + ), + textAlign: TextAlign.center, + ), + ), ); }, ), const SizedBox(width: 5), IconButton( - tooltip: - context.l10n.add_artist_to_blacklist, icon: Icon( - SpotubeIcons.userRemove, - color: !isBlackListed - ? Colors.red[400] - : Colors.white, + isPlaylistPlaying + ? SpotubeIcons.stop + : SpotubeIcons.play, + color: Colors.white, ), style: IconButton.styleFrom( - backgroundColor: isBlackListed - ? Colors.red[400] - : null, + backgroundColor: + theme.colorScheme.primary, ), - onPressed: () async { - if (isBlackListed) { - ref - .read(BlackListNotifier - .provider.notifier) - .remove( - BlacklistedElement.artist( - data.id!, data.name!), - ); - } else { - ref - .read(BlackListNotifier - .provider.notifier) - .add( - BlacklistedElement.artist( - data.id!, data.name!), - ); - } - }, - ), - IconButton( - icon: const Icon(SpotubeIcons.share), - onPressed: () async { - if (data.externalUrls?.spotify != null) { - await Clipboard.setData( - ClipboardData( - text: data.externalUrls!.spotify!, - ), - ); - } - - if (!context.mounted) return; - - scaffoldMessenger.showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - context.l10n.artist_url_copied, - textAlign: TextAlign.center, - ), - ), - ); - }, + onPressed: () => + playPlaylist(topTracks.toList()), ) ], - ) + ), + ...topTracks.mapIndexed((i, track) { + return TrackTile( + index: i, + track: track, + onTap: () async { + playPlaylist( + topTracks.toList(), + currentTrack: track, + ); + }, + ); + }), ], - ), + ); + }, + ), + const SizedBox(height: 50), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + context.l10n.albums, + style: theme.textTheme.headlineSmall, ), - ], - ), - const SizedBox(height: 50), - HookBuilder( - builder: (context) { - final topTracksQuery = useQueries.artist.topTracksOf( - ref, - artistId, - ); - - final isPlaylistPlaying = playlist.containsTracks( - topTracksQuery.data ?? [], - ); - - if (topTracksQuery.isLoading || - !topTracksQuery.hasData) { - return const CircularProgressIndicator(); - } else if (topTracksQuery.hasError) { - return Center( - child: Text(topTracksQuery.error.toString()), + ), + ArtistAlbumList(artistId), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + context.l10n.fans_also_like, + style: theme.textTheme.headlineSmall, + ), + ), + const SizedBox(height: 10), + HookBuilder( + builder: (context) { + final relatedArtists = + useQueries.artist.relatedArtistsOf( + ref, + artistId, ); - } - final topTracks = topTracksQuery.data!; - - void playPlaylist(List tracks, - {Track? currentTrack}) async { - currentTrack ??= tracks.first; - if (!isPlaylistPlaying) { - playlistNotifier.load( - tracks, - initialIndex: tracks - .indexWhere((s) => s.id == currentTrack?.id), - autoPlay: true, + if (relatedArtists.isLoading || + !relatedArtists.hasData) { + return const CircularProgressIndicator(); + } else if (relatedArtists.hasError) { + return Center( + child: Text(relatedArtists.error.toString()), ); - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != playlist.activeTrack?.id) { - await playlistNotifier.jumpToTrack(currentTrack); } - } - return Column( - children: [ - Row( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.l10n.top_tracks, - style: theme.textTheme.headlineSmall, - ), - ), - if (!isPlaylistPlaying) - IconButton( - icon: const Icon( - SpotubeIcons.queueAdd, - ), - onPressed: () { - playlistNotifier - .addTracks(topTracks.toList()); - scaffoldMessenger.showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - context.l10n.added_to_queue( - topTracks.length, - ), - textAlign: TextAlign.center, - ), - ), - ); - }, - ), - const SizedBox(width: 5), - IconButton( - icon: Icon( - isPlaylistPlaying - ? SpotubeIcons.stop - : SpotubeIcons.play, - color: Colors.white, - ), - style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.primary, - ), - onPressed: () => - playPlaylist(topTracks.toList()), - ) - ], - ), - ...topTracks.mapIndexed((i, track) { - return TrackTile( - index: i, - track: track, - onTap: () async { - playPlaylist( - topTracks.toList(), - currentTrack: track, - ); - }, - ); - }), - ], - ); - }, - ), - const SizedBox(height: 50), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.l10n.albums, - style: theme.textTheme.headlineSmall, - ), - ), - ArtistAlbumList(artistId), - const SizedBox(height: 20), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.l10n.fans_also_like, - style: theme.textTheme.headlineSmall, - ), - ), - const SizedBox(height: 10), - HookBuilder( - builder: (context) { - final relatedArtists = - useQueries.artist.relatedArtistsOf( - ref, - artistId, - ); - - if (relatedArtists.isLoading || - !relatedArtists.hasData) { - return const CircularProgressIndicator(); - } else if (relatedArtists.hasError) { return Center( - child: Text(relatedArtists.error.toString()), + child: Wrap( + spacing: 20, + runSpacing: 20, + children: relatedArtists.data! + .map((artist) => ArtistCard(artist)) + .toList(), + ), ); - } - - return Center( - child: Wrap( - spacing: 20, - runSpacing: 20, - children: relatedArtists.data! - .map((artist) => ArtistCard(artist)) - .toList(), - ), - ); - }, - ), - ], + }, + ), + ], + ), ), ), ); diff --git a/lib/pages/home/genres.dart b/lib/pages/home/genres.dart index af0d3836..b4e3c664 100644 --- a/lib/pages/home/genres.dart +++ b/lib/pages/home/genres.dart @@ -7,6 +7,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/genre/category_card.dart'; import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; +import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/shimmers/shimmer_categories.dart'; import 'package:spotube/components/shared/waypoint.dart'; @@ -73,24 +74,30 @@ class GenrePage extends HookConsumerWidget { searchController: searchController, searchFocus: searchFocus, ), - Expanded( - child: ListView.builder( - controller: scrollController, - itemCount: categories.length, - itemBuilder: (context, index) { - return AnimatedCrossFade( - crossFadeState: searchController.text.isEmpty && - index == categories.length - 1 && - categoriesQuery.hasNextPage - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - duration: const Duration(milliseconds: 300), - firstChild: const ShimmerCategories(), - secondChild: CategoryCard(categories[index]), - ); - }, + if (!categoriesQuery.hasPageData) + const ShimmerCategories() + else + Expanded( + child: InterScrollbar( + controller: scrollController, + child: ListView.builder( + controller: scrollController, + itemCount: categories.length, + itemBuilder: (context, index) { + return AnimatedCrossFade( + crossFadeState: searchController.text.isEmpty && + index == categories.length - 1 && + categoriesQuery.hasNextPage + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 300), + firstChild: const ShimmerCategories(), + secondChild: CategoryCard(categories[index]), + ); + }, + ), + ), ), - ), ], ), ), diff --git a/lib/pages/home/personalized.dart b/lib/pages/home/personalized.dart index 29f6ecb5..d6192592 100644 --- a/lib/pages/home/personalized.dart +++ b/lib/pages/home/personalized.dart @@ -6,6 +6,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/album/album_card.dart'; import 'package:spotube/components/playlist/playlist_card.dart'; +import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/shared/shimmers/shimmer_categories.dart'; import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; import 'package:spotube/components/shared/waypoint.dart'; import 'package:spotube/extensions/context.dart'; @@ -95,6 +97,7 @@ class PersonalizedPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { + final controller = useScrollController(); final auth = ref.watch(AuthenticationNotifier.provider); final featuredPlaylistsQuery = useQueries.playlist.featured(ref); final playlists = useMemoized( @@ -107,12 +110,9 @@ class PersonalizedPage extends HookConsumerWidget { final madeForUser = useQueries.views.get(ref, "made-for-x-hub"); final newReleases = useQueries.album.newReleases(ref); - final userArtists = useQueries.artist - .followedByMeAll(ref) - .data - ?.map((s) => s.id!) - .toList() ?? - const []; + final userArtistsQuery = useQueries.artist.followedByMeAll(ref); + final userArtists = + userArtistsQuery.data?.map((s) => s.id!).toList() ?? const []; final albums = useMemoized( () => newReleases.pages @@ -126,37 +126,46 @@ class PersonalizedPage extends HookConsumerWidget { [newReleases.pages], ); - return ListView( - children: [ - PersonalizedItemCard( - playlists: playlists, - title: context.l10n.featured, - hasNextPage: featuredPlaylistsQuery.hasNextPage, - onFetchMore: featuredPlaylistsQuery.fetchNext, - ), - if (auth != null) - PersonalizedItemCard( - albums: albums, - title: context.l10n.new_releases, - hasNextPage: newReleases.hasNextPage, - onFetchMore: newReleases.fetchNext, - ), - ...?madeForUser.data?["content"]?["items"]?.map((item) { - final playlists = item["content"]?["items"] - ?.where((itemL2) => itemL2["type"] == "playlist") - .map((itemL2) => PlaylistSimple.fromJson(itemL2)) - .toList() - .cast() ?? - []; - if (playlists.isEmpty) return const SizedBox.shrink(); - return PersonalizedItemCard( - playlists: playlists, - title: item["name"] ?? "", - hasNextPage: false, - onFetchMore: () {}, - ); - }) - ], + return InterScrollbar( + controller: controller, + child: ListView( + controller: controller, + children: [ + if (!featuredPlaylistsQuery.hasPageData) + const ShimmerCategories() + else + PersonalizedItemCard( + playlists: playlists, + title: context.l10n.featured, + hasNextPage: featuredPlaylistsQuery.hasNextPage, + onFetchMore: featuredPlaylistsQuery.fetchNext, + ), + if (auth != null && + newReleases.hasPageData && + userArtistsQuery.hasData) + PersonalizedItemCard( + albums: albums, + title: context.l10n.new_releases, + hasNextPage: newReleases.hasNextPage, + onFetchMore: newReleases.fetchNext, + ), + ...?madeForUser.data?["content"]?["items"]?.map((item) { + final playlists = item["content"]?["items"] + ?.where((itemL2) => itemL2["type"] == "playlist") + .map((itemL2) => PlaylistSimple.fromJson(itemL2)) + .toList() + .cast() ?? + []; + if (playlists.isEmpty) return const SizedBox.shrink(); + return PersonalizedItemCard( + playlists: playlists, + title: item["name"] ?? "", + hasNextPage: false, + onFetchMore: () {}, + ); + }) + ], + ), ); } } diff --git a/lib/pages/lastfm_login/lastfm_login.dart b/lib/pages/lastfm_login/lastfm_login.dart new file mode 100644 index 00000000..f77d0abb --- /dev/null +++ b/lib/pages/lastfm_login/lastfm_login.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:form_validator/form_validator.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/scrobbler_provider.dart'; + +class LastFMLoginPage extends HookConsumerWidget { + const LastFMLoginPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final theme = Theme.of(context); + final router = GoRouter.of(context); + final scrobblerNotifier = ref.read(scrobblerProvider.notifier); + + final formKey = useMemoized(() => GlobalKey(), []); + final username = useTextEditingController(); + final password = useTextEditingController(); + final passwordVisible = useState(false); + + final isLoading = useState(false); + + return Scaffold( + appBar: const PageWindowTitleBar(leading: BackButton()), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Card( + margin: const EdgeInsets.all(8.0), + child: Padding( + padding: const EdgeInsets.all(16.0).copyWith(top: 8), + child: Form( + key: formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30), + color: const Color.fromARGB(255, 186, 0, 0), + ), + padding: const EdgeInsets.all(12), + child: const Icon( + SpotubeIcons.lastFm, + color: Colors.white, + size: 60, + ), + ), + Text( + "last.fm", + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 10), + Text(context.l10n.login_with_your_lastfm), + const SizedBox(height: 10), + AutofillGroup( + child: Column( + children: [ + TextFormField( + autofillHints: const [ + AutofillHints.username, + AutofillHints.email, + ], + controller: username, + validator: ValidationBuilder().required().build(), + decoration: InputDecoration( + labelText: context.l10n.username, + ), + ), + const SizedBox(height: 10), + TextFormField( + autofillHints: const [ + AutofillHints.password, + ], + controller: password, + validator: ValidationBuilder().required().build(), + obscureText: !passwordVisible.value, + decoration: InputDecoration( + labelText: context.l10n.password, + suffixIcon: IconButton( + icon: Icon( + passwordVisible.value + ? SpotubeIcons.eye + : SpotubeIcons.noEye, + ), + onPressed: () => passwordVisible.value = + !passwordVisible.value, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 10), + FilledButton( + onPressed: isLoading.value + ? null + : () async { + try { + isLoading.value = true; + if (formKey.currentState?.validate() != true) { + return; + } + await scrobblerNotifier.login( + username.text, + password.text, + ); + router.pop(); + } catch (e) { + if (context.mounted) { + showPromptDialog( + context: context, + title: context.l10n + .error("Authentication failed"), + message: e.toString(), + cancelText: null, + ); + } + } finally { + isLoading.value = false; + } + }, + child: Text(context.l10n.login), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 2a2e16ca..33e244b0 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -37,7 +37,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { final genresCollection = useQueries.category.genreSeeds(ref); final limit = useValueNotifier(10); - final market = useValueNotifier(preferences.recommendationMarket); + final market = useValueNotifier(preferences.recommendationMarket); final genres = useState>([]); final artists = useState>([]); @@ -220,7 +220,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { final countrySelector = ValueListenableBuilder( valueListenable: market, builder: (context, value, _) { - return DropdownButtonFormField( + return DropdownButtonFormField( decoration: InputDecoration( labelText: context.l10n.country, labelStyle: textTheme.titleMedium, diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index 653a2263..015685f1 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -18,7 +18,7 @@ typedef PlaylistGenerateResultRouteState = ({ ({List tracks, List artists, List genres})? seeds, RecommendationParameters? parameters, int limit, - String? market, + Market? market, }); class PlaylistGenerateResultPage extends HookConsumerWidget { diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index 1d1237e6..f6eaa5d5 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -104,7 +104,7 @@ class PlainLyrics extends HookConsumerWidget { ? 1.7 : 2, ), - child: Text( + child: SelectableText( lyrics == null && playlist.activeTrack == null ? "No Track being played currently" : lyrics ?? "", diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 9f343539..b2bd4620 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -1,11 +1,11 @@ import 'dart:async'; +import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:internet_connection_checker/internet_connection_checker.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart'; @@ -18,10 +18,10 @@ import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; const rootPaths = { - 0: "/", - 1: "/search", - 2: "/library", - 3: "/lyrics", + "/": 0, + "/search": 1, + "/library": 2, + "/lyrics": 3, }; class RootApp extends HookConsumerWidget { @@ -33,12 +33,12 @@ class RootApp extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final index = useState(0); final isMounted = useIsMounted(); final showingDialogCompleter = useRef(Completer()..complete()); final downloader = ref.watch(downloadManagerProvider); final scaffoldMessenger = ScaffoldMessenger.of(context); final theme = Theme.of(context); + final location = GoRouterState.of(context).matchedLocation; useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -51,44 +51,43 @@ class RootApp extends HookConsumerWidget { }); final subscription = - InternetConnectionChecker().onStatusChange.listen((status) { - switch (status) { - case InternetConnectionStatus.connected: - 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, + QueryClient.connectivity.onConnectivityChanged.listen((status) { + if (status) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Row( + children: [ + Icon( + SpotubeIcons.wifi, + color: theme.colorScheme.onPrimary, + ), + const SizedBox(width: 10), + Text(context.l10n.connection_restored), + ], ), - ); - case InternetConnectionStatus.disconnected: - 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, + 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, + ), + ); } }); @@ -147,13 +146,21 @@ class RootApp extends HookConsumerWidget { return null; }, [backgroundColor]); + void onSelectIndexChanged(int d) { + final invertedRouteMap = + rootPaths.map((key, value) => MapEntry(value, key)); + + if (context.mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + GoRouter.of(context).go(invertedRouteMap[d]!); + }); + } + } + return Scaffold( body: Sidebar( - selectedIndex: index.value, - onSelectedIndexChanged: (i) { - index.value = i; - GoRouter.of(context).go(rootPaths[index.value]!); - }, + selectedIndex: rootPaths[location] ?? 0, + onSelectedIndexChanged: onSelectIndexChanged, child: child, ), extendBody: true, @@ -162,11 +169,8 @@ class RootApp extends HookConsumerWidget { children: [ BottomPlayer(), SpotubeNavigationBar( - selectedIndex: index.value, - onSelectedIndexChanged: (selectedIndex) { - index.value = selectedIndex; - GoRouter.of(context).go(rootPaths[selectedIndex]!); - }, + selectedIndex: rootPaths[location] ?? 0, + onSelectedIndexChanged: onSelectIndexChanged, ), ], ), diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index 7ceecd58..19a9aafa 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -9,6 +9,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/album/album_card.dart'; import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; +import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; @@ -71,7 +72,10 @@ class SearchPage extends HookConsumerWidget { final queries = [searchTrack, searchAlbum, searchPlaylist, searchArtist]; final isFetching = queries.every( - (s) => s.isLoadingPage || s.isRefreshingPage || !s.hasPageData, + (s) => + (!s.hasPageData && !s.hasPageError) || + s.isRefreshingPage || + !s.hasPageData, ) && searchTerm.isNotEmpty; @@ -103,240 +107,246 @@ class SearchPage extends HookConsumerWidget { } } - return SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (tracks.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - context.l10n.songs, - style: theme.textTheme.titleLarge!, + return InterScrollbar( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (tracks.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + context.l10n.songs, + style: theme.textTheme.titleLarge!, + ), ), - ), - if (searchTrack.isLoadingPage) - const CircularProgressIndicator() - else if (searchTrack.hasPageError) - Text( - searchTrack.errors.lastOrNull?.toString() ?? "", - ) - else - ...tracks.mapIndexed((i, track) { - return TrackTile( - index: i, - track: track, - onTap: () async { - final isTrackPlaying = - playlist.activeTrack?.id == track.id; - if (!isTrackPlaying && context.mounted) { - final shouldPlay = (playlist.tracks.length) > 20 - ? await showPromptDialog( - context: context, - title: context.l10n.playing_track( - track.name!, - ), - message: context.l10n.queue_clear_alert( - playlist.tracks.length, - ), - ) - : true; + if (!searchTrack.hasPageData && + !searchTrack.hasPageError && + !searchTrack.isLoadingNextPage) + const CircularProgressIndicator() + else if (searchTrack.hasPageError) + Text( + searchTrack.errors.lastOrNull?.toString() ?? "", + ) + else + ...tracks.mapIndexed((i, track) { + return TrackTile( + index: i, + track: track, + onTap: () async { + final isTrackPlaying = + playlist.activeTrack?.id == track.id; + if (!isTrackPlaying && context.mounted) { + final shouldPlay = (playlist.tracks.length) > 20 + ? await showPromptDialog( + context: context, + title: context.l10n.playing_track( + track.name!, + ), + message: context.l10n.queue_clear_alert( + playlist.tracks.length, + ), + ) + : true; - if (shouldPlay) { - await playlistNotifier.load( - [track], - autoPlay: true, - ); - } - } - }, - ); - }), - if (searchTrack.hasNextPage && tracks.isNotEmpty) - Center( - child: TextButton( - onPressed: searchTrack.isRefreshingPage - ? null - : () => searchTrack.fetchNext(), - child: searchTrack.isRefreshingPage - ? const CircularProgressIndicator() - : Text(context.l10n.load_more), - ), - ), - if (playlists.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - context.l10n.playlists, - style: theme.textTheme.titleLarge!, - ), - ), - const SizedBox(height: 10), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - scrollbarOrientation: mediaQuery.lgAndUp - ? ScrollbarOrientation.bottom - : ScrollbarOrientation.top, - controller: playlistController, - child: Waypoint( - onTouchEdge: () { - searchPlaylist.fetchNext(); - }, - controller: playlistController, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: playlistController, - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - ...playlists.mapIndexed( - (i, playlist) { - if (i == playlists.length - 1 && - searchPlaylist.hasNextPage) { - return const ShimmerPlaybuttonCard( - count: 1); - } - return PlaylistCard(playlist); - }, - ), - ], - ), - ), - ), - ), - ), - if (searchPlaylist.isLoadingPage) - const CircularProgressIndicator(), - if (searchPlaylist.hasPageError) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - searchPlaylist.errors.lastOrNull?.toString() ?? "", - ), - ), - const SizedBox(height: 20), - if (artists.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - context.l10n.artists, - style: theme.textTheme.titleLarge!, - ), - ), - const SizedBox(height: 10), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - controller: artistController, - child: Waypoint( - controller: artistController, - onTouchEdge: () { - searchArtist.fetchNext(); - }, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: artistController, - child: Row( - children: [ - ...artists.mapIndexed( - (i, artist) { - if (i == artists.length - 1 && - searchArtist.hasNextPage) { - return const ShimmerPlaybuttonCard( - count: 1); - } - return Container( - margin: const EdgeInsets.symmetric( - horizontal: 15), - child: ArtistCard(artist), - ); - }, - ), - ], - ), - ), - ), - ), - ), - if (searchArtist.isLoadingPage) - const CircularProgressIndicator(), - if (searchArtist.hasPageError) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - searchArtist.errors.lastOrNull?.toString() ?? "", - ), - ), - const SizedBox(height: 20), - if (albums.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - context.l10n.albums, - style: theme.textTheme.titleLarge!, - ), - ), - const SizedBox(height: 10), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - controller: albumController, - child: Waypoint( - controller: albumController, - onTouchEdge: () { - searchAlbum.fetchNext(); - }, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: albumController, - child: Row( - children: [ - ...albums.mapIndexed((i, album) { - if (i == albums.length - 1 && - searchAlbum.hasNextPage) { - return const ShimmerPlaybuttonCard(count: 1); - } - return AlbumCard( - TypeConversionUtils.simpleAlbum_X_Album( - album, - ), + if (shouldPlay) { + await playlistNotifier.load( + [track], + autoPlay: true, ); - }), - ], + } + } + }, + ); + }), + if (searchTrack.hasNextPage && tracks.isNotEmpty) + Center( + child: TextButton( + onPressed: searchTrack.isLoadingNextPage + ? null + : () => searchTrack.fetchNext(), + child: searchTrack.isLoadingNextPage + ? const CircularProgressIndicator() + : Text(context.l10n.load_more), + ), + ), + if (playlists.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + context.l10n.playlists, + style: theme.textTheme.titleLarge!, + ), + ), + const SizedBox(height: 10), + ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }, + ), + child: Scrollbar( + scrollbarOrientation: mediaQuery.lgAndUp + ? ScrollbarOrientation.bottom + : ScrollbarOrientation.top, + controller: playlistController, + child: Waypoint( + onTouchEdge: () { + searchPlaylist.fetchNext(); + }, + controller: playlistController, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: playlistController, + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + ...playlists.mapIndexed( + (i, playlist) { + if (i == playlists.length - 1 && + searchPlaylist.hasNextPage) { + return const ShimmerPlaybuttonCard( + count: 1); + } + return PlaylistCard(playlist); + }, + ), + ], + ), ), ), ), ), - ), - if (searchAlbum.isLoadingPage) - const CircularProgressIndicator(), - if (searchAlbum.hasPageError) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - searchAlbum.errors.lastOrNull?.toString() ?? "", + if (!searchPlaylist.hasPageData && + !searchPlaylist.hasPageError) + const CircularProgressIndicator(), + if (searchPlaylist.hasPageError) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + searchPlaylist.errors.lastOrNull?.toString() ?? "", + ), + ), + const SizedBox(height: 20), + if (artists.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + context.l10n.artists, + style: theme.textTheme.titleLarge!, + ), + ), + const SizedBox(height: 10), + ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }, + ), + child: Scrollbar( + controller: artistController, + child: Waypoint( + controller: artistController, + onTouchEdge: () { + searchArtist.fetchNext(); + }, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: artistController, + child: Row( + children: [ + ...artists.mapIndexed( + (i, artist) { + if (i == artists.length - 1 && + searchArtist.hasNextPage) { + return const ShimmerPlaybuttonCard( + count: 1); + } + return Container( + margin: const EdgeInsets.symmetric( + horizontal: 15), + child: ArtistCard(artist), + ); + }, + ), + ], + ), + ), + ), ), ), - ], + if (!searchArtist.hasPageData && !searchArtist.hasPageError) + const CircularProgressIndicator(), + if (searchArtist.hasPageError) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + searchArtist.errors.lastOrNull?.toString() ?? "", + ), + ), + const SizedBox(height: 20), + if (albums.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + context.l10n.albums, + style: theme.textTheme.titleLarge!, + ), + ), + const SizedBox(height: 10), + ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }, + ), + child: Scrollbar( + controller: albumController, + child: Waypoint( + controller: albumController, + onTouchEdge: () { + searchAlbum.fetchNext(); + }, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: albumController, + child: Row( + children: [ + ...albums.mapIndexed((i, album) { + if (i == albums.length - 1 && + searchAlbum.hasNextPage) { + return const ShimmerPlaybuttonCard( + count: 1); + } + return AlbumCard( + TypeConversionUtils.simpleAlbum_X_Album( + album, + ), + ); + }), + ], + ), + ), + ), + ), + ), + if (!searchAlbum.hasPageData && !searchAlbum.hasPageError) + const CircularProgressIndicator(), + if (searchAlbum.hasPageError) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + searchAlbum.errors.lastOrNull?.toString() ?? "", + ), + ), + ], + ), ), ), ), @@ -359,7 +369,8 @@ class SearchPage extends HookConsumerWidget { ), color: theme.scaffoldBackgroundColor, child: TextField( - autofocus: queries.none((s) => s.hasPageData), + autofocus: + queries.none((s) => s.hasPageData && !s.hasPageError), decoration: InputDecoration( prefixIcon: const Icon(SpotubeIcons.search), hintText: "${context.l10n.search}...", diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index a41a38eb..69800633 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -5,6 +5,7 @@ import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/blacklist_provider.dart'; @@ -56,25 +57,27 @@ class BlackListPage extends HookConsumerWidget { ), ), ), - ListView.builder( - shrinkWrap: true, - itemCount: filteredBlacklist.length, - itemBuilder: (context, index) { - final item = filteredBlacklist.elementAt(index); - return ListTile( - leading: Text("${index + 1}."), - title: Text("${item.name} (${item.type.name})"), - subtitle: Text(item.id), - trailing: IconButton( - icon: Icon(SpotubeIcons.trash, color: Colors.red[400]), - onPressed: () { - ref - .read(BlackListNotifier.provider.notifier) - .remove(filteredBlacklist.elementAt(index)); - }, - ), - ); - }, + InterScrollbar( + child: ListView.builder( + shrinkWrap: true, + itemCount: filteredBlacklist.length, + itemBuilder: (context, index) { + final item = filteredBlacklist.elementAt(index); + return ListTile( + leading: Text("${index + 1}."), + title: Text("${item.name} (${item.type.name})"), + subtitle: Text(item.id), + trailing: IconButton( + icon: Icon(SpotubeIcons.trash, color: Colors.red[400]), + onPressed: () { + ref + .read(BlackListNotifier.provider.notifier) + .remove(filteredBlacklist.elementAt(index)); + }, + ), + ); + }, + ), ), ], ), diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart index 3bc1319f..91d87fbb 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/logger.dart'; @@ -91,47 +92,49 @@ class LogsPage extends HookWidget { ], ), body: SafeArea( - child: ListView.builder( - itemCount: logs.value.length, - itemBuilder: (context, index) { - final log = logs.value[index]; - return Stack( - children: [ - SectionCardWithHeading( - heading: log.date.toString(), - children: [ - Padding( - padding: const EdgeInsets.all(12.0), - child: SelectableText(log.body), - ), - ], - ), - Positioned( - right: 10, - top: 0, - child: IconButton( - icon: const Icon(SpotubeIcons.clipboard), - onPressed: () async { - await Clipboard.setData( - ClipboardData(text: log.body), - ); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.copied_to_clipboard( - log.date.toString(), + child: InterScrollbar( + child: ListView.builder( + itemCount: logs.value.length, + itemBuilder: (context, index) { + final log = logs.value[index]; + return Stack( + children: [ + SectionCardWithHeading( + heading: log.date.toString(), + children: [ + Padding( + padding: const EdgeInsets.all(12.0), + child: SelectableText(log.body), + ), + ], + ), + Positioned( + right: 10, + top: 0, + child: IconButton( + icon: const Icon(SpotubeIcons.clipboard), + onPressed: () async { + await Clipboard.setData( + ClipboardData(text: log.body), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.copied_to_clipboard( + log.date.toString(), + ), ), ), - ), - ); - } - }, + ); + } + }, + ), ), - ), - ], - ); - }, + ], + ); + }, + ), ), ), ); diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart new file mode 100644 index 00000000..83740866 --- /dev/null +++ b/lib/pages/settings/sections/accounts.dart @@ -0,0 +1,128 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/scrobbler_provider.dart'; + +class SettingsAccountSection extends HookConsumerWidget { + const SettingsAccountSection({Key? key}) : super(key: key); + + @override + Widget build(context, ref) { + final theme = Theme.of(context); + final auth = ref.watch(AuthenticationNotifier.provider); + final scrobbler = ref.watch(scrobblerProvider); + final router = GoRouter.of(context); + + final logoutBtnStyle = FilledButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ); + + return SectionCardWithHeading( + heading: context.l10n.account, + children: [ + if (auth == null) + LayoutBuilder(builder: (context, constrains) { + return ListTile( + leading: Icon( + SpotubeIcons.spotify, + color: theme.colorScheme.primary, + ), + title: Align( + alignment: Alignment.centerLeft, + child: AutoSizeText( + context.l10n.login_with_spotify, + maxLines: 1, + style: TextStyle( + color: theme.colorScheme.primary, + ), + ), + ), + onTap: constrains.mdAndUp + ? null + : () { + router.push("/login"); + }, + trailing: constrains.smAndDown + ? null + : FilledButton( + onPressed: () { + router.push("/login"); + }, + style: ButtonStyle( + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25.0), + ), + ), + ), + child: Text( + context.l10n.connect_with_spotify.toUpperCase(), + ), + ), + ); + }) + else + Builder(builder: (context) { + return ListTile( + leading: const Icon(SpotubeIcons.spotify), + title: SizedBox( + height: 50, + width: 180, + child: Align( + alignment: Alignment.centerLeft, + child: AutoSizeText( + context.l10n.logout_of_this_account, + maxLines: 1, + ), + ), + ), + trailing: FilledButton( + style: logoutBtnStyle, + onPressed: () async { + ref.read(AuthenticationNotifier.provider.notifier).logout(); + GoRouter.of(context).pop(); + }, + child: Text(context.l10n.logout), + ), + ); + }), + if (scrobbler == null) + ListTile( + leading: const Icon(SpotubeIcons.lastFm), + title: Text(context.l10n.login_with_lastfm), + subtitle: Text(context.l10n.scrobble_to_lastfm), + trailing: FilledButton.icon( + icon: const Icon(SpotubeIcons.lastFm), + label: Text(context.l10n.connect), + onPressed: () { + router.push("/lastfm-login"); + }, + style: FilledButton.styleFrom( + backgroundColor: const Color.fromARGB(255, 186, 0, 0), + foregroundColor: Colors.white, + ), + ), + ) + else + ListTile( + leading: const Icon(SpotubeIcons.lastFm), + title: Text(context.l10n.disconnect_lastfm), + trailing: FilledButton( + onPressed: () { + ref.read(scrobblerProvider.notifier).logout(); + }, + style: logoutBtnStyle, + child: Text(context.l10n.disconnect), + ), + ), + ], + ); + } +} diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index a6efdc8f..e319997a 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -9,6 +9,7 @@ import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:piped_client/piped_client.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/collections/env.dart'; import 'package:spotube/collections/language_codes.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -16,13 +17,14 @@ import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart'; import 'package:spotube/components/shared/adaptive/adaptive_list_tile.dart'; import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; +import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/collections/spotify_markets.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/models/matched_track.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/pages/settings/sections/accounts.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/provider/piped_instances_provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -32,8 +34,7 @@ class SettingsPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final UserPreferences preferences = ref.watch(userPreferencesProvider); - final auth = ref.watch(AuthenticationNotifier.provider); + final preferences = ref.watch(userPreferencesProvider); final theme = Theme.of(context); final mediaQuery = MediaQuery.of(context); @@ -46,10 +47,14 @@ class SettingsPage extends HookConsumerWidget { }, []); final pickDownloadLocation = useCallback(() async { - final dirStr = await getDirectoryPath( + String? dirStr = await getDirectoryPath( initialDirectory: preferences.downloadLocation, ); if (dirStr == null) return; + if (DesktopTools.platform.isAndroid && dirStr.startsWith("content://")) { + dirStr = + "/storage/emulated/0/${Uri.decodeFull(dirStr).split("primary:").last}"; + } preferences.setDownloadLocation(dirStr); }, [preferences.downloadLocation]); @@ -66,526 +71,499 @@ class SettingsPage extends HookConsumerWidget { Flexible( child: Container( constraints: const BoxConstraints(maxWidth: 1366), - child: ListView( - children: [ - SectionCardWithHeading( - heading: context.l10n.account, - children: [ - if (auth == null) - LayoutBuilder(builder: (context, constrains) { - return ListTile( - leading: Icon( - SpotubeIcons.login, - color: theme.colorScheme.primary, - ), - title: Align( - alignment: Alignment.centerLeft, - child: AutoSizeText( - context.l10n.login_with_spotify, - maxLines: 1, - style: TextStyle( - color: theme.colorScheme.primary, - ), - ), - ), - onTap: constrains.mdAndUp - ? null - : () { - GoRouter.of(context).push("/login"); - }, - trailing: constrains.smAndDown - ? null - : FilledButton( - onPressed: () { - GoRouter.of(context).push("/login"); - }, - style: ButtonStyle( - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(25.0), - ), - ), - ), - child: Text( - context.l10n.connect_with_spotify - .toUpperCase(), - ), - ), - ); - }) - else - Builder(builder: (context) { - return ListTile( - leading: const Icon(SpotubeIcons.logout), - title: SizedBox( - height: 50, - width: 180, - child: Align( - alignment: Alignment.centerLeft, - child: AutoSizeText( - context.l10n.logout_of_this_account, - maxLines: 1, - ), - ), - ), - trailing: FilledButton( - style: ButtonStyle( - backgroundColor: - MaterialStateProperty.all(Colors.red), - foregroundColor: - MaterialStateProperty.all(Colors.white), - ), - onPressed: () async { - ref - .read(AuthenticationNotifier - .provider.notifier) - .logout(); - GoRouter.of(context).pop(); - }, - child: Text(context.l10n.logout), - ), - ); - }), - ], - ), - SectionCardWithHeading( - heading: context.l10n.language_region, - children: [ - AdaptiveSelectTile( - value: preferences.locale, - onChanged: (locale) { - if (locale == null) return; - preferences.setLocale(locale); - }, - title: Text(context.l10n.language), - secondary: const Icon(SpotubeIcons.language), - options: [ - DropdownMenuItem( - value: const Locale("system", "system"), - child: Text(context.l10n.system_default), - ), - for (final locale in L10n.all) - DropdownMenuItem( - value: locale, - child: Builder(builder: (context) { - final isoCodeName = - LanguageLocals.getDisplayLanguage( - locale.languageCode, - ); - return Text( - "${isoCodeName.name} (${isoCodeName.nativeName})", - ); - }), - ), - ], - ), - AdaptiveSelectTile( - breakLayout: mediaQuery.lgAndUp, - secondary: const Icon(SpotubeIcons.shoppingBag), - title: Text(context.l10n.market_place_region), - subtitle: Text(context.l10n.recommendation_country), - value: preferences.recommendationMarket, - onChanged: (value) { - if (value == null) return; - preferences.setRecommendationMarket(value); - }, - options: spotifyMarkets - .map( - (country) => DropdownMenuItem( - value: country.$1, - child: Text(country.$2), - ), - ) - .toList(), - ), - ], - ), - SectionCardWithHeading( - heading: context.l10n.appearance, - children: [ - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.dashboard), - title: Text(context.l10n.layout_mode), - subtitle: Text(context.l10n.override_layout_settings), - value: preferences.layoutMode, - onChanged: (value) { - if (value != null) { - preferences.setLayoutMode(value); - } - }, - options: [ - DropdownMenuItem( - value: LayoutMode.adaptive, - child: Text(context.l10n.adaptive), - ), - DropdownMenuItem( - value: LayoutMode.compact, - child: Text(context.l10n.compact), - ), - DropdownMenuItem( - value: LayoutMode.extended, - child: Text(context.l10n.extended), - ), - ], - ), - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.darkMode), - title: Text(context.l10n.theme), - value: preferences.themeMode, - options: [ - DropdownMenuItem( - value: ThemeMode.dark, - child: Text(context.l10n.dark), - ), - DropdownMenuItem( - value: ThemeMode.light, - child: Text(context.l10n.light), - ), - DropdownMenuItem( - value: ThemeMode.system, - child: Text(context.l10n.system), - ), - ], - onChanged: (value) { - if (value != null) { - preferences.setThemeMode(value); - } - }, - ), - ListTile( - leading: const Icon(SpotubeIcons.palette), - title: Text(context.l10n.accent_color), - contentPadding: const EdgeInsets.symmetric( - horizontal: 15, - vertical: 5, - ), - trailing: ColorTile.compact( - color: preferences.accentColorScheme, - onPressed: pickColorScheme(), - isActive: true, - ), - onTap: pickColorScheme(), - ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.colorSync), - title: Text(context.l10n.sync_album_color), - subtitle: - Text(context.l10n.sync_album_color_description), - value: preferences.albumColorSync, - onChanged: preferences.setAlbumColorSync, - ), - ], - ), - SectionCardWithHeading( - heading: context.l10n.playback, - children: [ - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.audioQuality), - title: Text(context.l10n.audio_quality), - value: preferences.audioQuality, - options: [ - DropdownMenuItem( - value: AudioQuality.high, - child: Text(context.l10n.high), - ), - DropdownMenuItem( - value: AudioQuality.low, - child: Text(context.l10n.low), - ), - ], - onChanged: (value) { - if (value != null) { - preferences.setAudioQuality(value); - } - }, - ), - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.api), - title: Text(context.l10n.youtube_api_type), - value: preferences.youtubeApiType, - options: YoutubeApiType.values - .map((e) => DropdownMenuItem( - value: e, - child: Text(e.label), - )) - .toList(), - onChanged: (value) { - if (value == null) return; - preferences.setYoutubeApiType(value); - }, - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: preferences.youtubeApiType == - YoutubeApiType.youtube - ? const SizedBox.shrink() - : Consumer(builder: (context, ref, child) { - final instanceList = - ref.watch(pipedInstancesFutureProvider); - - return instanceList.when( - data: (data) { - return AdaptiveSelectTile( - secondary: - const Icon(SpotubeIcons.piped), - title: - Text(context.l10n.piped_instance), - subtitle: RichText( - text: TextSpan( - children: [ - TextSpan( - text: context - .l10n.piped_description, - style: - theme.textTheme.bodyMedium, - ), - const TextSpan(text: "\n"), - TextSpan( - text: - context.l10n.piped_warning, - style: - theme.textTheme.labelMedium, - ) - ], - ), - ), - value: preferences.pipedInstance, - showValueWhenUnfolded: false, - options: data - .sortedBy((e) => e.name) - .map( - (e) => DropdownMenuItem( - value: e.apiUrl, - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: - "${e.name.trim()}\n", - style: Theme.of(context) - .textTheme - .labelLarge, - ), - TextSpan( - text: e.locations - .map( - countryCodeToEmoji) - .join(""), - style: GoogleFonts - .notoColorEmoji(), - ), - ], - ), - ), - ), - ) - .toList(), - onChanged: (value) { - if (value != null) { - preferences.setPipedInstance(value); - } - }, - ); - }, - loading: () => const Center( - child: CircularProgressIndicator(), - ), - error: (error, stackTrace) => - Text(error.toString()), - ); - }), - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: preferences.youtubeApiType == - YoutubeApiType.youtube - ? const SizedBox.shrink() - : AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.search), - title: Text(context.l10n.search_mode), - value: preferences.searchMode, - options: SearchMode.values - .map((e) => DropdownMenuItem( - value: e, - child: Text(e.label), - )) - .toList(), - onChanged: (value) { - if (value == null) return; - preferences.setSearchMode(value); - }, - ), - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: preferences.searchMode == - SearchMode.youtubeMusic && - preferences.youtubeApiType == - YoutubeApiType.piped - ? const SizedBox.shrink() - : SwitchListTile( - secondary: const Icon(SpotubeIcons.skip), - title: Text(context.l10n.skip_non_music), - value: preferences.skipNonMusic, - onChanged: (state) { - preferences.setSkipNonMusic(state); - }, - ), - ), - ListTile( - leading: const Icon(SpotubeIcons.playlistRemove), - title: Text(context.l10n.blacklist), - subtitle: Text(context.l10n.blacklist_description), - onTap: () { - GoRouter.of(context).push("/settings/blacklist"); - }, - trailing: const Icon(SpotubeIcons.angleRight), - ), - ], - ), - SectionCardWithHeading( - heading: context.l10n.downloads, - children: [ - ListTile( - leading: const Icon(SpotubeIcons.download), - title: Text(context.l10n.download_location), - subtitle: Text(preferences.downloadLocation), - trailing: FilledButton( - onPressed: pickDownloadLocation, - child: const Icon(SpotubeIcons.folder), - ), - onTap: pickDownloadLocation, - ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.lyrics), - title: Text(context.l10n.download_lyrics), - value: preferences.saveTrackLyrics, - onChanged: (state) { - preferences.setSaveTrackLyrics(state); - }, - ), - ], - ), - if (DesktopTools.platform.isDesktop) + child: InterScrollbar( + child: ListView( + children: [ + const SettingsAccountSection(), SectionCardWithHeading( - heading: context.l10n.desktop, + heading: context.l10n.language_region, children: [ - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.close), - title: Text(context.l10n.close_behavior), - value: preferences.closeBehavior, + AdaptiveSelectTile( + value: preferences.locale, + onChanged: (locale) { + if (locale == null) return; + preferences.setLocale(locale); + }, + title: Text(context.l10n.language), + secondary: const Icon(SpotubeIcons.language), options: [ DropdownMenuItem( - value: CloseBehavior.close, - child: Text(context.l10n.close), + value: const Locale("system", "system"), + child: Text(context.l10n.system_default), + ), + for (final locale in L10n.all) + DropdownMenuItem( + value: locale, + child: Builder(builder: (context) { + final isoCodeName = + LanguageLocals.getDisplayLanguage( + locale.languageCode, + ); + return Text( + "${isoCodeName.name} (${isoCodeName.nativeName})", + ); + }), + ), + ], + ), + AdaptiveSelectTile( + breakLayout: mediaQuery.lgAndUp, + secondary: const Icon(SpotubeIcons.shoppingBag), + title: Text(context.l10n.market_place_region), + subtitle: Text(context.l10n.recommendation_country), + value: preferences.recommendationMarket, + onChanged: (value) { + if (value == null) return; + preferences.setRecommendationMarket(value); + }, + options: spotifyMarkets + .map( + (country) => DropdownMenuItem( + value: country.$1, + child: Text(country.$2), + ), + ) + .toList(), + ), + ], + ), + SectionCardWithHeading( + heading: context.l10n.appearance, + children: [ + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.dashboard), + title: Text(context.l10n.layout_mode), + subtitle: + Text(context.l10n.override_layout_settings), + value: preferences.layoutMode, + onChanged: (value) { + if (value != null) { + preferences.setLayoutMode(value); + } + }, + options: [ + DropdownMenuItem( + value: LayoutMode.adaptive, + child: Text(context.l10n.adaptive), ), DropdownMenuItem( - value: CloseBehavior.minimizeToTray, - child: Text(context.l10n.minimize_to_tray), + value: LayoutMode.compact, + child: Text(context.l10n.compact), + ), + DropdownMenuItem( + value: LayoutMode.extended, + child: Text(context.l10n.extended), + ), + ], + ), + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.darkMode), + title: Text(context.l10n.theme), + value: preferences.themeMode, + options: [ + DropdownMenuItem( + value: ThemeMode.dark, + child: Text(context.l10n.dark), + ), + DropdownMenuItem( + value: ThemeMode.light, + child: Text(context.l10n.light), + ), + DropdownMenuItem( + value: ThemeMode.system, + child: Text(context.l10n.system), ), ], onChanged: (value) { if (value != null) { - preferences.setCloseBehavior(value); + preferences.setThemeMode(value); } }, ), SwitchListTile( - secondary: const Icon(SpotubeIcons.tray), - title: Text(context.l10n.show_tray_icon), - value: preferences.showSystemTrayIcon, - onChanged: preferences.setShowSystemTrayIcon, + secondary: const Icon(SpotubeIcons.amoled), + title: Text(context.l10n.use_amoled_mode), + subtitle: Text(context.l10n.pitch_dark_theme), + value: preferences.amoledDarkTheme, + onChanged: preferences.setAmoledDarkTheme, + ), + ListTile( + leading: const Icon(SpotubeIcons.palette), + title: Text(context.l10n.accent_color), + contentPadding: const EdgeInsets.symmetric( + horizontal: 15, + vertical: 5, + ), + trailing: ColorTile.compact( + color: preferences.accentColorScheme, + onPressed: pickColorScheme(), + isActive: true, + ), + onTap: pickColorScheme(), ), SwitchListTile( - secondary: const Icon(SpotubeIcons.window), - title: Text(context.l10n.use_system_title_bar), - value: preferences.systemTitleBar, - onChanged: preferences.setSystemTitleBar, + secondary: const Icon(SpotubeIcons.colorSync), + title: Text(context.l10n.sync_album_color), + subtitle: + Text(context.l10n.sync_album_color_description), + value: preferences.albumColorSync, + onChanged: preferences.setAlbumColorSync, ), ], ), - if (!kIsWeb) SectionCardWithHeading( - heading: context.l10n.developers, + heading: context.l10n.playback, + children: [ + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.audioQuality), + title: Text(context.l10n.audio_quality), + value: preferences.audioQuality, + options: [ + DropdownMenuItem( + value: AudioQuality.high, + child: Text(context.l10n.high), + ), + DropdownMenuItem( + value: AudioQuality.low, + child: Text(context.l10n.low), + ), + ], + onChanged: (value) { + if (value != null) { + preferences.setAudioQuality(value); + } + }, + ), + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.api), + title: Text(context.l10n.youtube_api_type), + value: preferences.youtubeApiType, + options: YoutubeApiType.values + .map((e) => DropdownMenuItem( + value: e, + child: Text(e.label), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + preferences.setYoutubeApiType(value); + }, + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: preferences.youtubeApiType == + YoutubeApiType.youtube + ? const SizedBox.shrink() + : Consumer(builder: (context, ref, child) { + final instanceList = + ref.watch(pipedInstancesFutureProvider); + + return instanceList.when( + data: (data) { + return AdaptiveSelectTile( + secondary: + const Icon(SpotubeIcons.piped), + title: + Text(context.l10n.piped_instance), + subtitle: RichText( + text: TextSpan( + children: [ + TextSpan( + text: context + .l10n.piped_description, + style: theme + .textTheme.bodyMedium, + ), + const TextSpan(text: "\n"), + TextSpan( + text: context + .l10n.piped_warning, + style: theme + .textTheme.labelMedium, + ) + ], + ), + ), + value: preferences.pipedInstance, + showValueWhenUnfolded: false, + options: data + .sortedBy((e) => e.name) + .map( + (e) => DropdownMenuItem( + value: e.apiUrl, + child: RichText( + text: TextSpan( + children: [ + TextSpan( + text: + "${e.name.trim()}\n", + style: theme.textTheme + .labelLarge, + ), + TextSpan( + text: e.locations + .map( + countryCodeToEmoji) + .join(""), + style: GoogleFonts + .notoColorEmoji(), + ), + ], + ), + ), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + preferences + .setPipedInstance(value); + } + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stackTrace) => + Text(error.toString()), + ); + }), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: preferences.youtubeApiType == + YoutubeApiType.youtube + ? const SizedBox.shrink() + : AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.search), + title: Text(context.l10n.search_mode), + value: preferences.searchMode, + options: SearchMode.values + .map((e) => DropdownMenuItem( + value: e, + child: Text(e.label), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + preferences.setSearchMode(value); + }, + ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: preferences.searchMode == + SearchMode.youtubeMusic && + preferences.youtubeApiType == + YoutubeApiType.piped + ? const SizedBox.shrink() + : SwitchListTile( + secondary: const Icon(SpotubeIcons.skip), + title: Text(context.l10n.skip_non_music), + value: preferences.skipNonMusic, + onChanged: (state) { + preferences.setSkipNonMusic(state); + }, + ), + ), + ListTile( + leading: const Icon(SpotubeIcons.playlistRemove), + title: Text(context.l10n.blacklist), + subtitle: Text(context.l10n.blacklist_description), + onTap: () { + GoRouter.of(context).push("/settings/blacklist"); + }, + trailing: const Icon(SpotubeIcons.angleRight), + ), + SwitchListTile( + secondary: const Icon(SpotubeIcons.normalize), + title: Text(context.l10n.normalize_audio), + value: preferences.normalizeAudio, + onChanged: preferences.setNormalizeAudio, + ), + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.stream), + title: Text(context.l10n.streaming_music_codec), + value: preferences.streamMusicCodec, + showValueWhenUnfolded: false, + options: MusicCodec.values + .map((e) => DropdownMenuItem( + value: e, + child: Text( + e.label, + style: theme.textTheme.labelMedium, + ), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + preferences.setStreamMusicCodec(value); + }, + ), + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.file), + title: Text(context.l10n.download_music_codec), + value: preferences.downloadMusicCodec, + showValueWhenUnfolded: false, + options: MusicCodec.values + .map((e) => DropdownMenuItem( + value: e, + child: Text( + e.label, + style: theme.textTheme.labelMedium, + ), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + preferences.setDownloadMusicCodec(value); + }, + ), + ], + ), + SectionCardWithHeading( + heading: context.l10n.downloads, children: [ ListTile( - leading: const Icon(SpotubeIcons.logs), - title: Text(context.l10n.logs), + leading: const Icon(SpotubeIcons.download), + title: Text(context.l10n.download_location), + subtitle: Text(preferences.downloadLocation), + trailing: FilledButton( + onPressed: pickDownloadLocation, + child: const Icon(SpotubeIcons.folder), + ), + onTap: pickDownloadLocation, + ), + ], + ), + if (DesktopTools.platform.isDesktop) + SectionCardWithHeading( + heading: context.l10n.desktop, + children: [ + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.close), + title: Text(context.l10n.close_behavior), + value: preferences.closeBehavior, + options: [ + DropdownMenuItem( + value: CloseBehavior.close, + child: Text(context.l10n.close), + ), + DropdownMenuItem( + value: CloseBehavior.minimizeToTray, + child: Text(context.l10n.minimize_to_tray), + ), + ], + onChanged: (value) { + if (value != null) { + preferences.setCloseBehavior(value); + } + }, + ), + SwitchListTile( + secondary: const Icon(SpotubeIcons.tray), + title: Text(context.l10n.show_tray_icon), + value: preferences.showSystemTrayIcon, + onChanged: preferences.setShowSystemTrayIcon, + ), + SwitchListTile( + secondary: const Icon(SpotubeIcons.window), + title: Text(context.l10n.use_system_title_bar), + value: preferences.systemTitleBar, + onChanged: preferences.setSystemTitleBar, + ), + ], + ), + if (!kIsWeb) + SectionCardWithHeading( + heading: context.l10n.developers, + children: [ + ListTile( + leading: const Icon(SpotubeIcons.logs), + title: Text(context.l10n.logs), + trailing: const Icon(SpotubeIcons.angleRight), + onTap: () { + GoRouter.of(context).push("/settings/logs"); + }, + ) + ], + ), + SectionCardWithHeading( + heading: context.l10n.about, + children: [ + AdaptiveListTile( + leading: const Icon( + SpotubeIcons.heart, + color: Colors.pink, + ), + title: SizedBox( + height: 50, + width: 200, + child: Align( + alignment: Alignment.centerLeft, + child: AutoSizeText( + context.l10n.u_love_spotube, + maxLines: 1, + style: const TextStyle( + color: Colors.pink, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + trailing: (context, update) => FilledButton( + style: ButtonStyle( + backgroundColor: + MaterialStatePropertyAll(Colors.red[100]), + foregroundColor: const MaterialStatePropertyAll( + Colors.pinkAccent), + padding: const MaterialStatePropertyAll( + EdgeInsets.all(15)), + ), + onPressed: () { + launchUrlString( + "https://opencollective.com/spotube", + mode: LaunchMode.externalApplication, + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(SpotubeIcons.heart), + const SizedBox(width: 5), + Text(context.l10n.please_sponsor), + ], + ), + ), + ), + if (Env.enableUpdateChecker) + SwitchListTile( + secondary: const Icon(SpotubeIcons.update), + title: Text(context.l10n.check_for_updates), + value: preferences.checkUpdate, + onChanged: (checked) => + preferences.setCheckUpdate(checked), + ), + ListTile( + leading: const Icon(SpotubeIcons.info), + title: Text(context.l10n.about_spotube), trailing: const Icon(SpotubeIcons.angleRight), onTap: () { - GoRouter.of(context).push("/settings/logs"); + GoRouter.of(context).push("/settings/about"); }, ) ], ), - SectionCardWithHeading( - heading: context.l10n.about, - children: [ - AdaptiveListTile( - leading: const Icon( - SpotubeIcons.heart, - color: Colors.pink, - ), - title: SizedBox( - height: 50, - width: 200, - child: Align( - alignment: Alignment.centerLeft, - child: AutoSizeText( - context.l10n.u_love_spotube, - maxLines: 1, - style: const TextStyle( - color: Colors.pink, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - trailing: (context, update) => FilledButton( - style: ButtonStyle( - backgroundColor: - MaterialStatePropertyAll(Colors.red[100]), - foregroundColor: const MaterialStatePropertyAll( - Colors.pinkAccent), - padding: const MaterialStatePropertyAll( - EdgeInsets.all(15)), - ), - onPressed: () { - launchUrlString( - "https://opencollective.com/spotube", - mode: LaunchMode.externalApplication, - ); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(SpotubeIcons.heart), - const SizedBox(width: 5), - Text(context.l10n.please_sponsor), - ], - ), - ), + Center( + child: FilledButton( + onPressed: preferences.reset, + child: Text(context.l10n.restore_defaults), ), - if (Env.enableUpdateChecker) - SwitchListTile( - secondary: const Icon(SpotubeIcons.update), - title: Text(context.l10n.check_for_updates), - value: preferences.checkUpdate, - onChanged: (checked) => - preferences.setCheckUpdate(checked), - ), - ListTile( - leading: const Icon(SpotubeIcons.info), - title: Text(context.l10n.about_spotube), - trailing: const Icon(SpotubeIcons.angleRight), - onTap: () { - GoRouter.of(context).push("/settings/about"); - }, - ) - ], - ), - ], + ), + const SizedBox(height: 10), + ], + ), ), ), ), diff --git a/lib/provider/authentication_provider.dart b/lib/provider/authentication_provider.dart index c09cb199..f1cf58ec 100644 --- a/lib/provider/authentication_provider.dart +++ b/lib/provider/authentication_provider.dart @@ -4,6 +4,9 @@ import 'dart:convert'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart'; +import 'package:spotube/collections/routes.dart'; +import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/platform.dart'; @@ -21,24 +24,44 @@ class AuthenticationCredentials { }); static Future fromCookie(String cookie) async { - final Map body = await get( - Uri.parse( - "https://open.spotify.com/get_access_token?reason=transport&productType=web_player", - ), - headers: { - "Cookie": cookie, - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36" - }, - ).then((res) => jsonDecode(res.body)); + try { + final res = await get( + Uri.parse( + "https://open.spotify.com/get_access_token?reason=transport&productType=web_player", + ), + headers: { + "Cookie": cookie, + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36" + }, + ); + final body = jsonDecode(res.body); - return AuthenticationCredentials( - cookie: cookie, - accessToken: body['accessToken'], - expiration: DateTime.fromMillisecondsSinceEpoch( - body['accessTokenExpirationTimestampMs'], - ), - ); + if (res.statusCode >= 400) { + throw Exception( + "Failed to get access token: ${body['error'] ?? res.reasonPhrase}", + ); + } + + return AuthenticationCredentials( + cookie: cookie, + accessToken: body['accessToken'], + expiration: DateTime.fromMillisecondsSinceEpoch( + body['accessTokenExpirationTimestampMs'], + ), + ); + } catch (e) { + if (rootNavigatorKey?.currentContext != null) { + showPromptDialog( + context: rootNavigatorKey!.currentContext!, + title: rootNavigatorKey!.currentContext!.l10n + .error("Authentication Failure"), + message: e.toString(), + cancelText: null, + ); + } + rethrow; + } } factory AuthenticationCredentials.fromJson(Map json) { diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index 78c07270..46c7ee7e 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:io'; -import 'package:catcher/catcher.dart'; +import 'package:catcher_2/catcher_2.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; @@ -40,7 +40,11 @@ class DownloadManagerProvider extends ChangeNotifier { await oldFile.exists()) { await oldFile.rename(savePath); } - if (status != DownloadStatus.completed) return; + if (status != DownloadStatus.completed || + //? WebA audiotagging is not supported yet + //? Although in future by converting weba to opus & then tagging it + //? is possible using vorbis comments + downloadCodec == MusicCodec.weba) return; final file = File(request.path); @@ -89,6 +93,8 @@ class DownloadManagerProvider extends ChangeNotifier { YoutubeEndpoints get yt => ref.read(youtubeProvider); String get downloadDirectory => ref.read(userPreferencesProvider.select((s) => s.downloadLocation)); + MusicCodec get downloadCodec => + ref.read(userPreferencesProvider.select((s) => s.downloadMusicCodec)); int get $downloadCount => dl .getAllDownloads() @@ -123,14 +129,14 @@ class DownloadManagerProvider extends ChangeNotifier { return Uint8List.fromList(bytes); } catch (e, stackTrace) { - Catcher.reportCheckedError(e, stackTrace); + Catcher2.reportCheckedError(e, stackTrace); return null; } } String getTrackFileUrl(Track track) { final name = - "${track.name} - ${TypeConversionUtils.artists_X_String(track.artists ?? [])}.m4a"; + "${track.name} - ${TypeConversionUtils.artists_X_String(track.artists ?? [])}.${downloadCodec.name}"; return join(downloadDirectory, PrimitiveUtils.toSafeFileName(name)); } @@ -166,7 +172,7 @@ class DownloadManagerProvider extends ChangeNotifier { await oldFile.rename("$savePath.old"); } - if (track is SpotubeTrack) { + if (track is SpotubeTrack && track.codec == downloadCodec) { final downloadTask = await dl.addDownload(track.ytUri, savePath); if (downloadTask != null) { $history.add(track); @@ -174,7 +180,7 @@ class DownloadManagerProvider extends ChangeNotifier { } else { $backHistory.add(track); final spotubeTrack = - await SpotubeTrack.fetchFromTrack(track, yt).then((d) { + await SpotubeTrack.fetchFromTrack(track, yt, downloadCodec).then((d) { $backHistory.remove(track); return d; }); @@ -203,7 +209,7 @@ class DownloadManagerProvider extends ChangeNotifier { ); } } catch (e) { - Catcher.reportCheckedError(e, StackTrace.current); + Catcher2.reportCheckedError(e, StackTrace.current); continue; } } diff --git a/lib/provider/proxy_playlist/next_fetcher_mixin.dart b/lib/provider/proxy_playlist/next_fetcher_mixin.dart index fce006b0..61b86d8c 100644 --- a/lib/provider/proxy_playlist/next_fetcher_mixin.dart +++ b/lib/provider/proxy_playlist/next_fetcher_mixin.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/local_track.dart'; +import 'package:spotube/models/logger.dart'; import 'package:spotube/models/matched_track.dart'; import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; @@ -10,6 +11,8 @@ import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/services/supabase.dart'; import 'package:spotube/services/youtube/youtube.dart'; +final logger = getLogger("NextFetcherMixin"); + mixin NextFetcher on StateNotifier { Future> fetchTracks( UserPreferences preferences, @@ -30,6 +33,7 @@ mixin NextFetcher on StateNotifier { final future = SpotubeTrack.fetchFromTrack( track, youtube, + preferences.streamMusicCodec, ); if (i == 0) { return await future; @@ -123,8 +127,8 @@ mixin NextFetcher on StateNotifier { ); } } catch (e, stackTrace) { - debugPrint(e.toString()); - debugPrintStack(stackTrace: stackTrace); + logger.e(e.toString()); + logger.t(stackTrace); } } } diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index 7ab9293a..685a9942 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math'; -import 'package:catcher/catcher.dart'; +import 'package:catcher_2/catcher_2.dart'; import 'package:collection/collection.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -19,6 +19,7 @@ import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/next_fetcher_mixin.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; +import 'package:spotube/provider/scrobbler_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/provider/youtube_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -52,6 +53,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier final Ref ref; late final AudioServices notificationService; + ScrobblerNotifier get scrobbler => ref.read(scrobblerProvider.notifier); UserPreferences get preferences => ref.read(userPreferencesProvider); YoutubeEndpoints get youtube => ref.read(youtubeProvider); ProxyPlaylist get playlist => state; @@ -96,7 +98,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier updatePalette(); } catch (e, stackTrace) { - Catcher.reportCheckedError(e, stackTrace); + Catcher2.reportCheckedError(e, stackTrace); } }); @@ -115,7 +117,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier active: newActiveIndex, ); } catch (e, stackTrace) { - Catcher.reportCheckedError(e, stackTrace); + Catcher2.reportCheckedError(e, stackTrace); } }); @@ -151,7 +153,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull; await removeTrack(oldTrack!.id!); } - Catcher.reportCheckedError(e, stackTrace); + Catcher2.reportCheckedError(e, stackTrace); } finally { isPreSearching.value = false; } @@ -185,28 +187,30 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier ), ); } catch (e) { - currentSegments.value = ( - source: audioPlayer.currentSource!, - segments: [], - ); + if (audioPlayer.currentSource != null) { + currentSegments.value = ( + source: audioPlayer.currentSource!, + segments: [], + ); + } } finally { isFetchingSegments.value = false; } } - final (source: _, :segments) = currentSegments.value!; - // skipping in first 2 second breaks stream - if (segments.isEmpty || position < const Duration(seconds: 3)) return; + if (currentSegments.value == null || + currentSegments.value!.segments.isEmpty || + position < const Duration(seconds: 3)) return; - for (final segment in segments) { + for (final segment in currentSegments.value!.segments) { if (position.inSeconds >= segment.start && position.inSeconds < segment.end) { await audioPlayer.seek(Duration(seconds: segment.end)); } } } catch (e, stackTrace) { - Catcher.reportCheckedError(e, stackTrace); + Catcher2.reportCheckedError(e, stackTrace); } }); }(); @@ -223,7 +227,11 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier final nthFetchedTrack = switch (track.runtimeType) { SpotubeTrack => track as SpotubeTrack, - _ => await SpotubeTrack.fetchFromTrack(track, youtube), + _ => await SpotubeTrack.fetchFromTrack( + track, + youtube, + preferences.streamMusicCodec, + ), }; await audioPlayer.replaceSource( @@ -309,10 +317,12 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier final addableTrack = await SpotubeTrack.fetchFromTrack( tracks.elementAtOrNull(initialIndex) ?? tracks.first, youtube, + preferences.streamMusicCodec, ).catchError((e, stackTrace) { return SpotubeTrack.fetchFromTrack( tracks.elementAtOrNull(initialIndex + 1) ?? tracks.first, youtube, + preferences.streamMusicCodec, ); }); @@ -569,11 +579,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier )); if (res.body == "Not Found") { - Catcher.reportCheckedError( - "[SponsorBlock] no skip segments found for $id\n" - "${res.request?.url}", - StackTrace.current, - ); return List.castFrom([]); } @@ -586,7 +591,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier end, ); }).toList(); - getLogger('getSkipSegments').v( + getLogger('getSkipSegments').t( "[SponsorBlock] successfully fetched skip segments for $id", ); @@ -597,19 +602,37 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier return List.castFrom(segments); } catch (e, stack) { await SkipSegment.box.put(id, []); - Catcher.reportCheckedError(e, stack); + Catcher2.reportCheckedError(e, stack); return List.castFrom([]); } } @override set state(state) { + final hasActiveTrackChanged = super.state.activeTrack is SpotubeTrack + ? state.activeTrack?.id != super.state.activeTrack?.id + : super.state.activeTrack is LocalTrack && + state.activeTrack is LocalTrack + ? (super.state.activeTrack as LocalTrack).path != + (state.activeTrack as LocalTrack).path + : super.state.activeTrack?.id != state.activeTrack?.id; + + final oldTrack = super.state.activeTrack; + super.state = state; if (state.tracks.isEmpty && ref.read(paletteProvider) != null) { ref.read(paletteProvider.notifier).state = null; } else { updatePalette(); } + audioPlayer.position.then((position) { + final isMoreThan30secs = position != null && + (position == Duration.zero || position.inSeconds > 30); + + if (hasActiveTrackChanged && oldTrack != null && isMoreThan30secs) { + scrobbler.scrobble(oldTrack); + } + }); } @override diff --git a/lib/provider/scrobbler_provider.dart b/lib/provider/scrobbler_provider.dart new file mode 100644 index 00000000..5ac3c5a1 --- /dev/null +++ b/lib/provider/scrobbler_provider.dart @@ -0,0 +1,129 @@ +import 'dart:async'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:scrobblenaut/scrobblenaut.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/env.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/utils/persisted_state_notifier.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; + +class ScrobblerState { + final String username; + final String passwordHash; + + final Scrobblenaut scrobblenaut; + + ScrobblerState({ + required this.username, + required this.passwordHash, + required this.scrobblenaut, + }); + + Map toJson() { + return { + 'username': username, + 'passwordHash': passwordHash, + }; + } +} + +class ScrobblerNotifier extends PersistedStateNotifier { + final Scrobblenaut? scrobblenaut; + + /// Directly scrobbling in set state of [ProxyPlaylistNotifier] + /// brings extra latency in playback + final StreamController _scrobbleController = + StreamController.broadcast(); + + ScrobblerNotifier() + : scrobblenaut = null, + super(null, "scrobbler", encrypted: true) { + _scrobbleController.stream.listen((track) async { + try { + await state?.scrobblenaut.track.scrobble( + artist: TypeConversionUtils.artists_X_String(track.artists!), + track: track.name!, + album: track.album!.name!, + chosenByUser: true, + duration: track.duration, + timestamp: DateTime.now().toUtc(), + trackNumber: track.trackNumber, + ); + } catch (e, stackTrace) { + Catcher2.reportCheckedError(e, stackTrace); + } + }); + } + + Future login( + String username, + String password, + ) async { + final lastFm = await LastFM.authenticate( + apiKey: Env.lastFmApiKey, + apiSecret: Env.lastFmApiSecret, + username: username, + password: password, + ); + if (!lastFm.isAuth) throw Exception("Invalid credentials"); + state = ScrobblerState( + username: username, + passwordHash: lastFm.passwordHash!, + scrobblenaut: Scrobblenaut(lastFM: lastFm), + ); + } + + Future logout() async { + state = null; + } + + void scrobble(Track track) { + _scrobbleController.add(track); + } + + Future love(Track track) async { + await state?.scrobblenaut.track.love( + artist: TypeConversionUtils.artists_X_String(track.artists!), + track: track.name!, + ); + } + + Future unlove(Track track) async { + await state?.scrobblenaut.track.unLove( + artist: TypeConversionUtils.artists_X_String(track.artists!), + track: track.name!, + ); + } + + @override + FutureOr fromJson(Map json) async { + if (json.isEmpty) { + return null; + } + + return ScrobblerState( + username: json['username'], + passwordHash: json['passwordHash'], + scrobblenaut: Scrobblenaut( + lastFM: await LastFM.authenticateWithPasswordHash( + apiKey: Env.lastFmApiKey, + apiSecret: Env.lastFmApiSecret, + username: json["username"], + passwordHash: json["passwordHash"], + ), + ), + ); + } + + @override + Map toJson() { + return state?.toJson() ?? {}; + } +} + +final scrobblerProvider = + StateNotifierProvider( + (ref) => ScrobblerNotifier(), +); diff --git a/lib/provider/user_preferences_provider.dart b/lib/provider/user_preferences_provider.dart index cedf6273..3355adb0 100644 --- a/lib/provider/user_preferences_provider.dart +++ b/lib/provider/user_preferences_provider.dart @@ -6,10 +6,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/models/matched_track.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/utils/persisted_change_notifier.dart'; import 'package:spotube/utils/platform.dart'; @@ -38,46 +40,44 @@ enum YoutubeApiType { String get label => name[0].toUpperCase() + name.substring(1); } +enum MusicCodec { + m4a._("M4a (Best for downloaded music)"), + weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"); + + final String label; + const MusicCodec._(this.label); +} + class UserPreferences extends PersistedChangeNotifier { - ThemeMode themeMode; - String recommendationMarket; - bool saveTrackLyrics; - bool checkUpdate; AudioQuality audioQuality; - - SpotubeColor accentColorScheme; bool albumColorSync; - - String downloadLocation; - - LayoutMode layoutMode; - - CloseBehavior closeBehavior; - + bool amoledDarkTheme; + bool checkUpdate; + bool normalizeAudio; bool showSystemTrayIcon; - - Locale locale; - - String pipedInstance; - - SearchMode searchMode; - bool skipNonMusic; - - YoutubeApiType youtubeApiType; - bool systemTitleBar; + CloseBehavior closeBehavior; + late SpotubeColor accentColorScheme; + LayoutMode layoutMode; + Locale locale; + Market recommendationMarket; + SearchMode searchMode; + String downloadLocation; + String pipedInstance; + ThemeMode themeMode; + YoutubeApiType youtubeApiType; + MusicCodec streamMusicCodec; + MusicCodec downloadMusicCodec; final Ref ref; UserPreferences( this.ref, { - required this.recommendationMarket, - required this.themeMode, - required this.layoutMode, - required this.accentColorScheme, + this.recommendationMarket = Market.US, + this.themeMode = ThemeMode.system, + this.layoutMode = LayoutMode.adaptive, this.albumColorSync = true, - this.saveTrackLyrics = false, this.checkUpdate = true, this.audioQuality = AudioQuality.high, this.downloadLocation = "", @@ -89,7 +89,14 @@ class UserPreferences extends PersistedChangeNotifier { this.skipNonMusic = true, this.youtubeApiType = YoutubeApiType.youtube, this.systemTitleBar = false, + this.amoledDarkTheme = false, + this.normalizeAudio = true, + this.streamMusicCodec = MusicCodec.weba, + this.downloadMusicCodec = MusicCodec.m4a, + SpotubeColor? accentColorScheme, }) : super() { + this.accentColorScheme = + accentColorScheme ?? SpotubeColor(Colors.blue.value, name: "Blue"); if (downloadLocation.isEmpty && !kIsWeb) { _getDefaultDownloadDirectory().then( (value) { @@ -99,19 +106,48 @@ class UserPreferences extends PersistedChangeNotifier { } } + void reset() { + setRecommendationMarket(Market.US); + setThemeMode(ThemeMode.system); + setLayoutMode(LayoutMode.adaptive); + setAlbumColorSync(true); + setCheckUpdate(true); + setAudioQuality(AudioQuality.high); + setDownloadLocation(""); + setCloseBehavior(CloseBehavior.close); + setShowSystemTrayIcon(true); + setLocale(const Locale("system", "system")); + setPipedInstance("https://pipedapi.kavin.rocks"); + setSearchMode(SearchMode.youtube); + setSkipNonMusic(true); + setYoutubeApiType(YoutubeApiType.youtube); + setSystemTitleBar(false); + setAmoledDarkTheme(false); + setNormalizeAudio(true); + setAccentColorScheme(SpotubeColor(Colors.blue.value, name: "Blue")); + setStreamMusicCodec(MusicCodec.weba); + setDownloadMusicCodec(MusicCodec.m4a); + } + + void setStreamMusicCodec(MusicCodec codec) { + streamMusicCodec = codec; + notifyListeners(); + updatePersistence(); + } + + void setDownloadMusicCodec(MusicCodec codec) { + downloadMusicCodec = codec; + notifyListeners(); + updatePersistence(); + } + void setThemeMode(ThemeMode mode) { themeMode = mode; notifyListeners(); updatePersistence(); } - void setSaveTrackLyrics(bool shouldSave) { - saveTrackLyrics = shouldSave; - notifyListeners(); - updatePersistence(); - } - - void setRecommendationMarket(String country) { + void setRecommendationMarket(Market country) { recommendationMarket = country; notifyListeners(); updatePersistence(); @@ -203,9 +239,24 @@ class UserPreferences extends PersistedChangeNotifier { void setSystemTitleBar(bool isSystemTitleBar) { systemTitleBar = isSystemTitleBar; - DesktopTools.window.setTitleBarStyle( - systemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, - ); + if (DesktopTools.platform.isDesktop) { + DesktopTools.window.setTitleBarStyle( + systemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, + ); + } + notifyListeners(); + updatePersistence(); + } + + void setAmoledDarkTheme(bool isAmoled) { + amoledDarkTheme = isAmoled; + notifyListeners(); + updatePersistence(); + } + + void setNormalizeAudio(bool normalize) { + normalizeAudio = normalize; + audioPlayer.setAudioNormalization(normalize); notifyListeners(); updatePersistence(); } @@ -224,8 +275,11 @@ class UserPreferences extends PersistedChangeNotifier { @override FutureOr loadFromLocal(Map map) async { - saveTrackLyrics = map["saveTrackLyrics"] ?? false; - recommendationMarket = map["recommendationMarket"] ?? recommendationMarket; + recommendationMarket = Market.values.firstWhere( + (market) => + market.name == (map["recommendationMarket"] ?? recommendationMarket), + orElse: () => Market.US, + ); checkUpdate = map["checkUpdate"] ?? checkUpdate; themeMode = ThemeMode.values[map["themeMode"] ?? 0]; @@ -274,13 +328,27 @@ class UserPreferences extends PersistedChangeNotifier { systemTitleBar = map["systemTitleBar"] ?? systemTitleBar; // updates the title bar setSystemTitleBar(systemTitleBar); + + amoledDarkTheme = map["amoledDarkTheme"] ?? amoledDarkTheme; + + normalizeAudio = map["normalizeAudio"] ?? normalizeAudio; + audioPlayer.setAudioNormalization(normalizeAudio); + + streamMusicCodec = MusicCodec.values.firstWhere( + (codec) => codec.name == map["streamMusicCodec"], + orElse: () => MusicCodec.weba, + ); + + downloadMusicCodec = MusicCodec.values.firstWhere( + (codec) => codec.name == map["downloadMusicCodec"], + orElse: () => MusicCodec.m4a, + ); } @override FutureOr> toMap() { return { - "saveTrackLyrics": saveTrackLyrics, - "recommendationMarket": recommendationMarket, + "recommendationMarket": recommendationMarket.name, "themeMode": themeMode.index, "accentColorScheme": accentColorScheme.toString(), "albumColorSync": albumColorSync, @@ -297,6 +365,10 @@ class UserPreferences extends PersistedChangeNotifier { "skipNonMusic": skipNonMusic, "youtubeApiType": youtubeApiType.name, 'systemTitleBar': systemTitleBar, + "amoledDarkTheme": amoledDarkTheme, + "normalizeAudio": normalizeAudio, + "streamMusicCodec": streamMusicCodec.name, + "downloadMusicCodec": downloadMusicCodec.name, }; } @@ -315,7 +387,7 @@ class UserPreferences extends PersistedChangeNotifier { SearchMode? searchMode, bool? skipNonMusic, YoutubeApiType? youtubeApiType, - String? recommendationMarket, + Market? recommendationMarket, bool? saveTrackLyrics, }) { return UserPreferences( @@ -335,17 +407,10 @@ class UserPreferences extends PersistedChangeNotifier { skipNonMusic: skipNonMusic ?? this.skipNonMusic, youtubeApiType: youtubeApiType ?? this.youtubeApiType, recommendationMarket: recommendationMarket ?? this.recommendationMarket, - saveTrackLyrics: saveTrackLyrics ?? this.saveTrackLyrics, ); } } final userPreferencesProvider = ChangeNotifierProvider( - (ref) => UserPreferences( - ref, - accentColorScheme: SpotubeColor(Colors.blue.value, name: "Blue"), - recommendationMarket: 'US', - themeMode: ThemeMode.system, - layoutMode: LayoutMode.adaptive, - ), + (ref) => UserPreferences(ref), ); diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index f468e87a..c944004c 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -1,4 +1,4 @@ -import 'package:catcher/catcher.dart'; +import 'package:catcher_2/catcher_2.dart'; import 'package:spotube/services/audio_player/mk_state_player.dart'; // import 'package:just_audio/just_audio.dart' as ja; import 'dart:async'; @@ -16,12 +16,16 @@ abstract class AudioPlayerInterface { final MkPlayerWithState _mkPlayer; // final ja.AudioPlayer? _justAudio; - AudioPlayerInterface() : _mkPlayer = MkPlayerWithState() - // _mkPlayer = _mkSupportedPlatform ? MkPlayerWithState() : null, + AudioPlayerInterface() + : _mkPlayer = MkPlayerWithState( + configuration: const mk.PlayerConfiguration( + title: "Spotube", + ), + ) // _justAudio = !_mkSupportedPlatform ? ja.AudioPlayer() : null { _mkPlayer.stream.error.listen((event) { - Catcher.reportCheckedError(event, StackTrace.current); + Catcher2.reportCheckedError(event, StackTrace.current); }); } diff --git a/lib/services/audio_player/audio_player_impl.dart b/lib/services/audio_player/audio_player_impl.dart index 417cf4b3..4576ce8d 100644 --- a/lib/services/audio_player/audio_player_impl.dart +++ b/lib/services/audio_player/audio_player_impl.dart @@ -142,6 +142,7 @@ class SpotubeAudioPlayer extends AudioPlayerInterface String? get currentSource { // if (mkSupportedPlatform) { + if (_mkPlayer.playlist.index == -1) return null; return _mkPlayer.playlist.medias .elementAtOrNull(_mkPlayer.playlist.index) ?.uri; @@ -312,4 +313,8 @@ class SpotubeAudioPlayer extends AudioPlayerInterface // await _justAudio!.setLoopMode(loop.toLoopMode()); // } } + + Future setAudioNormalization(bool normalize) async { + await _mkPlayer.setAudioNormalization(normalize); + } } diff --git a/lib/services/audio_player/mk_state_player.dart b/lib/services/audio_player/mk_state_player.dart index 945b2ce0..386b0a0e 100644 --- a/lib/services/audio_player/mk_state_player.dart +++ b/lib/services/audio_player/mk_state_player.dart @@ -1,10 +1,9 @@ import 'dart:async'; -import 'package:catcher/catcher.dart'; +import 'package:catcher_2/catcher_2.dart'; import 'package:collection/collection.dart'; import 'package:media_kit/media_kit.dart'; // ignore: implementation_imports -import 'package:media_kit/src/models/playable.dart'; import 'package:spotube/services/audio_player/playback_state.dart'; /// MediaKit [Player] by default doesn't have a state stream. @@ -46,7 +45,6 @@ class MkPlayerWithState extends Player { if (!isCompleted) return; _playerStateStream.add(AudioPlaybackState.completed); - if (loopMode == PlaylistMode.single) { await super.open(_playlist!.medias[_playlist!.index], play: true); } else { @@ -54,7 +52,7 @@ class MkPlayerWithState extends Player { await Future.delayed(const Duration(milliseconds: 250), play); } } catch (e, stackTrace) { - Catcher.reportCheckedError(e, stackTrace); + Catcher2.reportCheckedError(e, stackTrace); } }), stream.playlist.listen((event) { @@ -63,7 +61,7 @@ class MkPlayerWithState extends Player { } }), stream.error.listen((event) { - Catcher.reportCheckedError('[MediaKitError] \n$event', null); + Catcher2.reportCheckedError('[MediaKitError] \n$event', null); }), ]; } @@ -124,7 +122,9 @@ class MkPlayerWithState extends Player { _loopModeStream.add(playlistMode); } + @override Future stop() async { + await super.stop(); await pause(); await seek(Duration.zero); @@ -165,10 +165,22 @@ class MkPlayerWithState extends Player { final isLast = _playlist!.index == _playlist!.medias.length - 1; - if (loopMode == PlaylistMode.loop && isLast) { - playlist = _playlist!.copyWith(index: 0); - return super.open(_playlist!.medias[_playlist!.index], play: true); - } else if (!isLast) { + if (isLast) { + switch (loopMode) { + case PlaylistMode.loop: + playlist = _playlist!.copyWith(index: 0); + super.open(_playlist!.medias[_playlist!.index], play: true); + break; + case PlaylistMode.none: + // Fixes auto-repeating the last track + await super.stop(); + await Future.delayed(const Duration(seconds: 2), () { + super.open(_playlist!.medias[_playlist!.index], play: false); + }); + break; + default: + } + } else { playlist = _playlist!.copyWith(index: _playlist!.index + 1); return super.open(_playlist!.medias[_playlist!.index], play: true); @@ -317,4 +329,14 @@ class MkPlayerWithState extends Player { index: newMedias.indexOf(_playlist!.medias[_playlist!.index]), ); } + + NativePlayer get nativePlayer => platform as NativePlayer; + + Future setAudioNormalization(bool normalize) async { + if (normalize) { + await nativePlayer.setProperty('af', 'dynaudnorm=g=5:f=250:r=0.9:p=0.5'); + } else { + await nativePlayer.setProperty('af', ''); + } + } } diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index 6d6c9d43..645548fb 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -4,8 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/audio_services/linux_audio_service.dart'; import 'package:spotube/services/audio_services/mobile_audio_service.dart'; import 'package:spotube/services/audio_services/windows_audio_service.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -13,49 +11,33 @@ import 'package:spotube/utils/type_conversion_utils.dart'; class AudioServices { final MobileAudioService? mobile; final WindowsAudioService? smtc; - final LinuxAudioService? mpris; - AudioServices(this.mobile, this.smtc, this.mpris); + AudioServices(this.mobile, this.smtc); static Future create( Ref ref, ProxyPlaylistNotifier playback, ) async { - final mobile = - DesktopTools.platform.isMobile || DesktopTools.platform.isMacOS - ? await AudioService.init( - builder: () => MobileAudioService(playback), - config: const AudioServiceConfig( - androidNotificationChannelId: 'com.krtirtho.Spotube', - androidNotificationChannelName: 'Spotube', - androidNotificationOngoing: true, - ), - ) - : null; + final mobile = DesktopTools.platform.isMobile || + DesktopTools.platform.isMacOS || + DesktopTools.platform.isLinux + ? await AudioService.init( + builder: () => MobileAudioService(playback), + config: const AudioServiceConfig( + androidNotificationChannelId: 'com.krtirtho.Spotube', + androidNotificationChannelName: 'Spotube', + androidNotificationOngoing: true, + ), + ) + : null; final smtc = DesktopTools.platform.isWindows ? WindowsAudioService(ref, playback) : null; - final mpris = - DesktopTools.platform.isLinux ? LinuxAudioService(ref, playback) : null; - if (mpris != null) { - playback.addListener((state) { - mpris.player.updateProperties(); - }); - audioPlayer.playerStateStream.listen((state) { - mpris.player.updateProperties(); - }); - audioPlayer.positionStream.listen((state) async { - await mpris.player.emitPropertiesChanged( - "org.mpris.MediaPlayer2.Player", - changedProperties: { - "Position": (await mpris.player.getPosition()).returnValues.first, - }, - ); - }); - } - - return AudioServices(mobile, smtc, mpris); + return AudioServices( + mobile, + smtc, + ); } Future addTrack(Track track) async { @@ -86,6 +68,5 @@ class AudioServices { void dispose() { smtc?.dispose(); - mpris?.dispose(); } } diff --git a/lib/services/audio_services/mobile_audio_service.dart b/lib/services/audio_services/mobile_audio_service.dart index e9bf7a3e..9750fce8 100644 --- a/lib/services/audio_services/mobile_audio_service.dart +++ b/lib/services/audio_services/mobile_audio_service.dart @@ -18,16 +18,32 @@ class MobileAudioService extends BaseAudioHandler { session = s; session?.configure(const AudioSessionConfiguration.music()); s.interruptionEventStream.listen((event) async { - switch (event.type) { - case AudioInterruptionType.duck: - await audioPlayer.setVolume(event.begin ? 0.5 : 1.0); - break; - case AudioInterruptionType.pause: - case AudioInterruptionType.unknown: - await audioPlayer.pause(); - break; + if (event.begin) { + switch (event.type) { + case AudioInterruptionType.duck: + await audioPlayer.setVolume(0.5); + break; + case AudioInterruptionType.pause: + case AudioInterruptionType.unknown: + await audioPlayer.pause(); + break; + } + } else { + switch (event.type) { + case AudioInterruptionType.duck: + await audioPlayer.setVolume(1.0); + break; + case AudioInterruptionType.pause: + case AudioInterruptionType.unknown: + await audioPlayer.resume(); + break; + } } }); + + s.becomingNoisyEventStream.listen((_) { + audioPlayer.pause(); + }); }); audioPlayer.playerStateStream.listen((state) async { playbackState.add(await _transformEvent()); diff --git a/lib/services/connectivity_adapter.dart b/lib/services/connectivity_adapter.dart index 6a3a46ee..c628f2f7 100644 --- a/lib/services/connectivity_adapter.dart +++ b/lib/services/connectivity_adapter.dart @@ -1,12 +1,114 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; import 'package:fl_query/fl_query.dart'; -import 'package:internet_connection_checker/internet_connection_checker.dart'; +import 'package:flutter/widgets.dart'; -class FlQueryInternetConnectionCheckerAdapter extends ConnectivityAdapter { - @override - Future get isConnected => InternetConnectionChecker().hasConnection; +class FlQueryInternetConnectionCheckerAdapter extends ConnectivityAdapter + with WidgetsBindingObserver { + final _connectionStreamController = StreamController.broadcast(); + final Dio dio; + + FlQueryInternetConnectionCheckerAdapter() + : dio = Dio(), + super() { + Timer? timer; + + onConnectivityChanged.listen((connected) { + if (!connected && timer == null) { + timer = Timer.periodic(const Duration(seconds: 30), (timer) async { + if (WidgetsBinding.instance.lifecycleState == + AppLifecycleState.paused) { + return; + } + await isConnected; + }); + } else { + timer?.cancel(); + timer = null; + } + }); + } @override - Stream get onConnectivityChanged => InternetConnectionChecker() - .onStatusChange - .map((status) => status == InternetConnectionStatus.connected); + didChangeAppLifecycleState(AppLifecycleState state) async { + if (state == AppLifecycleState.resumed) { + await isConnected; + } + } + + final vpnNames = [ + 'tun', + 'tap', + 'ppp', + 'pptp', + 'l2tp', + 'ipsec', + 'vpn', + 'wireguard', + 'openvpn', + 'softether', + 'proton', + 'strongswan', + 'cisco', + 'forticlient', + 'fortinet', + 'hideme', + 'hidemy', + 'hideman', + 'hidester', + 'lightway', + ]; + + Future isVpnActive() async { + final interfaces = await NetworkInterface.list( + includeLoopback: false, + type: InternetAddressType.any, + ); + + if (interfaces.isEmpty) { + return false; + } + + return interfaces.any( + (interface) => + vpnNames.any((name) => interface.name.toLowerCase().contains(name)), + ); + } + + Future doesConnectTo(String address) async { + try { + final result = await InternetAddress.lookup(address); + if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) { + return true; + } + return false; + } on SocketException catch (_) { + try { + final response = await dio.head('https://$address'); + return (response.statusCode ?? 500) <= 400; + } on DioException catch (_) { + return false; + } + } + } + + Future _isConnected() async { + return await doesConnectTo('google.com') || + await doesConnectTo('www.baidu.com') || // for China + await isVpnActive(); // when VPN is active that means we are connected + } + + @override + Future get isConnected async { + final connected = await _isConnected(); + if (connected != isConnectedSync /*previous value*/) { + _connectionStreamController.add(connected); + } + return connected; + } + + @override + Stream get onConnectivityChanged => _connectionStreamController.stream; } diff --git a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart index a0c54fb9..4a55130a 100644 --- a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart +++ b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart @@ -43,8 +43,8 @@ class CustomSpotifyEndpoints { String imageStyle = "gradient_overlay", String includeExternal = "audio", String? locale, - String? market, - String? country, + Market? market, + Market? country, }) async { if (accessToken.isEmpty) { throw Exception('[CustomSpotifyEndpoints.getView]: accessToken is empty'); @@ -58,8 +58,8 @@ class CustomSpotifyEndpoints { 'include_external': includeExternal, 'timestamp': DateTime.now().toUtc().toIso8601String(), if (locale != null) 'locale': locale, - if (market != null) 'market': market, - if (country != null) 'country': country, + if (market != null) 'market': market.name, + if (country != null) 'country': country.name, }.entries.map((e) => '${e.key}=${e.value}').join('&'); final res = await _client.get( @@ -124,7 +124,7 @@ class CustomSpotifyEndpoints { Iterable? seedGenres, Iterable? seedTracks, int limit = 20, - String? market, + Market? market, Map? max, Map? min, Map? target, @@ -143,7 +143,7 @@ class CustomSpotifyEndpoints { 'seed_genres': seedGenres, 'seed_tracks': seedTracks }.forEach((key, list) => _addList(parameters, key, list!)); - if (market != null) parameters['market'] = market; + if (market != null) parameters['market'] = market.name; for (var map in [min, max, target]) { _addTunableTrackMap(parameters, map); } diff --git a/lib/services/download_manager/chunked_download.dart b/lib/services/download_manager/chunked_download.dart index 672acfb3..b2849a3c 100644 --- a/lib/services/download_manager/chunked_download.dart +++ b/lib/services/download_manager/chunked_download.dart @@ -3,6 +3,9 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import 'package:spotube/models/logger.dart'; + +final logger = getLogger("ChunkedDownload"); /// Downloading by spiting as file in chunks extension ChunkDownload on Dio { @@ -67,11 +70,11 @@ extension ChunkDownload on Dio { } await raf.close(); - debugPrint("Downloaded file path: ${headFile.path}"); + logger.d("Downloaded file path: ${headFile.path}"); headFile = await headFile.rename(savePath); - debugPrint("Renamed file path: ${headFile.path}"); + logger.d("Renamed file path: ${headFile.path}"); } final firstResponse = await downloadChunk( diff --git a/lib/services/download_manager/download_manager.dart b/lib/services/download_manager/download_manager.dart index 3a192ea3..904f06cf 100644 --- a/lib/services/download_manager/download_manager.dart +++ b/lib/services/download_manager/download_manager.dart @@ -1,11 +1,12 @@ import 'dart:async'; import 'dart:collection'; import 'dart:io'; -import 'package:catcher/core/catcher.dart'; +import 'package:catcher_2/catcher_2.dart'; import 'package:collection/collection.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import 'package:spotube/models/logger.dart'; import 'package:spotube/services/download_manager/chunked_download.dart'; import 'package:spotube/services/download_manager/download_request.dart'; import 'package:spotube/services/download_manager/download_status.dart'; @@ -22,6 +23,7 @@ typedef DownloadStatusEvent = ({ }); class DownloadManager { + final logger = getLogger("DownloadManager"); final Map _cache = {}; final Queue _queue = Queue(); var dio = Dio(); @@ -73,7 +75,7 @@ class DownloadManager { } setStatus(task, DownloadStatus.downloading); - debugPrint("[DownloadManager] $url"); + logger.d("[DownloadManager] $url"); final file = File(savePath.toString()); partialFilePath = savePath + partialExtension; partialFile = File(partialFilePath); @@ -82,10 +84,10 @@ class DownloadManager { final partialFileExist = await partialFile.exists(); if (fileExist) { - debugPrint("[DownloadManager] File Exists"); + logger.d("[DownloadManager] File Exists"); setStatus(task, DownloadStatus.completed); } else if (partialFileExist) { - debugPrint("[DownloadManager] Partial File Exists"); + logger.d("[DownloadManager] Partial File Exists"); final partialFileLength = await partialFile.length(); @@ -128,7 +130,7 @@ class DownloadManager { } } } catch (e, stackTrace) { - Catcher.reportCheckedError(e, stackTrace); + Catcher2.reportCheckedError(e, stackTrace); var task = getDownload(url)!; if (task.status.value != DownloadStatus.canceled && @@ -205,7 +207,7 @@ class DownloadManager { } Future pauseDownload(String url) async { - debugPrint("[DownloadManager] Pause Download"); + logger.d("[DownloadManager] Pause Download"); var task = getDownload(url)!; setStatus(task, DownloadStatus.paused); task.request.cancelToken.cancel(); @@ -214,7 +216,7 @@ class DownloadManager { } Future cancelDownload(String url) async { - debugPrint("[DownloadManager] Cancel Download"); + logger.d("[DownloadManager] Cancel Download"); var task = getDownload(url)!; setStatus(task, DownloadStatus.canceled); _queue.remove(task.request); @@ -222,7 +224,7 @@ class DownloadManager { } Future resumeDownload(String url) async { - debugPrint("[DownloadManager] Resume Download"); + logger.d("[DownloadManager] Resume Download"); var task = getDownload(url)!; setStatus(task, DownloadStatus.downloading); task.request.cancelToken = CancelToken(); @@ -388,7 +390,7 @@ class DownloadManager { while (_queue.isNotEmpty && runningTasks < maxConcurrentTasks) { runningTasks++; - debugPrint('Concurrent workers: $runningTasks'); + logger.d('Concurrent workers: $runningTasks'); var currentRequest = _queue.removeFirst(); await download( diff --git a/lib/services/queries/album.dart b/lib/services/queries/album.dart index 76a7937e..53fcaf86 100644 --- a/lib/services/queries/album.dart +++ b/lib/services/queries/album.dart @@ -1,4 +1,4 @@ -import 'package:catcher/catcher.dart'; +import 'package:catcher_2/catcher_2.dart'; import 'package:fl_query/fl_query.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; @@ -66,12 +66,12 @@ class AlbumQueries { (pageParam, spotify) async { try { final albums = await spotify.browse - .getNewReleases(country: market) + .newReleases(country: market) .getPage(50, pageParam); return albums; } catch (e, stack) { - Catcher.reportCheckedError(e, stack); + Catcher2.reportCheckedError(e, stack); rethrow; } }, diff --git a/lib/services/queries/artist.dart b/lib/services/queries/artist.dart index 27a58572..6dad2718 100644 --- a/lib/services/queries/artist.dart +++ b/lib/services/queries/artist.dart @@ -51,11 +51,12 @@ class ArtistQueries { return page.items?.toList() ?? []; } + following.addAll(page.items ?? []); while (page?.isLast != true) { - following.addAll(page?.items ?? []); page = await spotify.me .following(FollowingType.artist) .getPage(50, page?.after ?? ''); + following.addAll(page.items ?? []); } return following; diff --git a/lib/services/queries/category.dart b/lib/services/queries/category.dart index 91513fd7..33668d82 100644 --- a/lib/services/queries/category.dart +++ b/lib/services/queries/category.dart @@ -13,7 +13,7 @@ class CategoryQueries { InfiniteQuery, dynamic, int> list( WidgetRef ref, - String recommendationMarket, + Market recommendationMarket, ) { ref.watch(userPreferencesProvider.select((s) => s.locale)); final locale = useContext().l10n.localeName; @@ -53,7 +53,7 @@ class CategoryQueries { (pageParam, spotify) async { final playlists = await Pages( spotify, - "v1/browse/categories/$category/playlists?country=$market&locale=$locale", + "v1/browse/categories/$category/playlists?country=${market.name}&locale=$locale", (json) => json == null ? null : PlaylistSimple.fromJson(json), 'playlists', (json) => PlaylistsFeatured.fromJson(json), diff --git a/lib/services/queries/playlist.dart b/lib/services/queries/playlist.dart index 0204f9b7..ac8dc73f 100644 --- a/lib/services/queries/playlist.dart +++ b/lib/services/queries/playlist.dart @@ -1,7 +1,4 @@ -import 'dart:io'; -import 'dart:math'; - -import 'package:catcher/catcher.dart'; +import 'package:catcher_2/catcher_2.dart'; import 'package:fl_query/fl_query.dart'; import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -13,7 +10,6 @@ import 'package:spotube/extensions/track.dart'; import 'package:spotube/hooks/use_spotify_infinite_query.dart'; import 'package:spotube/hooks/use_spotify_query.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; -import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; @@ -123,8 +119,9 @@ class PlaylistQueries { return useSpotifyQuery( "playlist-is-followed/$playlistId/$userId", (spotify) async { - final result = await spotify.playlists.followedBy(playlistId, [userId]); - return result.first; + final result = + await spotify.playlists.followedByUsers(playlistId, [userId]); + return result[userId] ?? false; }, ref: ref, ); @@ -224,7 +221,7 @@ class PlaylistQueries { await spotify.playlists.featured.getPage(5, pageParam); return playlists; } catch (e, stack) { - Catcher.reportCheckedError(e, stack); + Catcher2.reportCheckedError(e, stack); rethrow; } }, @@ -244,7 +241,7 @@ class PlaylistQueries { ({List tracks, List artists, List genres})? seeds, RecommendationParameters? parameters, int limit = 20, - String? market, + Market? market, }) { final marketOfPreference = ref.watch( userPreferencesProvider.select((s) => s.recommendationMarket), diff --git a/lib/services/queries/search.dart b/lib/services/queries/search.dart index 7eb3e139..eaf9c1b7 100644 --- a/lib/services/queries/search.dart +++ b/lib/services/queries/search.dart @@ -11,7 +11,7 @@ class SearchQueries { SearchType searchType, ) { return useSpotifyInfiniteQuery, dynamic, int>( - "search-query/${searchType.key}", + "search-query/${searchType.name}", (page, spotify) { if (query.trim().isEmpty) return []; final queryString = query; diff --git a/lib/services/youtube/youtube.dart b/lib/services/youtube/youtube.dart index fbf559d4..c8c277e3 100644 --- a/lib/services/youtube/youtube.dart +++ b/lib/services/youtube/youtube.dart @@ -181,24 +181,33 @@ class YoutubeEndpoints { } } - String _pipedStreamResponseToStreamUrl(PipedStreamResponse stream) { + String _pipedStreamResponseToStreamUrl( + PipedStreamResponse stream, + MusicCodec codec, + ) { + final pipedStreamFormat = switch (codec) { + MusicCodec.m4a => PipedAudioStreamFormat.m4a, + MusicCodec.weba => PipedAudioStreamFormat.webm, + }; + return switch (preferences.audioQuality) { - AudioQuality.high => stream - .highestBitrateAudioStreamOfFormat(PipedAudioStreamFormat.m4a)! - .url, - AudioQuality.low => stream - .lowestBitrateAudioStreamOfFormat(PipedAudioStreamFormat.m4a)! - .url, + AudioQuality.high => + stream.highestBitrateAudioStreamOfFormat(pipedStreamFormat)!.url, + AudioQuality.low => + stream.lowestBitrateAudioStreamOfFormat(pipedStreamFormat)!.url, }; } - Future streamingUrl(String id) async { + Future streamingUrl(String id, MusicCodec codec) async { if (youtube != null) { final res = await PrimitiveUtils.raceMultiple( () => youtube!.videos.streams.getManifest(id), ); final audioOnlyManifests = res.audioOnly.where((info) { - return info.codec.mimeType == "audio/mp4"; + return switch (codec) { + MusicCodec.m4a => info.codec.mimeType == "audio/mp4", + MusicCodec.weba => info.codec.mimeType == "audio/webm", + }; }); return switch (preferences.audioQuality) { @@ -208,26 +217,27 @@ class YoutubeEndpoints { audioOnlyManifests.sortByBitrate().last.url.toString(), }; } else { - return _pipedStreamResponseToStreamUrl(await piped!.streams(id)); + return _pipedStreamResponseToStreamUrl(await piped!.streams(id), codec); } } Future<(YoutubeVideoInfo info, String streamingUrl)> video( String id, SearchMode searchMode, + MusicCodec codec, ) async { if (youtube != null) { final res = await youtube!.videos.get(id); return ( YoutubeVideoInfo.fromVideo(res), - await streamingUrl(id), + await streamingUrl(id, codec), ); } else { try { final res = await piped!.streams(id); return ( YoutubeVideoInfo.fromStreamResponse(res, searchMode), - _pipedStreamResponseToStreamUrl(res), + _pipedStreamResponseToStreamUrl(res, codec), ); } on Exception catch (e) { await showPipedErrorDialog(e); diff --git a/lib/themes/theme.dart b/lib/themes/theme.dart index e11f0cc2..8c968e1b 100644 --- a/lib/themes/theme.dart +++ b/lib/themes/theme.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; -ThemeData theme(Color seed, Brightness brightness) { +ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { final scheme = ColorScheme.fromSeed( seedColor: seed, shadow: Colors.black12, + background: isAmoled ? Colors.black : null, + surface: isAmoled ? Colors.black : null, brightness: brightness, ); return ThemeData( @@ -66,5 +68,8 @@ ThemeData theme(Color seed, Brightness brightness) { ), ), ), + scrollbarTheme: const ScrollbarThemeData( + thickness: MaterialStatePropertyAll(14), + ), ); } diff --git a/lib/utils/duration.dart b/lib/utils/duration.dart index 858503fb..35678a96 100644 --- a/lib/utils/duration.dart +++ b/lib/utils/duration.dart @@ -1,4 +1,4 @@ -import 'package:catcher/catcher.dart'; +import 'package:catcher_2/catcher_2.dart'; /// Parses duration string formatted by Duration.toString() to [Duration]. /// The string should be of form hours:minutes:seconds.microseconds @@ -53,7 +53,7 @@ Duration? tryParseDuration(String input) { try { return parseDuration(input); } catch (e, stack) { - Catcher.reportCheckedError(e, stack); + Catcher2.reportCheckedError(e, stack); return null; } } diff --git a/lib/utils/primitive_utils.dart b/lib/utils/primitive_utils.dart index 3843601e..801c2e5a 100644 --- a/lib/utils/primitive_utils.dart +++ b/lib/utils/primitive_utils.dart @@ -48,6 +48,6 @@ abstract class PrimitiveUtils { } static String toSafeFileName(String str) { - return str.replaceAll(RegExp(r'[^\w\s\.\-_]'), "_"); + return str.replaceAll(RegExp(r'[/\?%*:|"<>]'), ' '); } } diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart index 68a8d9a4..a805272c 100644 --- a/lib/utils/type_conversion_utils.dart +++ b/lib/utils/type_conversion_utils.dart @@ -126,21 +126,21 @@ abstract class TypeConversionUtils { }) { final track = Track(); track.album = Album() - ..name = metadata?.album ?? "Spotube" + ..name = metadata?.album ?? "Unknown" ..images = [if (art != null) Image()..url = art] ..genres = [if (metadata?.genre != null) metadata!.genre!] ..artists = [ Artist() - ..name = metadata?.albumArtist ?? "Spotube" - ..id = metadata?.albumArtist ?? "Spotube" + ..name = metadata?.albumArtist ?? "Unknown" + ..id = metadata?.albumArtist ?? "Unknown" ..type = "artist", ] ..id = metadata?.album ..releaseDate = metadata?.year?.toString(); track.artists = [ Artist() - ..name = metadata?.artist ?? "Spotube" - ..id = metadata?.artist ?? "Spotube" + ..name = metadata?.artist ?? "Unknown" + ..id = metadata?.artist ?? "Unknown" ]; track.id = metadata?.title ?? basenameWithoutExtension(file.path); diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 7e1d5828..8f100774 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -68,6 +68,8 @@ set_target_properties(${BINARY_NAME} # them to the application. include(flutter/generated_plugins.cmake) +target_link_libraries(${BINARY_NAME} PRIVATE ${MIMALLOC_LIB}) + # === Installation === # By default, "installing" just makes a relocatable bundle in the build diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 9f8f2fd3..d455dc02 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,7 +6,6 @@ #include "generated_plugin_registrant.h" -#include #include #include #include @@ -19,9 +18,6 @@ #include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) catcher_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "CatcherPlugin"); - catcher_plugin_register_with_registrar(catcher_registrar); g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index c7f0e848..22319e92 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,7 +3,6 @@ # list(APPEND FLUTTER_PLUGIN_LIST - catcher file_selector_linux flutter_secure_storage_linux local_notifier diff --git a/linux/packaging/deb/make_config.yaml b/linux/packaging/deb/make_config.yaml index 148b9618..46493122 100644 --- a/linux/packaging/deb/make_config.yaml +++ b/linux/packaging/deb/make_config.yaml @@ -16,7 +16,8 @@ dependencies: - libsecret-1-0 - libnotify-bin - libjsoncpp25 - - libmpv2 + - libmpv1 | libmpv2 + - xdg-user-dirs essential: false icon: assets/spotube-logo.png diff --git a/linux/packaging/rpm/make_config.yaml b/linux/packaging/rpm/make_config.yaml index 0e3e1624..00f4c20e 100644 --- a/linux/packaging/rpm/make_config.yaml +++ b/linux/packaging/rpm/make_config.yaml @@ -12,6 +12,7 @@ requires: - jsoncpp - libsecret - libnotify + - xdg-user-dirs display_name: Spotube diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 1010a6c4..270e6261 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,7 +7,6 @@ import Foundation import audio_service import audio_session -import catcher import device_info_plus import file_selector_macos import flutter_secure_storage_macos @@ -27,7 +26,6 @@ import window_size func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) - CatcherPlugin.register(with: registry.registrar(forPlugin: "CatcherPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) diff --git a/pubspec.lock b/pubspec.lock index fde8f6b1..3dbc3cbf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -85,10 +85,10 @@ packages: dependency: transitive description: name: archive - sha256: e0902a06f0e00414e4e3438a084580161279f137aeb862274710f29ec10cf01e + sha256: ca12e6c9ac022f33fd89128e7007fb5e97ab6e814d4fa05dd8d4f2db1e3c69cb url: "https://pub.dev" source: hosted - version: "3.3.9" + version: "3.4.5" args: dependency: "direct main" description: @@ -113,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.18.12" + audio_service_mpris: + dependency: "direct main" + description: + name: audio_service_mpris + sha256: "31be5de2db0c71b217157afce1974ac6d0ad329bd91deb1f19ad094d29340d8e" + url: "https://pub.dev" + source: hosted + version: "0.1.0" audio_service_platform_interface: dependency: transitive description: @@ -237,35 +245,34 @@ packages: dependency: "direct main" description: name: cached_network_image - sha256: fd3d0dc1d451f9a252b32d95d3f0c3c487bc41a75eba2e6097cb0b9c71491b15 + sha256: f98972704692ba679db144261172a8e20feb145636c617af0eb4022132a6797f url: "https://pub.dev" source: hosted - version: "3.2.3" + version: "3.3.0" cached_network_image_platform_interface: dependency: transitive description: name: cached_network_image_platform_interface - sha256: bb2b8403b4ccdc60ef5f25c70dead1f3d32d24b9d6117cfc087f496b178594a7 + sha256: "56aa42a7a01e3c9db8456d9f3f999931f1e05535b5a424271e9a38cabf066613" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" cached_network_image_web: dependency: transitive description: name: cached_network_image_web - sha256: b8eb814ebfcb4dea049680f8c1ffb2df399e4d03bf7a352c775e26fa06e02fa0 + sha256: "759b9a9f8f6ccbb66c185df805fac107f05730b1dab9c64626d1008cca532257" url: "https://pub.dev" source: hosted - version: "1.0.2" - catcher: + version: "1.1.0" + catcher_2: dependency: "direct main" description: - path: "." - ref: HEAD - resolved-ref: "354108cbfe75299e8dd25be521946b32c41f621d" - url: "https://github.com/ThexXTURBOXx/catcher" - source: git - version: "0.8.0" + name: catcher_2 + sha256: "73e251057b1b59442d58b5109d26401cfed850df21da44b028338d4f85f69105" + url: "https://pub.dev" + source: hosted + version: "1.0.0" change_case: dependency: transitive description: @@ -438,10 +445,10 @@ packages: dependency: "direct main" description: name: dio - sha256: ce75a1b40947fea0a0e16ce73337122a86762e38b982e1ccb909daa3b9bc4197 + sha256: "417e2a6f9d83ab396ec38ff4ea5da6c254da71e4db765ad737a42af6930140b7" url: "https://pub.dev" source: hosted - version: "5.3.2" + version: "5.3.3" disable_battery_optimization: dependency: "direct main" description: @@ -581,27 +588,30 @@ packages: fl_query: dependency: "direct main" description: - name: fl_query - sha256: "3d71cd1eeb3232efa5e32363a351d74fd9ff07c6eb80aeb672b1970962764945" - url: "https://pub.dev" - source: hosted - version: "1.0.0-alpha.4" + path: "packages/fl_query" + ref: HEAD + resolved-ref: a817713a0bb0c486e908e9ed74467c4f7f58bea7 + url: "https://github.com/KRTirtho/fl-query.git" + source: git + version: "1.0.0-alpha.5" fl_query_devtools: dependency: "direct main" description: - name: fl_query_devtools - sha256: "72fac45293902b9f99c726609cd5416573566cce0b7c6e27311efde7fdf1b8b1" - url: "https://pub.dev" - source: hosted - version: "0.1.0-alpha.2" + path: "packages/fl_query_devtools" + ref: HEAD + resolved-ref: a817713a0bb0c486e908e9ed74467c4f7f58bea7 + url: "https://github.com/KRTirtho/fl-query.git" + source: git + version: "0.1.0-alpha.3" fl_query_hooks: dependency: "direct main" description: - name: fl_query_hooks - sha256: "7f0880696666714f77981777509a8aedb765857dcdbdde23e623da20a24c4ae0" - url: "https://pub.dev" - source: hosted - version: "1.0.0-alpha.4+1" + path: "packages/fl_query_hooks" + ref: HEAD + resolved-ref: a817713a0bb0c486e908e9ed74467c4f7f58bea7 + url: "https://github.com/KRTirtho/fl-query.git" + source: git + version: "1.0.0-alpha.5" fluentui_system_icons: dependency: "direct main" description: @@ -631,14 +641,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.9" - flutter_blurhash: - dependency: transitive - description: - name: flutter_blurhash - sha256: "05001537bd3fac7644fa6558b09ec8c0a3f2eba78c0765f88912882b1331a5c6" - url: "https://pub.dev" - source: hosted - version: "0.7.0" flutter_cache_manager: dependency: "direct main" description: @@ -798,10 +800,10 @@ packages: dependency: "direct main" description: name: flutter_native_splash - sha256: ecff62b3b893f2f665de7e4ad3de89f738941fcfcaaba8ee601e749efafa4698 + sha256: "91004565166dbbc7a85e7e99b84124a287839830ca957cfe45004793fe6fe69f" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.3" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -814,26 +816,26 @@ packages: dependency: "direct main" description: name: flutter_riverpod - sha256: "1bd39b04f1bcd217a969589777ca6bd642d116e3e5de65c3e6a8e8bdd8b178ec" + sha256: e667e406a74d67715f1fa0bd941d9ded49aff72f3a9f4440a36aece4e8d457a7 url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.3" flutter_rust_bridge: dependency: transitive description: name: flutter_rust_bridge - sha256: a2ff791f96ed03be0d4a8d249130688371ab3612ef95efeddef23600b904a1ef + sha256: e12415c3bce49bcbc3fed383f0ea41ad7d828f6cf0eccba0588ffa5a812fe522 url: "https://pub.dev" source: hosted - version: "1.81.0" + version: "1.82.1" flutter_secure_storage: dependency: "direct main" description: name: flutter_secure_storage - sha256: "22dbf16f23a4bcf9d35e51be1c84ad5bb6f627750565edd70dab70f3ff5fff8f" + sha256: ffdbb60130e4665d2af814a0267c481bcf522c41ae2e43caf69fa0146876d685 url: "https://pub.dev" source: hosted - version: "8.1.0" + version: "9.0.0" flutter_secure_storage_linux: dependency: transitive description: @@ -870,10 +872,10 @@ packages: dependency: transitive description: name: flutter_secure_storage_windows - sha256: "38f9501c7cb6f38961ef0e1eacacee2b2d4715c63cc83fe56449c4d3d0b47255" + sha256: "5809c66f9dd3b4b93b0a6e2e8561539405322ee767ac2f64d084e2ab5429d108" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "3.0.0" flutter_svg: dependency: "direct main" description: @@ -941,10 +943,10 @@ packages: dependency: "direct main" description: name: fuzzywuzzy - sha256: f685751951297e560361b6416d9ba62d40231599d7b3e8c078318990d3bab84a + sha256: a84b99ebb21c448e02267070c91b218b4fbbef9c668b344aaeada49865985cae url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "1.1.6" glob: dependency: transitive description: @@ -957,10 +959,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: "5668e6d3dbcb2d0dfa25f7567554b88c57e1e3f3c440b672b24d4a9477017d5b" + sha256: a07c781bf55bf11ae85133338e4850f0b4e33e261c44a66c750fc707d65d8393 url: "https://pub.dev" source: hosted - version: "10.1.2" + version: "11.1.2" google_fonts: dependency: "direct main" description: @@ -1021,10 +1023,10 @@ packages: dependency: "direct main" description: name: hooks_riverpod - sha256: ad7b877c3687e38764633d221a1f65491bc7a540e724101e9a404a84db2a4276 + sha256: "69dcb88acbc68c81fc27ec15a89a4e24b7812c83c13a6307a1a9366ada758541" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.3" html: dependency: "direct main" description: @@ -1061,10 +1063,10 @@ packages: dependency: transitive description: name: image - sha256: a72242c9a0ffb65d03de1b7113bc4e189686fc07c7147b8b41811d0dd0e0d9bf + sha256: "028f61960d56f26414eb616b48b04eb37d700cbe477b7fb09bf1d7ce57fd9271" url: "https://pub.dev" source: hosted - version: "4.0.17" + version: "4.1.3" image_picker: dependency: "direct main" description: @@ -1134,14 +1136,6 @@ packages: description: flutter source: sdk version: "0.0.0" - internet_connection_checker: - dependency: "direct main" - description: - name: internet_connection_checker - sha256: "1c683e63e89c9ac66a40748b1b20889fd9804980da732bf2b58d6d5456c8e876" - url: "https://pub.dev" - source: hosted - version: "1.0.0+1" intl: dependency: "direct main" description: @@ -1226,10 +1220,10 @@ packages: dependency: "direct main" description: name: logger - sha256: "7ad7215c15420a102ec687bb320a7312afd449bac63bfb1c60d9787c27b9767f" + sha256: ba3bc83117b2b49bdd723c0ea7848e8285a0fbc597ba09203b20d329d020c24a url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "2.0.2" logging: dependency: transitive description: @@ -1274,58 +1268,58 @@ packages: dependency: transitive description: name: media_kit_libs_android_audio - sha256: "109f5907b4ba2b464e7dc8fcc3062900a9fdce1fdf81e43a9fe1722c3e5fe618" + sha256: "1d9ba8814e58a12ae69014884abf96893d38e444abfb806ff38896dfe089dd15" url: "https://pub.dev" source: hosted - version: "1.3.3" + version: "1.3.5" media_kit_libs_audio: dependency: "direct main" description: name: media_kit_libs_audio - sha256: "67c0644ffd61df4c64d914e52a7ea344737e23301f5f5d7c72002ba3617d1d0b" + sha256: "9ccf9019edc220b8d0bfa1f53a91aa2cf2506ac860b4d5774c9d6bbdd49796ca" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.3" media_kit_libs_ios_audio: dependency: transitive description: name: media_kit_libs_ios_audio - sha256: b121cb552f644813993af9baa0fe34082759bbdc8920b3580b5b17ff4eccc1b8 + sha256: "78ccf04e27d6b4ba00a355578ccb39b772f00d48269a6ac3db076edf2d51934f" url: "https://pub.dev" source: hosted - version: "1.1.3" + version: "1.1.4" media_kit_libs_linux: dependency: transitive description: name: media_kit_libs_linux - sha256: "3b7c272179639a914dc8a50bf8a3f2df0e9a503bd727c88fab499dbdf6cb1eb8" + sha256: e186891c31daa6bedab4d74dcdb4e8adfccc7d786bfed6ad81fe24a3b3010310 url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.3" media_kit_libs_macos_audio: dependency: transitive description: name: media_kit_libs_macos_audio - sha256: c50a0feee961b7c9d4b9b367f94ce8415938b095ef0cc3d975b9c1f27e76e394 + sha256: "3be21844df98f286de32808592835073cdef2c1a10078bac135da790badca950" url: "https://pub.dev" source: hosted - version: "1.1.3" + version: "1.1.4" media_kit_libs_windows_audio: dependency: transitive description: name: media_kit_libs_windows_audio - sha256: "9f2dda645ef2d28f7945259848a6dcc6b0b36cb537d8e79d59d5054f08dbce13" + sha256: c2fd558cc87b9d89a801141fcdffe02e338a3b21a41a18fbd63d5b221a1b8e53 url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "1.0.9" media_kit_native_event_loop: dependency: transitive description: name: media_kit_native_event_loop - sha256: e37ce6fb5fa71b8cf513c6a6cd591367743a342a385e7da621a047dd8ef6f4a4 + sha256: a605cf185499d14d58935b8784955a92a4bf0ff4e19a23de3d17a9106303930e url: "https://pub.dev" source: hosted - version: "1.0.7" + version: "1.0.8" meta: dependency: transitive description: @@ -1338,10 +1332,10 @@ packages: dependency: "direct main" description: name: metadata_god - sha256: "562c223d83a7bbf0a289ed0d5ed6a8cf8d94d673263203e9ff4930b44bd2f066" + sha256: cf13931c39eba0b9443d16e8940afdabee125bf08945f18d4c0d02bcae2a3317 url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.5.2+1" mime: dependency: "direct main" description: @@ -1378,10 +1372,10 @@ packages: dependency: transitive description: name: octo_image - sha256: "107f3ed1330006a3bea63615e81cf637433f5135a52466c7caa0e7152bca9143" + sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "2.0.0" package_config: dependency: transitive description: @@ -1486,30 +1480,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.1" - pedantic: - dependency: transitive - description: - name: pedantic - sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602" - url: "https://pub.dev" - source: hosted - version: "1.11.1" permission_handler: dependency: "direct main" description: name: permission_handler - sha256: bc56bfe9d3f44c3c612d8d393bd9b174eb796d706759f9b495ac254e4294baa5 + sha256: "284a66179cabdf942f838543e10413246f06424d960c92ba95c84439154fcac8" url: "https://pub.dev" source: hosted - version: "10.4.5" + version: "11.0.1" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: "59c6322171c29df93a22d150ad95f3aa19ed86542eaec409ab2691b8f35f9a47" + sha256: ace7d15a3d1a4a0b91c041d01e5405df221edb9de9116525efc773c74e6fc790 url: "https://pub.dev" source: hosted - version: "10.3.6" + version: "11.0.5" permission_handler_apple: dependency: transitive description: @@ -1682,10 +1668,10 @@ packages: dependency: transitive description: name: riverpod - sha256: a600120d6f213a9922860eea1abc32597436edd5b2c4e73b91410f8c2af67d22 + sha256: "494bf2cfb4df30000273d3052bdb1cc1de738574c6b678f0beb146ea56f5e208" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.3" rxdart: dependency: transitive description: @@ -1710,6 +1696,15 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.9" + scrobblenaut: + dependency: "direct main" + description: + path: "." + ref: dart-3-support + resolved-ref: d90cb75d71737f3cfa2de4469d48080c0f3eedc2 + url: "https://github.com/KRTirtho/scrobblenaut.git" + source: git + version: "3.0.0" scroll_to_index: dependency: "direct main" description: @@ -1814,6 +1809,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.0" + simple_icons: + dependency: "direct main" + description: + name: simple_icons + sha256: "8aa6832dc7a263a3213e40ecbf1328a392308c809d534a3b860693625890483b" + url: "https://pub.dev" + source: hosted + version: "7.10.0" skeleton_text: dependency: "direct main" description: @@ -1831,10 +1834,10 @@ packages: dependency: "direct main" description: name: smtc_windows - sha256: dd86d0f29b5a73ffed5650279e6abee01846017b9bc16c07c708e129648c08ac + sha256: aba2bad5ddfaf595496db04df3d9fdb54fb128fc1f39c8f024945a67455388fe url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.1.1" source_gen: dependency: transitive description: @@ -1863,10 +1866,10 @@ packages: dependency: "direct main" description: name: spotify - sha256: c7c3f157f052143f713477bd5a764b080a0023ed084428bd0cf5a9e3bc260cc6 + sha256: e967c5e295792e9d38f4c5e9e60d7c2868ed9cb2a8fac2a67c75303f8395e374 url: "https://pub.dev" source: hosted - version: "0.11.0" + version: "0.12.0" sqflite: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7138bcaf..04f2d8b8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Open source Spotify client that doesn't require Premium nor uses El publish_to: "none" -version: 3.1.2+24 +version: 3.2.0+25 homepage: https://spotube.krtirtho.dev repository: https://github.com/KRTirtho/spotube @@ -19,10 +19,8 @@ dependencies: audio_session: ^0.1.13 auto_size_text: ^3.0.0 buttons_tabbar: ^1.3.6 - cached_network_image: ^3.2.2 - catcher: - git: - url: https://github.com/ThexXTURBOXx/catcher + cached_network_image: ^3.3.0 + catcher_2: 1.0.0 collection: ^1.15.0 cupertino_icons: ^1.0.5 curved_navigation_bar: ^1.0.3 @@ -34,9 +32,18 @@ dependencies: duration: ^3.0.12 envied: ^0.3.0 file_selector: ^1.0.1 - fl_query: ^1.0.0-alpha.4 - fl_query_hooks: ^1.0.0-alpha.4+1 - fl_query_devtools: ^0.1.0-alpha.2 + fl_query: + git: + url: https://github.com/KRTirtho/fl-query.git + path: packages/fl_query + fl_query_hooks: + git: + url: https://github.com/KRTirtho/fl-query.git + path: packages/fl_query_hooks + fl_query_devtools: + git: + url: https://github.com/KRTirtho/fl-query.git + path: packages/fl_query_devtools fluentui_system_icons: ^1.1.189 flutter: sdk: flutter @@ -51,42 +58,45 @@ dependencies: flutter_inappwebview: ^5.7.2+3 flutter_localizations: sdk: flutter - flutter_native_splash: ^2.2.19 - flutter_riverpod: ^2.1.1 - flutter_secure_storage: ^8.0.0 + flutter_native_splash: ^2.3.3 + flutter_riverpod: ^2.4.3 + flutter_secure_storage: ^9.0.0 flutter_svg: ^1.1.6 form_validator: ^2.1.1 - fuzzywuzzy: ^0.2.0 + fuzzywuzzy: ^1.1.6 google_fonts: ^5.1.0 - go_router: ^10.0.0 + go_router: ^11.1.2 hive: ^2.2.3 hive_flutter: ^1.1.0 - hooks_riverpod: ^2.1.1 + hooks_riverpod: ^2.4.3 html: ^0.15.1 http: ^1.1.0 image_picker: ^1.0.4 - internet_connection_checker: ^1.0.0+1 intl: ^0.18.0 introduction_screen: ^3.0.2 json_annotation: ^4.8.1 - logger: ^1.1.0 + logger: ^2.0.2 media_kit: ^1.1.3 - media_kit_libs_audio: ^1.0.1 - metadata_god: ^0.5.0 + media_kit_libs_audio: ^1.0.3 + metadata_god: ^0.5.2+1 mime: ^1.0.2 package_info_plus: ^4.1.0 palette_generator: ^0.3.3 path: ^1.8.0 path_provider: ^2.0.8 - permission_handler: ^10.2.0 + permission_handler: ^11.0.1 piped_client: ^0.1.0 popover: ^0.2.6+3 + scrobblenaut: + git: + url: https://github.com/KRTirtho/scrobblenaut.git + ref: dart-3-support scroll_to_index: ^3.0.1 shared_preferences: ^2.0.11 sidebarx: ^0.15.0 skeleton_text: ^3.0.1 - smtc_windows: ^0.1.0 - spotify: ^0.11.0 + smtc_windows: ^0.1.1 + spotify: ^0.12.0 stroke_text: ^0.0.2 supabase: ^1.9.9 system_theme: ^2.1.0 @@ -102,6 +112,8 @@ dependencies: ref: a738913c8ce2c9f47515382d40827e794a334274 path: plugins/window_size youtube_explode_dart: ^2.0.1 + simple_icons: ^7.10.0 + audio_service_mpris: ^0.1.0 dev_dependencies: build_runner: ^2.3.2 @@ -122,6 +134,14 @@ dev_dependencies: dependency_overrides: http: ^1.1.0 system_tray: 2.0.2 + fl_query: + git: + url: https://github.com/KRTirtho/fl-query.git + path: packages/fl_query + fl_query_hooks: + git: + url: https://github.com/KRTirtho/fl-query.git + path: packages/fl_query_hooks flutter: generate: true @@ -131,7 +151,7 @@ flutter: - assets/tutorial/ - LICENSE -flutter_icons: +flutter_launcher_icons: android: true image_path: "assets/spotube-logo.png" adaptive_icon_foreground: "assets/spotube-logo-foreground.jpg" diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 089930d3..ff25c4e3 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,7 +6,6 @@ #include "generated_plugin_registrant.h" -#include #include #include #include @@ -20,8 +19,6 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { - CatcherPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("CatcherPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b2b08c8e..0a5ab976 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,7 +3,6 @@ # list(APPEND FLUTTER_PLUGIN_LIST - catcher file_selector_windows flutter_secure_storage_windows local_notifier