diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 12a2f99b..805a89ac 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -66,7 +66,7 @@ jobs: - name: Release to AUR if: ${{ !inputs.dry_run }} - uses: KSXGitHub/github-actions-deploy-aur@v2.7.0 + uses: KSXGitHub/github-actions-deploy-aur@v2.7.1 with: pkgname: spotube-bin pkgbuild: aur-struct/PKGBUILD diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 772c9fe2..5d918a03 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -181,6 +181,7 @@ jobs: - uses: actions/upload-artifact@v3 + if: ${{ inputs.channel == 'stable' }} with: if-no-files-found: error name: Spotube-Release-Binaries @@ -188,6 +189,16 @@ jobs: dist/Spotube-linux-x86_64.deb dist/Spotube-linux-x86_64.rpm dist/spotube-linux-${{ env.BUILD_VERSION }}-x86_64.tar.xz + + - uses: actions/upload-artifact@v3 + if: ${{ inputs.channel == 'nightly' }} + with: + if-no-files-found: error + name: Spotube-Release-Binaries + path: | + dist/Spotube-linux-x86_64.deb + dist/Spotube-linux-x86_64.rpm + dist/spotube-linux-nightly-x86_64.tar.xz - name: Debug With SSH When fails if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} @@ -273,7 +284,7 @@ jobs: macos: - runs-on: macos-12 + runs-on: macos-14 steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2.12.0 @@ -316,7 +327,7 @@ jobs: - name: Package Macos App run: | - python3 -m pip install setuptools + brew install python-setuptools npm install -g appdmg mkdir -p build/${{ env.BUILD_VERSION }} appdmg appdmg.json build/Spotube-macos-universal.dmg @@ -338,7 +349,7 @@ jobs: limit-access-to-actor: true iOS: - runs-on: macos-latest + runs-on: macos-14 steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2.10.0 diff --git a/.vscode/settings.json b/.vscode/settings.json index 0e6a4294..462d33ef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,11 +2,15 @@ "cmake.configureOnOpen": false, "cSpell.words": [ "acousticness", + "Amoled", + "Buildless", "danceability", + "fuzzywuzzy", "instrumentalness", "Mpris", "riverpod", "Scrobblenaut", + "skeletonizer", "speechiness", "Spotube", "winget" diff --git a/.vscode/snippets.code-snippets b/.vscode/snippets.code-snippets new file mode 100644 index 00000000..9a18929b --- /dev/null +++ b/.vscode/snippets.code-snippets @@ -0,0 +1,170 @@ +{ + "PaginatedState": { + "scope": "dart", + "prefix": "paginatedState", + "description": "Generate a PaginatedState", + "body": [ + "class ${1:Model}State extends PaginatedState<${2:Model}> {", + " ${1:Model}State({", + " required super.items,", + " required super.offset,", + " required super.limit,", + " required super.hasMore,", + " });", + " ", + " @override", + " ${1:Model}State copyWith({", + " List<${2:Model}>? items,", + " int? offset,", + " int? limit,", + " bool? hasMore,", + " }) {", + " return ${1:Model}State(", + " items: items ?? this.items,", + " offset: offset ?? this.offset,", + " limit: limit ?? this.limit,", + " hasMore: hasMore ?? this.hasMore,", + " );", + " }", + "}" + ] + }, + "PaginatedAsyncNotifier": { + "scope": "dart", + "prefix": "paginatedAsyncNotifier", + "description": "Generate a PaginatedAsyncNotifier", + "body": [ + "class ${1:NotifierName}Notifier extends PaginatedAsyncNotifier<${3:Item}, ${2:Model}State> {", + " ${1:NotifierName}Notifier() : super();", + " ", + " @override", + " fetch(int offset, int limit) async {", + " throw UnimplementedError();", + " }", + " ", + " @override", + " build() async {", + " throw UnimplementedError();", + " }", + "}" + ] + }, + "PaginaitedNotifierWithState": { + "scope": "dart", + "prefix": "paginatedNotifierWithState", + "description": "Generate a PaginatedNotifier with PaginatedState", + "body": [ + "class $1State extends PaginatedState<$2> {", + " $1State({", + " required super.items,", + " required super.offset,", + " required super.limit,", + " required super.hasMore,", + " });", + " ", + " @override", + " $1State copyWith({", + " List<$2>? items,", + " int? offset,", + " int? limit,", + " bool? hasMore,", + " }) {", + " return $1State(", + " items: items ?? this.items,", + " offset: offset ?? this.offset,", + " limit: limit ?? this.limit,", + " hasMore: hasMore ?? this.hasMore,", + " );", + " }", + "}", + " ", + "class $1Notifier", + " extends PaginatedAsyncNotifier<$2, $1State> {", + " $1Notifier() : super();", + " ", + " @override", + " fetch(int offset, int limit) async {", + " throw UnimplementedError();", + " }", + " ", + " @override", + " build() async {", + " throw UnimplementedError();", + " }", + "}", + " ", + "final ${1/(.*)/${1:/camelcase}/}Provider = AsyncNotifierProvider<$1Notifier, $1State>(", + " ()=> $1Notifier(),", + ");" + ] + }, + "FamilyPaginatedAsyncNotifier": { + "scope": "dart", + "prefix": "familyPaginatedAsyncNotifier", + "description": "Generate a FamilyPaginatedAsyncNotifier", + "body": [ + "class ${1:NotifierName}Notifier extends FamilyPaginatedAsyncNotifier<${3:Item}, ${2:Model}State, {$4:Arg}> {", + " ${1:NotifierName}Notifier() : super();", + " ", + " @override", + " fetch(arg, offset, limit) async {", + " throw UnimplementedError();", + " }", + " ", + " @override", + " build(arg) async {", + " throw UnimplementedError();", + " }", + "}" + ] + }, + "FamilyPaginaitedNotifierWithState": { + "scope": "dart", + "prefix": "familyPaginatedNotifierWithState", + "description": "Generate a FamilyPaginatedAsyncNotifier with PaginatedState", + "body": [ + "class $1State extends PaginatedState<$2> {", + " $1State({", + " required super.items,", + " required super.offset,", + " required super.limit,", + " required super.hasMore,", + " });", + " ", + " @override", + " $1State copyWith({", + " List<$2>? items,", + " int? offset,", + " int? limit,", + " bool? hasMore,", + " }) {", + " return $1State(", + " items: items ?? this.items,", + " offset: offset ?? this.offset,", + " limit: limit ?? this.limit,", + " hasMore: hasMore ?? this.hasMore,", + " );", + " }", + "}", + " ", + "class $1Notifier", + " extends FamilyPaginatedAsyncNotifier<$2, $1State, $3> {", + " $1Notifier() : super();", + " ", + " @override", + " fetch(arg, offset, limit) async {", + " throw UnimplementedError();", + " }", + " ", + " @override", + " build(arg) async {", + " throw UnimplementedError();", + " }", + "}", + " ", + "final ${1/(.*)/${1:/camelcase}/}Provider = AsyncNotifierProviderFamily<$1Notifier, $1State, $3>(", + " ()=> $1Notifier(),", + ");" + ] + }, +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f48b39e..ddbd4fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ 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.5.0](https://github.com/krtirtho/spotube/compare/v3.4.1...v3.5.0) (2024-03-08) + + +### Features + +* add endless playback support [#285](https://github.com/krtirtho/spotube/issues/285) ([9dfd49c](https://github.com/krtirtho/spotube/commit/9dfd49ca04f0e915e333e205b17ac70456873f6e)) +* add getting started page ([96a2a1f](https://github.com/krtirtho/spotube/commit/96a2a1f5a622cb3c580041417d5023e37fa69716)) +* Add iOS background play support ([#1166](https://github.com/krtirtho/spotube/issues/1166)) ([095587e](https://github.com/krtirtho/spotube/commit/095587ee84f7d867c69fcf4b09ed608d63478e1e)) +* add songlink based track matching for youtube and open song link button ([9095a8c](https://github.com/krtirtho/spotube/commit/9095a8c8f849e42daabb7efcc20085cfb863c974)) +* **playlist:** show confirmation before deleting user playlist [#1222](https://github.com/krtirtho/spotube/issues/1222) ([9f92440](https://github.com/krtirtho/spotube/commit/9f9244062a39759aa0ce28d2d5f7c8fa53d73003)) +* Sort by Duration ([#1238](https://github.com/krtirtho/spotube/issues/1238)) ([6f8271f](https://github.com/krtirtho/spotube/commit/6f8271f5e9394cb4053e41dd222aa2844c34d609)) +* start radio support ([4defeef](https://github.com/krtirtho/spotube/commit/4defeefe7e5947aa00a2afb2a06577ec141cdc52)) +* **translations:** add Korean translation ([#1275](https://github.com/krtirtho/spotube/issues/1275)) ([fdea930](https://github.com/krtirtho/spotube/commit/fdea9307bbfb8f3f62cfb795bfb3ca58c38c33d9)) +* **translations:** Added Vietnamese ([#1135](https://github.com/krtirtho/spotube/issues/1135)) ([019ba86](https://github.com/krtirtho/spotube/commit/019ba865e20a8b54ea3490c01e47158eaf3a4c8d)) +* **windows:** Install Visual C++ 2015-2022 Redistributable if missing when installing ([ba69496](https://github.com/krtirtho/spotube/commit/ba69496dcc9a1b7f6ea4e104e71764a854d27f1f)) + + +### Bug Fixes + +* album images are small in certain places ([ca76a39](https://github.com/krtirtho/spotube/commit/ca76a39910b1a5af91aa7882a0d33c9d71db58a2)) +* album, artist page not loading [#1282](https://github.com/krtirtho/spotube/issues/1282) ([a9a1d4c](https://github.com/krtirtho/spotube/commit/a9a1d4c9dc24aaf3181dc4090d1822ebfe755991)) +* **android:** audio issue when screen is off and broadcast audio session id ([#1221](https://github.com/krtirtho/spotube/issues/1221) & [#1247](https://github.com/krtirtho/spotube/issues/1247)) ([17105a6](https://github.com/krtirtho/spotube/commit/17105a640bf5107bd5d333b9b4d097c14a3949a2)), closes [KRTirtho/spotube#571](https://github.com/KRTirtho/spotube/issues/571) +* **android:** only ask battery optimization once [#1252](https://github.com/krtirtho/spotube/issues/1252) ([e516afb](https://github.com/krtirtho/spotube/commit/e516afb185f616471822ea745495a3d1d1281bd3)) +* **android:** pressing back button in any other tab other than home exits the app ([c3289a0](https://github.com/krtirtho/spotube/commit/c3289a0ba4e7de094a15246677ffcb940504ebde)) +* **android:** system back button in player page exits the app ([3294f65](https://github.com/krtirtho/spotube/commit/3294f657fe8a03b18d9be8974968b6508465963d)) +* cleanTitle removing feat and ft from words instead of whole words ([8612345](https://github.com/krtirtho/spotube/commit/86123456f2ff577921cf62cffca180427dfe1dd5)) +* friends list not scrollable with mouse drag ([ab08c82](https://github.com/krtirtho/spotube/commit/ab08c82c8dd501263049f3adcbd48907ba13e3a9)) +* no draggable scrollbar in playlist/album page [#1158](https://github.com/krtirtho/spotube/issues/1158) ([6f71e52](https://github.com/krtirtho/spotube/commit/6f71e52ea8a5712d2c3527f2a524af9fbb718bef)) +* non-banger songs breaking the queue if sources not found ([90f7c53](https://github.com/krtirtho/spotube/commit/90f7c531cdc8640afdbabf5a0592159715ea1e6f)) +* track loading when not found in Youtube ([e964f61](https://github.com/krtirtho/spotube/commit/e964f61d38cb303e3d3fd60c866414f57207181c)) +* **translations:** Update app_nl.arb ([#1168](https://github.com/krtirtho/spotube/issues/1168)) ([8167963](https://github.com/krtirtho/spotube/commit/8167963212eeb5dfb0b4fb2eadf81d466659a9f1)) + ## [3.4.1](https://personal.github.com/krtirtho/spotube/compare/v3.4.0...v3.4.1) (2024-01-27) diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index 13996cea..e859f9e6 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -25,7 +25,7 @@ All types of contributions are encouraged and valued. See the [Table of Contents - [Before Submitting an Enhancement](#before-submitting-an-enhancement) - [How Do I Submit a Good Enhancement Suggestion?](#how-do-i-submit-a-good-enhancement-suggestion) - [Your First Code Contribution](#your-first-code-contribution) - - [Submit translations](#submit-translations) + - [Submit Translations](#submit-translations) ## Code of Conduct @@ -123,16 +123,16 @@ Do the following: - Install Development dependencies in linux - Debian (>=12/Bookworm)/Ubuntu ```bash - $ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev + $ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev avahi-daemon avahi-discover avahi-utils libnss-mdns mdns-scan ``` - Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04) - Arch/Manjaro ```bash - yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify + yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan ``` - Fedora ```bash - dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel + dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel avahi mdns-scan nss-mdns ``` - Clone the Repo - Create a `.env` in root of the project following the `.env.example` template diff --git a/README.md b/README.md index 18eb55aa..4ad4e1be 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,14 @@ eliminating the need for Spotify Premium Btw it's not just another Electron app 😉 -Visit the website +Visit the website Discord Server Support me on Patron Buy me a Coffee +[![HackerNews](https://hackerbadge.vercel.app/api?id=39066136&type=dark)](https://news.ycombinator.com/item?id=39066136) + Donate to our Open Collective --- @@ -136,6 +138,15 @@ This handy table lists all the methods you can use to install Spotube: + + Macos - Homebrew + +
+brew tap krtirtho/apps
+brew install --cask spotube
+
+ + Windows - Chocolatey @@ -193,6 +204,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [Piped](https://piped-docs.kavin.rocks/) - Piped is a privacy friendly alternative YouTube frontend, which is efficient and scalable by design. 1. [YouTube](https://youtube.com/) - YouTube is an American online video-sharing platform headquartered in San Bruno, California. Three former PayPal employees—Chad Hurley, Steve Chen, and Jawed Karim—created the service in February 2005 1. [JioSaavn](https://www.jiosaavn.com) - JioSaavn is an Indian online music streaming service and a digital distributor of Bollywood, English and other regional Indian music across the world. Since it was founded in 2007 as Saavn, the company has acquired rights to over 5 crore (50 million) music tracks in 15 languages +1. [SongLink](https://song.link) - SongLink is a free smart link service that helps you share music with your audience. It's a one-stop-shop for creating smart links for music, podcasts, and other audio content 1. [Linux](https://www.linux.org) - Linux is a family of open-source Unix-like operating systems based on the Linux kernel, an operating system kernel first released on September 17, 1991, by Linus Torvalds. Linux is typically packaged in a Linux distribution 1. [AUR](https://aur.archlinux.org) - AUR stands for Arch User Repository. It is a community-driven repository for Arch-based Linux distributions users 1. [Flatpak](https://flatpak.org) - Flatpak is a utility for software deployment and package management for Linux @@ -231,7 +243,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [flutter_hooks](https://github.com/rrousselGit/flutter_hooks) - A flutter implementation of React hooks. It adds a new kind of widget with enhanced code reuse. 1. [flutter_inappwebview](https://inappwebview.dev/) - A Flutter plugin that allows you to add an inline webview, to use an headless webview, and to open an in-app browser window. 1. [flutter_native_splash](https://pub.dev/packages/flutter_native_splash) - Customize Flutter's default white native splash screen with background color and splash image. Supports dark mode, full screen, and more. -1. [flutter_riverpod](https://riverpod.dev) - A simple way to access state from anywhere in your application while robust and testable. +1. [flutter_riverpod](https://riverpod.dev) - A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze. 1. [flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage) - Flutter Secure Storage provides API to store data in secure storage. Keychain is used in iOS, KeyStore based solution is used in Android. 1. [flutter_svg](https://pub.dev/packages/flutter_svg) - An SVG rendering and widget library for Flutter, which allows painting and displaying Scalable Vector Graphics 1.1 files. 1. [form_validator](https://github.com/TheMisir/form-validator) - Simplest form validation library for flutter's form field widgets @@ -240,7 +252,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [google_fonts](https://pub.dev/packages/google_fonts) - A Flutter package to use fonts from fonts.google.com. Supports HTTP fetching, caching, and asset bundling. 1. [hive](https://github.com/hivedb/hive/tree/master/hive) - Lightweight and blazing fast key-value database written in pure Dart. Strongly encrypted using AES-256. 1. [hive_flutter](https://github.com/hivedb/hive/tree/master/hive_flutter) - Extension for Hive. Makes it easier to use Hive in Flutter apps. -1. [hooks_riverpod](https://riverpod.dev) - A simple way to access state from anywhere in your application while robust and testable. +1. [hooks_riverpod](https://riverpod.dev) - A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze. 1. [html](https://pub.dev/packages/html) - APIs for parsing and manipulating HTML content outside the browser. 1. [http](https://pub.dev/packages/http) - A composable, multi-platform, Future-based API for HTTP requests. 1. [image_picker](https://pub.dev/packages/image_picker) - Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. @@ -257,14 +269,12 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [path](https://pub.dev/packages/path) - A string-based path manipulation library. All of the path operations you know and love, with solid support for Windows, POSIX (Linux and Mac OS X), and the web. 1. [path_provider](https://pub.dev/packages/path_provider) - Flutter plugin for getting commonly used locations on host platform file systems, such as the temp and app data directories. 1. [permission_handler](https://pub.dev/packages/permission_handler) - Permission plugin for Flutter. This plugin provides a cross-platform (iOS, Android) API to request and check permissions. -1. [piped_client](https://github.com/KRTirtho/piped_client) - API Client for piped.video 1. [popover](https://github.com/minikin/popover) - A popover is a transient view that appears above other content onscreen when you tap a control or in an area. 1. [scroll_to_index](https://github.com/quire-io/scroll-to-index) - Scroll to a specific child of any scrollable widget in Flutter 1. [sidebarx](https://github.com/Frezyx/sidebarx) - flutter multiplatform navigation sidebar / side navigationbar / drawer widget 1. [shared_preferences](https://pub.dev/packages/shared_preferences) - Flutter plugin for reading and writing simple key-value pairs. Wraps NSUserDefaults on iOS and SharedPreferences on Android. 1. [skeleton_text](https://github.com/101Loop/Skeleton-Text) - A package that provides an easy way to add skeleton text loading animation in Flutter project. This project is a part of 101Loop community. 1. [smtc_windows](https://github.com/KRTirtho/smtc_windows) - Windows `SystemMediaTransportControls` implementation for Flutter giving access to Windows OS Media Control applet. -1. [spotify](https://github.com/rinukkusu/spotify-dart) - An incomplete dart library for interfacing with the Spotify Web API. 1. [stroke_text](https://github.com/MohamedAbd0/stroke_text) - A Simple Flutter plugin for applying stroke (border) style to a text widget 1. [system_theme](https://pub.dev/packages/system_theme) - A plugin to get the current system theme info. Supports Android, Web, Windows, Linux and macOS 1. [titlebar_buttons](https://github.com/gtk-flutter/titlebar_buttons) - A package which provides most of the titlebar buttons from windows, linux and macos. @@ -287,6 +297,9 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [app_links](https://github.com/llfbandit/app_links) - Android App Links, Deep Links, iOs Universal Links and Custom URL schemes handler for Flutter (desktop included). 1. [win32_registry](https://win32.pub) - A package that provides a friendly Dart API for accessing the Windows Registry. 1. [flutter_sharing_intent](https://github.com/bhagat-techind/flutter_sharing_intent.git) - A flutter plugin that allow flutter apps to receive photos, videos, text, urls or any other file types from another app. +1. [flutter_broadcasts](https://pub.dev/packages/flutter_broadcasts) - A plugin for sending and receiving broadcasts with Android intents and iOS notifications. +1. [freezed_annotation](https://pub.dev/packages/freezed_annotation) - Annotations for the freezed code-generator. This package does nothing without freezed too. +1. [spotify](https://github.com/rinukkusu/spotify-dart) - An incomplete dart library for interfacing with the Spotify Web API. 1. [build_runner](https://pub.dev/packages/build_runner) - A build system for Dart code generation and modular compilation. 1. [envied_generator](https://github.com/petercinibulk/envied) - Generator for the Envied package. See https://pub.dev/packages/envied. 1. [flutter_distributor](https://distributor.leanflutter.org) - A complete tool for packaging and publishing your Flutter apps. @@ -297,7 +310,9 @@ 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. [freezed](https://pub.dev/packages/freezed) - Code generation for immutable classes that has a simple syntax/API without compromising on the features. 1. [flutter_desktop_tools](https://github.com/KRTirtho/flutter_desktop_tools) - Essential collection of tools for flutter desktop app development +1. [piped_client](https://github.com/KRTirtho/piped_client) - API Client for piped.video 1. [scrobblenaut](https://github.com/Nebulino/Scrobblenaut) - A deadly simple LastFM API Wrapper for Dart. So deadly simple that it's gonna hit the mark. 1. [window_size](https://github.com/google/flutter-desktop-embedding.git) - Allows resizing and repositioning the window containing Flutter. 1. [draggable_scrollbar](https://github.com/fluttercommunity/flutter-draggable-scrollbar) - A scrollbar that can be dragged for quickly navigation through a vertical list. Additional option is showing label next to scrollthumb with information about current item. diff --git a/analysis_options.yaml b/analysis_options.yaml index 5f2cbbe1..4ba476e0 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -25,6 +25,7 @@ linter: # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule file_names: false + avoid_renaming_method_parameters: false # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options @@ -34,3 +35,5 @@ analyzer: - patterns errors: invalid_annotation_target: ignore + plugins: + - custom_lint diff --git a/bin/translated_messages.dart b/bin/translated_messages.dart new file mode 100644 index 00000000..0de398df --- /dev/null +++ b/bin/translated_messages.dart @@ -0,0 +1,26 @@ +import 'dart:convert'; +import 'dart:io'; + +void main(List args) async { + final translatedFile = + jsonDecode(await File('tm.json').readAsString()) as Map; + + for (final MapEntry(:key, :value) in translatedFile.entries) { + print('Updating locale: $key'); + final file = File('lib/l10n/app_$key.arb'); + + final fileContent = + jsonDecode(await file.readAsString()) as Map; + + final newContent = { + ...fileContent, + ...value, + }; + + await file.writeAsString( + const JsonEncoder.withIndent(' ').convert(newContent), + ); + + print('✅ Updated locale: $key'); + } +} diff --git a/ios/Podfile b/ios/Podfile index bc3dcaa6..7235f482 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0b75217f..1d048cc9 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -5,6 +5,9 @@ PODS: - Flutter - audio_session (0.0.1): - Flutter + - bonsoir_darwin (0.0.1): + - Flutter + - FlutterMacOS - device_info_plus (0.0.1): - Flutter - DKImagePickerController/Core (4.3.4): @@ -44,11 +47,13 @@ PODS: - file_selector_ios (0.0.1): - Flutter - Flutter (1.0.0) - - flutter_inappwebview (0.0.1): + - flutter_broadcasts (0.0.1): - Flutter - - flutter_inappwebview/Core (= 0.0.1) + - flutter_inappwebview_ios (0.0.1): + - Flutter + - flutter_inappwebview_ios/Core (= 0.0.1) - OrderedSet (~> 5.0) - - flutter_inappwebview/Core (0.0.1): + - flutter_inappwebview_ios/Core (0.0.1): - Flutter - OrderedSet (~> 5.0) - flutter_keyboard_visibility (0.0.1): @@ -102,11 +107,13 @@ DEPENDENCIES: - app_links (from `.symlinks/plugins/app_links/ios`) - audio_service (from `.symlinks/plugins/audio_service/ios`) - audio_session (from `.symlinks/plugins/audio_session/ios`) + - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - Flutter (from `Flutter`) - - flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`) + - flutter_broadcasts (from `.symlinks/plugins/flutter_broadcasts/ios`) + - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - flutter_mailer (from `.symlinks/plugins/flutter_mailer/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) @@ -142,6 +149,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/audio_service/ios" audio_session: :path: ".symlinks/plugins/audio_session/ios" + bonsoir_darwin: + :path: ".symlinks/plugins/bonsoir_darwin/darwin" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" file_picker: @@ -150,8 +159,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/file_selector_ios/ios" Flutter: :path: Flutter - flutter_inappwebview: - :path: ".symlinks/plugins/flutter_inappwebview/ios" + flutter_broadcasts: + :path: ".symlinks/plugins/flutter_broadcasts/ios" + flutter_inappwebview_ios: + :path: ".symlinks/plugins/flutter_inappwebview_ios/ios" flutter_keyboard_visibility: :path: ".symlinks/plugins/flutter_keyboard_visibility/ios" flutter_mailer: @@ -191,13 +202,15 @@ SPEC CHECKSUMS: app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875 audio_service: f509d65da41b9521a61f1c404dd58651f265a567 audio_session: 4f3e461722055d21515cf3261b64c973c062f345 - device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea + bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 + device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - flutter_inappwebview: acd4fc0f012cefd09015000c241137d82f01ba62 + flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882 + flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83 flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef @@ -221,6 +234,6 @@ SPEC CHECKSUMS: Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 -PODFILE CHECKSUM: 5129d2e80ab0dfc533f262cedf032011b1dfe4fd +PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e COCOAPODS: 1.15.2 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 8e103cfa..ffd511a4 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -66,5 +66,11 @@ UIViewControllerBasedStatusBarAppearance + NSLocalNetworkUsageDescription + To allow other devices on the network control playback of Spotube securely. + NSBonjourServices + + _spotube._tcp + \ No newline at end of file diff --git a/lib/collections/fake.dart b/lib/collections/fake.dart index 8f5f9e8b..c5379ec6 100644 --- a/lib/collections/fake.dart +++ b/lib/collections/fake.dart @@ -6,7 +6,7 @@ abstract class FakeData { static final Image image = Image() ..height = 1 ..width = 1 - ..url = "url"; + ..url = "https://dummyimage.com/100x100/cfcfcf/cfcfcf.jpg"; static final Followers followers = Followers() ..href = "text" diff --git a/lib/collections/language_codes.dart b/lib/collections/language_codes.dart index 4754de46..bd3f8740 100644 --- a/lib/collections/language_codes.dart +++ b/lib/collections/language_codes.dart @@ -353,10 +353,10 @@ abstract class LanguageLocals { // name: "Kongo", // nativeName: "KiKongo", // ), - // "ko": const ISOLanguageName( - // name: "Korean", - // nativeName: "한국어 (韓國語), 조선말 (朝鮮語)", - // ), + "ko": const ISOLanguageName( + name: "Korean", + nativeName: "한국어 (韓國語), 조선말 (朝鮮語)", + ), // "ku": const ISOLanguageName( // name: "Kurdish", // nativeName: "Kurdî, كوردی‎", @@ -637,10 +637,10 @@ abstract class LanguageLocals { // name: "Tajik", // nativeName: "тоҷикӣ, toğikī, تاجیکی‎", // ), - // "th": const ISOLanguageName( - // name: "Thai", - // nativeName: "ไทย", - // ), + "th": const ISOLanguageName( + name: "Thai", + nativeName: "ไทย", + ), // "ti": const ISOLanguageName( // name: "Tigrinya", // nativeName: "ትግርኛ", diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 43d0cf2e..80067405 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -4,7 +4,10 @@ import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart' hide Search; +import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/pages/album/album.dart'; +import 'package:spotube/pages/connect/connect.dart'; +import 'package:spotube/pages/connect/control/control.dart'; import 'package:spotube/pages/getting_started/getting_started.dart'; import 'package:spotube/pages/home/genres/genre_playlists.dart'; import 'package:spotube/pages/home/genres/genres.dart'; @@ -96,8 +99,7 @@ final routerProvider = Provider((ref) { path: "result", pageBuilder: (context, state) => SpotubePage( child: PlaylistGenerateResultPage( - state: - state.extra as PlaylistGenerateResultRouteState, + state: state.extra as GeneratePlaylistProviderInput, ), ), ), @@ -173,6 +175,21 @@ final routerProvider = Provider((ref) { ); }, ), + GoRoute( + path: "/connect", + pageBuilder: (context, state) => const SpotubePage( + child: ConnectPage(), + ), + routes: [ + GoRoute( + path: "control", + pageBuilder: (context, state) { + return const SpotubePage( + child: ConnectControlPage(), + ); + }, + ) + ]) ], ), GoRoute( diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 6cf92085..6de21284 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -115,4 +115,10 @@ abstract class SpotubeIcons { static const github = SimpleIcons.github; static const openCollective = SimpleIcons.opencollective; static const anonymous = FeatherIcons.user; + static const history = FeatherIcons.clock; + static const connect = FeatherIcons.link; + static const speaker = FeatherIcons.speaker; + static const monitor = FeatherIcons.monitor; + static const power = FeatherIcons.power; + static const bluetooth = FeatherIcons.bluetooth; } diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index c7ae2f9a..678bfd06 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -1,17 +1,19 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/infinite_query.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/album.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; extension FormattedAlbumType on AlbumType { String get formatted => name.replaceFirst(name[0], name[0].toUpperCase()); @@ -21,8 +23,8 @@ class AlbumCard extends HookConsumerWidget { final AlbumSimple album; const AlbumCard( this.album, { - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { @@ -31,47 +33,25 @@ class AlbumCard extends HookConsumerWidget { useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final queryClient = useQueryClient(); - bool isPlaylistPlaying = useMemoized( () => playlist.containsCollection(album.id!), [playlist, album.id], ); final updating = useState(false); - final spotify = ref.watch(spotifyProvider); final scaffoldMessenger = ScaffoldMessenger.maybeOf(context); Future> fetchAllTrack() async { if (album.tracks != null && album.tracks!.isNotEmpty) { - return album.tracks! - .map((track) => - TypeConversionUtils.simpleTrack_X_Track(track, album)) - .toList(); + return album.tracks!.map((track) => track.asTrack(album)).toList(); } - final job = AlbumQueries.tracksOfJob(album.id!); - - final query = queryClient.createInfiniteQuery( - job.queryKey, - (page) => job.task(page, (spotify: spotify, album: album)), - initialPage: 0, - nextPage: job.nextPage, - ); - - return await query.fetchAllTracks( - getAllTracks: () async { - final res = await spotify.albums.tracks(album.id!).all(); - return res - .map((e) => TypeConversionUtils.simpleTrack_X_Track(e, album)) - .toList(); - }, - ); + await ref.read(albumTracksProvider(album).future); + return ref.read(albumTracksProvider(album).notifier).fetchAll(); } return PlaybuttonCard( - imageUrl: TypeConversionUtils.image_X_UrlString( - album.images, + imageUrl: album.images.asUrlString( placeholder: ImagePlaceholder.collection, ), margin: const EdgeInsets.symmetric(horizontal: 10), @@ -80,7 +60,7 @@ class AlbumCard extends HookConsumerWidget { updating.value, title: album.name!, description: - "${album.albumType?.formatted} • ${TypeConversionUtils.artists_X_String(album.artists ?? [])}", + "${album.albumType?.formatted} • ${album.artists?.asString() ?? ""}", onTap: () { ServiceUtils.push(context, "/album/${album.id}", extra: album); }, @@ -95,8 +75,19 @@ class AlbumCard extends HookConsumerWidget { if (fetchedTracks.isEmpty) return; - await playlistNotifier.load(fetchedTracks, autoPlay: true); - playlistNotifier.addCollection(album.id!); + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: fetchedTracks, + collectionId: album.id!, + ), + ); + } else { + await playlistNotifier.load(fetchedTracks, autoPlay: true); + playlistNotifier.addCollection(album.id!); + } } finally { updating.value = false; } diff --git a/lib/components/artist/artist_album_list.dart b/lib/components/artist/artist_album_list.dart index 5114170c..a91327ce 100644 --- a/lib/components/artist/artist_album_list.dart +++ b/lib/components/artist/artist_album_list.dart @@ -1,38 +1,35 @@ import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/logger.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class ArtistAlbumList extends HookConsumerWidget { final String artistId; ArtistAlbumList( this.artistId, { - Key? key, - }) : super(key: key); + super.key, + }); final logger = getLogger(ArtistAlbumList); @override Widget build(BuildContext context, ref) { - final albumsQuery = useQueries.artist.albumsOf(ref, artistId); + final albumsQuery = ref.watch(artistAlbumsProvider(artistId)); + final albumsQueryNotifier = + ref.watch(artistAlbumsProvider(artistId).notifier); - final albums = useMemoized(() { - return albumsQuery.pages - .expand((page) => page.items ?? const Iterable.empty()) - .toList(); - }, [albumsQuery.pages]); + final albums = albumsQuery.asData?.value.items ?? []; final theme = Theme.of(context); return HorizontalPlaybuttonCardView( isLoadingNextPage: albumsQuery.isLoadingNextPage, - hasNextPage: albumsQuery.hasNextPage, + hasNextPage: albumsQuery.asData?.value.hasMore ?? false, items: albums, - onFetchMore: albumsQuery.fetchNext, + onFetchMore: albumsQueryNotifier.fetchMore, title: Text( context.l10n.albums, style: theme.textTheme.headlineSmall, diff --git a/lib/components/artist/artist_card.dart b/lib/components/artist/artist_card.dart index 3526e88f..ebe18e72 100644 --- a/lib/components/artist/artist_card.dart +++ b/lib/components/artist/artist_card.dart @@ -6,22 +6,21 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class ArtistCard extends HookConsumerWidget { final Artist artist; - const ArtistCard(this.artist, {Key? key}) : super(key: key); + const ArtistCard(this.artist, {super.key}); @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); final backgroundImage = UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - artist.images, + artist.images.asUrlString( placeholder: ImagePlaceholder.artist, ), ); diff --git a/lib/components/connect/connect_device.dart b/lib/components/connect/connect_device.dart new file mode 100644 index 00000000..8ece074f --- /dev/null +++ b/lib/components/connect/connect_device.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class ConnectDeviceButton extends HookConsumerWidget { + const ConnectDeviceButton({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:colorScheme) = Theme.of(context); + final pixelRatio = MediaQuery.of(context).devicePixelRatio; + final connectClients = ref.watch(connectClientsProvider); + + return SizedBox( + height: 40 * pixelRatio, + child: Stack( + alignment: Alignment.centerRight, + fit: StackFit.loose, + children: [ + Center( + child: InkWell( + onTap: () { + ServiceUtils.push(context, "/connect"); + }, + borderRadius: BorderRadius.circular(50), + child: Ink( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50), + color: colorScheme.primaryContainer, + ), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (connectClients.asData?.value.resolvedService != + null) ...[ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: Colors.greenAccent, + borderRadius: BorderRadius.circular(50), + ), + ), + const Gap(5), + ], + Text(context.l10n.devices), + if (connectClients.asData?.value.services.isNotEmpty == + true) + Text( + " (${connectClients.asData?.value.services.length})", + style: TextStyle( + color: + colorScheme.onPrimaryContainer.withOpacity(0.5), + ), + ), + const Gap(35), + ], + ), + ), + ), + ), + Positioned( + right: 0, + child: IconButton.filled( + icon: const Icon(SpotubeIcons.speaker), + style: IconButton.styleFrom( + visualDensity: VisualDensity.standard, + foregroundColor: colorScheme.onPrimary, + ), + onPressed: () { + ServiceUtils.push(context, "/connect"); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/components/connect/local_devices.dart b/lib/components/connect/local_devices.dart new file mode 100644 index 00000000..dd7db971 --- /dev/null +++ b/lib/components/connect/local_devices.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; + +class ConnectPageLocalDevices extends HookWidget { + const ConnectPageLocalDevices({super.key}); + + @override + Widget build(BuildContext context) { + final ThemeData(:textTheme) = Theme.of(context); + final devicesFuture = useFuture(audioPlayer.devices); + final devicesStream = useStream(audioPlayer.devicesStream); + final selectedDeviceFuture = useFuture(audioPlayer.selectedDevice); + final selectedDeviceStream = useStream(audioPlayer.selectedDeviceStream); + + final devices = devicesStream.data ?? devicesFuture.data; + final selectedDevice = + selectedDeviceStream.data ?? selectedDeviceFuture.data; + + if (devices == null) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + return SliverMainAxisGroup( + slivers: [ + const SliverGap(10), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + sliver: SliverToBoxAdapter( + child: Text( + context.l10n.this_device, + style: textTheme.titleMedium, + ), + ), + ), + const SliverGap(10), + SliverList.separated( + itemCount: devices.length, + separatorBuilder: (context, index) => const Gap(10), + itemBuilder: (context, index) { + final device = devices[index]; + + return Card( + child: ListTile( + leading: const Icon(SpotubeIcons.speaker), + title: Text(device.description), + subtitle: Text(device.name), + selected: selectedDevice == device, + onTap: () => audioPlayer.setAudioDevice(device), + ), + ); + }, + ), + ], + ); + } +} diff --git a/lib/components/desktop_login/login_form.dart b/lib/components/desktop_login/login_form.dart index 5abb9524..a3deb54a 100644 --- a/lib/components/desktop_login/login_form.dart +++ b/lib/components/desktop_login/login_form.dart @@ -8,9 +8,9 @@ import 'package:spotube/provider/authentication_provider.dart'; class TokenLoginForm extends HookConsumerWidget { final void Function()? onDone; const TokenLoginForm({ - Key? key, + super.key, this.onDone, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/home/sections/featured.dart b/lib/components/home/sections/featured.dart index 8a7c2c95..0db5a1e8 100644 --- a/lib/components/home/sections/featured.dart +++ b/lib/components/home/sections/featured.dart @@ -1,35 +1,28 @@ import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HomeFeaturedSection extends HookConsumerWidget { - const HomeFeaturedSection({Key? key}) : super(key: key); + const HomeFeaturedSection({super.key}); @override Widget build(BuildContext context, ref) { - final featuredPlaylistsQuery = useQueries.playlist.featured(ref); - final playlists = useMemoized( - () => featuredPlaylistsQuery.pages - .whereType>() - .expand((page) => page.items ?? const []), - [featuredPlaylistsQuery.pages], - ); - final isLoadingFeaturedPlaylists = !featuredPlaylistsQuery.hasPageData && - !featuredPlaylistsQuery.isLoadingNextPage; + final featuredPlaylists = ref.watch(featuredPlaylistsProvider); + final featuredPlaylistsNotifier = + ref.watch(featuredPlaylistsProvider.notifier); return Skeletonizer( - enabled: isLoadingFeaturedPlaylists, + enabled: featuredPlaylists.isLoading, child: HorizontalPlaybuttonCardView( - items: playlists.toList(), + items: featuredPlaylists.asData?.value.items ?? [], title: Text(context.l10n.featured), - isLoadingNextPage: featuredPlaylistsQuery.isLoadingNextPage, - hasNextPage: featuredPlaylistsQuery.hasNextPage, - onFetchMore: featuredPlaylistsQuery.fetchNext, + isLoadingNextPage: featuredPlaylists.isLoadingNextPage, + hasNextPage: featuredPlaylists.asData?.value.hasMore ?? false, + onFetchMore: featuredPlaylistsNotifier.fetchMore, ), ); } diff --git a/lib/components/home/sections/friends.dart b/lib/components/home/sections/friends.dart index 6382f6fd..35ec09b0 100644 --- a/lib/components/home/sections/friends.dart +++ b/lib/components/home/sections/friends.dart @@ -1,4 +1,3 @@ -import 'dart:ffi'; import 'dart:ui'; import 'package:flutter/material.dart'; @@ -8,15 +7,16 @@ import 'package:spotube/collections/fake.dart'; import 'package:spotube/components/home/sections/friends/friend_item.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/models/spotify_friends.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HomePageFriendsSection extends HookConsumerWidget { - const HomePageFriendsSection({Key? key}) : super(key: key); + const HomePageFriendsSection({super.key}); @override Widget build(BuildContext context, ref) { - final friendsQuery = useQueries.user.friendActivity(ref); - final friends = friendsQuery.data?.friends ?? FakeData.friends.friends; + final friendsQuery = ref.watch(friendsProvider); + final friends = + friendsQuery.asData?.value.friends ?? FakeData.friends.friends; final groupCount = useBreakpointValue( sm: 3, @@ -51,8 +51,8 @@ class HomePageFriendsSection extends HookConsumerWidget { }, ); - if (!friendsQuery.isLoading && - (!friendsQuery.hasData || friendsQuery.data!.friends.isEmpty)) { + if (friendsQuery.isLoading || + friendsQuery.asData?.value.friends.isEmpty == true) { return const SliverToBoxAdapter( child: SizedBox.shrink(), ); diff --git a/lib/components/home/sections/friends/friend_item.dart b/lib/components/home/sections/friends/friend_item.dart index fcdadab7..b883e2cc 100644 --- a/lib/components/home/sections/friends/friend_item.dart +++ b/lib/components/home/sections/friends/friend_item.dart @@ -1,10 +1,8 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/models/spotify_friends.dart'; @@ -13,9 +11,9 @@ import 'package:spotube/provider/spotify_provider.dart'; class FriendItem extends HookConsumerWidget { final SpotifyFriendActivity friend; const FriendItem({ - Key? key, + super.key, required this.friend, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { @@ -24,7 +22,6 @@ class FriendItem extends HookConsumerWidget { colorScheme: colorScheme, ) = Theme.of(context); - final queryClient = useQueryClient(); final spotify = ref.watch(spotifyProvider); return Container( @@ -86,15 +83,11 @@ class FriendItem extends HookConsumerWidget { ..onTap = () async { context.push( "/${friend.track.context.path}", - extra: !friend.track.context.path - .startsWith("album") - ? null - : await queryClient.fetchQuery( - "album/${friend.track.album.id}", - () => spotify.albums.get( - friend.track.album.id, - ), - ), + extra: + !friend.track.context.path.startsWith("album") + ? null + : await spotify.albums + .get(friend.track.context.id), ); }, ), @@ -110,12 +103,7 @@ class FriendItem extends HookConsumerWidget { recognizer: TapGestureRecognizer() ..onTap = () async { final album = - await queryClient.fetchQuery( - "album/${friend.track.album.id}", - () => spotify.albums.get( - friend.track.album.id, - ), - ); + await spotify.albums.get(friend.track.album.id); if (context.mounted) { context.push( "/album/${friend.track.album.id}", diff --git a/lib/components/home/sections/genres.dart b/lib/components/home/sections/genres.dart index 41ba235c..ac2644f0 100644 --- a/lib/components/home/sections/genres.dart +++ b/lib/components/home/sections/genres.dart @@ -13,28 +13,26 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HomeGenresSection extends HookConsumerWidget { - const HomeGenresSection({Key? key}) : super(key: key); + const HomeGenresSection({super.key}); @override Widget build(BuildContext context, ref) { final ThemeData(:textTheme, :colorScheme) = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final recommendationMarket = ref.watch( - userPreferencesProvider.select((s) => s.recommendationMarket), + final categoriesQuery = ref.watch(categoriesProvider); + final categories = useMemoized( + () => + categoriesQuery.asData?.value + .where((c) => (c.icons?.length ?? 0) > 0) + .take(mediaQuery.mdAndDown ? 6 : 10) + .toList() ?? + [], + [mediaQuery.mdAndDown, categoriesQuery.asData?.value], ); - final categoriesQuery = - useQueries.category.listAll(ref, recommendationMarket); - - final categories = categoriesQuery.data - ?.where((c) => (c.icons?.length ?? 0) > 0) - .take(mediaQuery.mdAndDown ? 6 : 10) - .toList() ?? - []; return SliverMainAxisGroup( slivers: [ diff --git a/lib/components/home/sections/made_for_user.dart b/lib/components/home/sections/made_for_user.dart index a3f96899..d1d269f6 100644 --- a/lib/components/home/sections/made_for_user.dart +++ b/lib/components/home/sections/made_for_user.dart @@ -2,19 +2,19 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HomeMadeForUserSection extends HookConsumerWidget { - const HomeMadeForUserSection({Key? key}) : super(key: key); + const HomeMadeForUserSection({super.key}); @override Widget build(BuildContext context, ref) { - final madeForUser = useQueries.views.get(ref, "made-for-x-hub"); + final madeForUser = ref.watch(viewProvider("made-for-x-hub")); return SliverList.builder( - itemCount: madeForUser.data?["content"]?["items"]?.length ?? 0, + itemCount: madeForUser.asData?.value["content"]?["items"]?.length ?? 0, itemBuilder: (context, index) { - final item = madeForUser.data?["content"]?["items"]?[index]; + final item = madeForUser.asData?.value["content"]?["items"]?[index]; final playlists = item["content"]?["items"] ?.where((itemL2) => itemL2["type"] == "playlist") .map((itemL2) => PlaylistSimple.fromJson(itemL2)) diff --git a/lib/components/home/sections/new_releases.dart b/lib/components/home/sections/new_releases.dart index 0f4a046a..57af12fd 100644 --- a/lib/components/home/sections/new_releases.dart +++ b/lib/components/home/sections/new_releases.dart @@ -1,56 +1,35 @@ import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HomeNewReleasesSection extends HookConsumerWidget { - const HomeNewReleasesSection({Key? key}) : super(key: key); + const HomeNewReleasesSection({super.key}); @override Widget build(BuildContext context, ref) { final auth = ref.watch(AuthenticationNotifier.provider); - final newReleases = useQueries.album.newReleases(ref); - final userArtistsQuery = useQueries.artist.followedByMeAll(ref); - final userArtists = - userArtistsQuery.data?.map((s) => s.id!).toList() ?? const []; + final newReleases = ref.watch(albumReleasesProvider); + final newReleasesNotifier = ref.read(albumReleasesProvider.notifier); - final albums = useMemoized( - () { - final allReleases = newReleases.pages - .whereType>() - .expand((page) => page.items ?? const []) - .map((album) => TypeConversionUtils.simpleAlbum_X_Album(album)); + final albums = ref.watch(userArtistAlbumReleasesProvider); - final userArtistReleases = allReleases.where((album) { - return album.artists - ?.any((artist) => userArtists.contains(artist.id!)) == - true; - }).toList(); - - if (userArtistReleases.isEmpty) return allReleases.toList(); - return userArtistReleases; - }, - [newReleases.pages], - ); - - final hasNewReleases = newReleases.hasPageData && - userArtistsQuery.hasData && - !newReleases.isLoadingNextPage; - - if (auth == null || !hasNewReleases) return const SizedBox.shrink(); + if (auth == null || + newReleases.isLoading || + newReleases.asData?.value.items.isEmpty == true) { + return const SizedBox.shrink(); + } return HorizontalPlaybuttonCardView( items: albums, title: Text(context.l10n.new_releases), isLoadingNextPage: newReleases.isLoadingNextPage, - hasNextPage: newReleases.hasNextPage, - onFetchMore: newReleases.fetchNext, + hasNextPage: newReleases.asData?.value.hasMore ?? false, + onFetchMore: newReleasesNotifier.fetchMore, ); } } diff --git a/lib/components/library/playlist_generate/multi_select_field.dart b/lib/components/library/playlist_generate/multi_select_field.dart index ed5eb38f..e54fc2ba 100644 --- a/lib/components/library/playlist_generate/multi_select_field.dart +++ b/lib/components/library/playlist_generate/multi_select_field.dart @@ -25,7 +25,7 @@ class MultiSelectField extends HookWidget { final bool enabled; const MultiSelectField({ - Key? key, + super.key, required this.options, required this.selectedOptions, required this.getValueForOption, @@ -36,7 +36,7 @@ class MultiSelectField extends HookWidget { this.dialogTitle, this.helperText, this.enabled = true, - }) : super(key: key); + }); Widget defaultSelectedOptionBuilder(T option) { return Chip( @@ -134,14 +134,14 @@ class _MultiSelectDialog extends HookWidget { final String? helperText; const _MultiSelectDialog({ - Key? key, + super.key, required this.dialogTitle, required this.options, required this.getValueForOption, this.optionBuilder, this.initialSelection = const [], this.helperText, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/library/playlist_generate/recommendation_attribute_dials.dart b/lib/components/library/playlist_generate/recommendation_attribute_dials.dart index 87f7cb1b..d7f51ffb 100644 --- a/lib/components/library/playlist_generate/recommendation_attribute_dials.dart +++ b/lib/components/library/playlist_generate/recommendation_attribute_dials.dart @@ -20,12 +20,12 @@ class RecommendationAttributeDials extends HookWidget { final double base; const RecommendationAttributeDials({ - Key? key, + super.key, required this.values, required this.onChanged, required this.title, this.base = 1, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/library/playlist_generate/recommendation_attribute_fields.dart b/lib/components/library/playlist_generate/recommendation_attribute_fields.dart index de169147..75437360 100644 --- a/lib/components/library/playlist_generate/recommendation_attribute_fields.dart +++ b/lib/components/library/playlist_generate/recommendation_attribute_fields.dart @@ -12,12 +12,12 @@ class RecommendationAttributeFields extends HookWidget { final Map? presets; const RecommendationAttributeFields({ - Key? key, + super.key, required this.values, required this.onChanged, required this.title, this.presets, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart b/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart index b1665d32..73c58deb 100644 --- a/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart +++ b/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart @@ -26,7 +26,7 @@ class SeedsMultiAutocomplete extends HookWidget { final SelectedItemDisplayType selectedItemDisplayType; const SeedsMultiAutocomplete({ - Key? key, + super.key, required this.seeds, required this.fetchSeeds, required this.autocompleteOptionBuilder, @@ -35,7 +35,7 @@ class SeedsMultiAutocomplete extends HookWidget { this.inputDecoration, this.enabled = true, this.selectedItemDisplayType = SelectedItemDisplayType.wrap, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/library/playlist_generate/simple_track_tile.dart b/lib/components/library/playlist_generate/simple_track_tile.dart index 86800d06..cf4ddb1a 100644 --- a/lib/components/library/playlist_generate/simple_track_tile.dart +++ b/lib/components/library/playlist_generate/simple_track_tile.dart @@ -4,16 +4,16 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/extensions/image.dart'; class SimpleTrackTile extends HookWidget { final Track track; final VoidCallback? onDelete; const SimpleTrackTile({ - Key? key, + super.key, required this.track, this.onDelete, - }) : super(key: key); + }); @override Widget build(BuildContext context) { @@ -21,8 +21,7 @@ class SimpleTrackTile extends HookWidget { leading: ClipRRect( borderRadius: BorderRadius.circular(8), child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString( - track.album?.images, + path: (track.album?.images).asUrlString( placeholder: ImagePlaceholder.artist, ), height: 40, diff --git a/lib/components/library/user_albums.dart b/lib/components/library/user_albums.dart index 200d1c59..f58d6693 100644 --- a/lib/components/library/user_albums.dart +++ b/lib/components/library/user_albums.dart @@ -4,7 +4,6 @@ import 'package:collection/collection.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -13,44 +12,39 @@ import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/shared/waypoint.dart'; +import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; - -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class UserAlbums extends HookConsumerWidget { - const UserAlbums({Key? key}) : super(key: key); + const UserAlbums({super.key}); @override Widget build(BuildContext context, ref) { final auth = ref.watch(AuthenticationNotifier.provider); - final albumsQuery = useQueries.album.ofMine(ref); + final albumsQuery = ref.watch(favoriteAlbumsProvider); + final albumsQueryNotifier = ref.watch(favoriteAlbumsProvider.notifier); final controller = useScrollController(); final searchText = useState(''); - final allAlbums = useMemoized( - () => albumsQuery.pages - .expand((element) => element.items ?? []), - [albumsQuery.pages], - ); - final albums = useMemoized(() { if (searchText.value.isEmpty) { - return allAlbums; + return albumsQuery.asData?.value.items ?? []; } - return allAlbums - .map((e) => ( - weightedRatio(e.name!, searchText.value), - e, - )) - .sorted((a, b) => b.$1.compareTo(a.$1)) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList(); - }, [allAlbums, searchText.value]); + return albumsQuery.asData?.value.items + .map((e) => ( + weightedRatio(e.name!, searchText.value), + e, + )) + .sorted((a, b) => b.$1.compareTo(a.$1)) + .where((e) => e.$1 > 50) + .map((e) => e.$2) + .toList() ?? + []; + }, [albumsQuery.asData?.value, searchText.value]); if (auth == null) { return const AnonymousFallback(); @@ -60,7 +54,7 @@ class UserAlbums extends HookConsumerWidget { return RefreshIndicator( onRefresh: () async { - await albumsQuery.refresh(); + ref.invalidate(favoriteAlbumsProvider); }, child: SafeArea( child: Scaffold( @@ -85,7 +79,7 @@ class UserAlbums extends HookConsumerWidget { padding: const EdgeInsets.all(8.0), controller: controller, child: Skeletonizer( - enabled: albumsQuery.pages.isEmpty, + enabled: albumsQuery.isLoading, child: Center( child: Wrap( runSpacing: 20, @@ -93,7 +87,8 @@ class UserAlbums extends HookConsumerWidget { runAlignment: WrapAlignment.center, crossAxisAlignment: WrapCrossAlignment.center, children: [ - if (albumsQuery.pages.isEmpty) + if (albumsQuery.asData?.value == null || + albumsQuery.asData!.value.items.isEmpty) ...List.generate( 10, (index) => AlbumCard(FakeData.album), @@ -103,16 +98,17 @@ class UserAlbums extends HookConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, children: [NotFound()], ), - for (final album in albums) - AlbumCard( - TypeConversionUtils.simpleAlbum_X_Album(album), - ), - if (albums.isNotEmpty && albumsQuery.hasNextPage) - Waypoint( - controller: controller, - isGrid: true, - onTouchEdge: albumsQuery.fetchNext, - child: AlbumCard(FakeData.album), + for (final album in albums) AlbumCard(album.toAlbum()), + if (albums.isNotEmpty && + albumsQuery.asData?.value.hasMore == true) + Skeletonizer( + enabled: true, + child: Waypoint( + controller: controller, + isGrid: true, + onTouchEdge: albumsQueryNotifier.fetchMore, + child: AlbumCard(FakeData.album), + ), ) ], ), diff --git a/lib/components/library/user_artists.dart b/lib/components/library/user_artists.dart index 36b8528e..de6830c8 100644 --- a/lib/components/library/user_artists.dart +++ b/lib/components/library/user_artists.dart @@ -13,22 +13,22 @@ import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class UserArtists extends HookConsumerWidget { - const UserArtists({Key? key}) : super(key: key); + const UserArtists({super.key}); @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); final auth = ref.watch(AuthenticationNotifier.provider); - final artistQuery = useQueries.artist.followedByMeAll(ref); + final artistQuery = ref.watch(followedArtistsProvider); final searchText = useState(''); final filteredArtists = useMemoized(() { - final artists = artistQuery.data ?? []; + final artists = artistQuery.asData?.value.items ?? []; if (searchText.value.isEmpty) { return artists.toList(); @@ -42,7 +42,7 @@ class UserArtists extends HookConsumerWidget { .where((e) => e.$1 > 50) .map((e) => e.$2) .toList(); - }, [artistQuery.data, searchText.value]); + }, [artistQuery.asData?.value.items, searchText.value]); final controller = useScrollController(); @@ -66,7 +66,7 @@ class UserArtists extends HookConsumerWidget { ), ), backgroundColor: theme.scaffoldBackgroundColor, - body: artistQuery.data?.isEmpty == true + body: artistQuery.asData?.value.items.isEmpty == true ? Padding( padding: const EdgeInsets.all(20), child: Row( @@ -80,7 +80,7 @@ class UserArtists extends HookConsumerWidget { ) : RefreshIndicator( onRefresh: () async { - await artistQuery.refresh(); + ref.invalidate(followedArtistsProvider); }, child: InterScrollbar( controller: controller, @@ -109,8 +109,9 @@ class UserArtists extends HookConsumerWidget { ) ] : filteredArtists - .mapIndexed((index, artist) => - ArtistCard(artist)) + .mapIndexed( + (index, artist) => ArtistCard(artist), + ) .toList(), ), ), diff --git a/lib/components/library/user_downloads.dart b/lib/components/library/user_downloads.dart index c8ceee66..3a1162e6 100644 --- a/lib/components/library/user_downloads.dart +++ b/lib/components/library/user_downloads.dart @@ -7,7 +7,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; class UserDownloads extends HookConsumerWidget { - const UserDownloads({Key? key}) : super(key: key); + const UserDownloads({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/library/user_downloads/download_item.dart b/lib/components/library/user_downloads/download_item.dart index 10dec410..a145fdad 100644 --- a/lib/components/library/user_downloads/download_item.dart +++ b/lib/components/library/user_downloads/download_item.dart @@ -4,18 +4,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/services/download_manager/download_status.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class DownloadItem extends HookConsumerWidget { final Track track; const DownloadItem({ - Key? key, + super.key, required this.track, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { @@ -51,16 +52,15 @@ class DownloadItem extends HookConsumerWidget { child: UniversalImage( height: 40, width: 40, - path: TypeConversionUtils.image_X_UrlString( - track.album?.images, + path: (track.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, ), ), ), ), title: Text(track.name ?? ''), - subtitle: TypeConversionUtils.artists_X_ClickableArtists( - track.artists ?? [], + subtitle: ArtistLink( + artists: track.artists ?? [], mainAxisAlignment: WrapAlignment.start, ), trailing: isQueryingSourceInfo diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index 095e6e97..778558f6 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -21,12 +21,13 @@ import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; const supportedAudioTypes = [ @@ -111,7 +112,7 @@ final localTracksProvider = FutureProvider>((ref) async { final tracks = filesWithMetadata .map( (fileWithMetadata) => LocalTrack.fromTrack( - track: TypeConversionUtils.localTrack_X_Track( + track: Track().fromFile( fileWithMetadata["file"], metadata: fileWithMetadata["metadata"], art: fileWithMetadata["art"], @@ -129,7 +130,7 @@ final localTracksProvider = FutureProvider>((ref) async { }); class UserLocalTracks extends HookConsumerWidget { - const UserLocalTracks({Key? key}) : super(key: key); + const UserLocalTracks({super.key}); Future playLocalTracks( WidgetRef ref, @@ -159,7 +160,7 @@ class UserLocalTracks extends HookConsumerWidget { final playlist = ref.watch(ProxyPlaylistNotifier.provider); final trackSnapshot = ref.watch(localTracksProvider); final isPlaylistPlaying = - playlist.containsTracks(trackSnapshot.value ?? []); + playlist.containsTracks(trackSnapshot.asData?.value ?? []); final searchController = useTextEditingController(); useValueListenable(searchController); @@ -176,13 +177,13 @@ class UserLocalTracks extends HookConsumerWidget { children: [ const SizedBox(width: 10), FilledButton( - onPressed: trackSnapshot.value != null + onPressed: trackSnapshot.asData?.value != null ? () async { - if (trackSnapshot.value?.isNotEmpty == true) { + if (trackSnapshot.asData?.value.isNotEmpty == true) { if (!isPlaylistPlaying) { await playLocalTracks( ref, - trackSnapshot.value!, + trackSnapshot.asData!.value, ); } else { // TODO: Remove stop capability @@ -217,7 +218,7 @@ class UserLocalTracks extends HookConsumerWidget { FilledButton( child: const Icon(SpotubeIcons.refresh), onPressed: () { - ref.refresh(localTracksProvider); + ref.invalidate(localTracksProvider); }, ) ], @@ -242,7 +243,7 @@ class UserLocalTracks extends HookConsumerWidget { return sortedTracks .map((e) => ( weightedRatio( - "${e.name} - ${TypeConversionUtils.artists_X_String(e.artists ?? [])}", + "${e.name} - ${e.artists?.asString() ?? ""}", searchController.text, ), e, @@ -269,7 +270,7 @@ class UserLocalTracks extends HookConsumerWidget { return Expanded( child: RefreshIndicator( onRefresh: () async { - ref.refresh(localTracksProvider); + ref.invalidate(localTracksProvider); }, child: InterScrollbar( controller: controller, @@ -282,12 +283,17 @@ class UserLocalTracks extends HookConsumerWidget { trackSnapshot.isLoading ? 5 : filteredTracks.length, itemBuilder: (context, index) { if (trackSnapshot.isLoading) { - return TrackTile(track: FakeData.track, index: index); + return TrackTile( + playlist: playlist, + track: FakeData.track, + index: index, + ); } final track = filteredTracks[index]; return TrackTile( index: index, + playlist: playlist, track: track, userPlaylist: false, onTap: () async { @@ -310,8 +316,11 @@ class UserLocalTracks extends HookConsumerWidget { enabled: true, child: ListView.builder( itemCount: 5, - itemBuilder: (context, index) => - TrackTile(track: FakeData.track, index: index), + itemBuilder: (context, index) => TrackTile( + track: FakeData.track, + index: index, + playlist: playlist, + ), ), ), ), diff --git a/lib/components/library/user_playlists.dart b/lib/components/library/user_playlists.dart index 32e91ed6..3ff028b6 100644 --- a/lib/components/library/user_playlists.dart +++ b/lib/components/library/user_playlists.dart @@ -17,10 +17,10 @@ import 'package:spotube/components/shared/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class UserPlaylists extends HookConsumerWidget { - const UserPlaylists({Key? key}) : super(key: key); + const UserPlaylists({super.key}); @override Widget build(BuildContext context, ref) { @@ -28,13 +28,9 @@ class UserPlaylists extends HookConsumerWidget { final auth = ref.watch(AuthenticationNotifier.provider); - final playlistsQuery = useQueries.playlist.ofMine(ref); - - final pagePlaylists = useMemoized( - () => playlistsQuery.pages - .expand((page) => page.items?.toList() ?? []), - [playlistsQuery.pages], - ); + final playlistsQuery = ref.watch(favoritePlaylistsProvider); + final playlistsQueryNotifier = + ref.watch(favoritePlaylistsProvider.notifier); final likedTracksPlaylist = useMemoized( () => PlaylistSimple() @@ -58,12 +54,12 @@ class UserPlaylists extends HookConsumerWidget { if (searchText.value.isEmpty) { return [ likedTracksPlaylist, - ...pagePlaylists, + ...?playlistsQuery.asData?.value.items, ]; } return [ likedTracksPlaylist, - ...pagePlaylists, + ...?playlistsQuery.asData?.value.items, ] .map((e) => (weightedRatio(e.name!, searchText.value), e)) .sorted((a, b) => b.$1.compareTo(a.$1)) @@ -71,7 +67,7 @@ class UserPlaylists extends HookConsumerWidget { .map((e) => e.$2) .toList(); }, - [pagePlaylists, searchText.value], + [playlistsQuery, searchText.value], ); final controller = useScrollController(); @@ -81,7 +77,9 @@ class UserPlaylists extends HookConsumerWidget { } return RefreshIndicator( - onRefresh: playlistsQuery.refresh, + onRefresh: () async { + ref.invalidate(favoritePlaylistsProvider); + }, child: SafeArea( child: InterScrollbar( controller: controller, @@ -132,14 +130,14 @@ class UserPlaylists extends HookConsumerWidget { ), itemBuilder: (context, index) { if (playlists.isNotEmpty && index == playlists.length) { - if (!playlistsQuery.hasNextPage) { + if (playlistsQuery.asData?.value.hasMore != true) { return const SizedBox.shrink(); } return Waypoint( controller: controller, isGrid: true, - onTouchEdge: playlistsQuery.fetchNext, + onTouchEdge: playlistsQueryNotifier.fetchMore, child: Skeletonizer( enabled: true, child: PlaylistCard(FakeData.playlistSimple), diff --git a/lib/components/lyrics/zoom_controls.dart b/lib/components/lyrics/zoom_controls.dart index f50ea71d..73beb4ae 100644 --- a/lib/components/lyrics/zoom_controls.dart +++ b/lib/components/lyrics/zoom_controls.dart @@ -17,7 +17,7 @@ class ZoomControls extends HookWidget { final String unit; const ZoomControls({ - Key? key, + super.key, required this.value, required this.onChanged, this.min, @@ -27,7 +27,7 @@ class ZoomControls extends HookWidget { this.decreaseIcon = const Icon(SpotubeIcons.zoomOut), this.direction = Axis.horizontal, this.unit = "%", - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index a43fcbca..6dbd9b11 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart' hide Offset; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/player/player_actions.dart'; @@ -13,28 +12,33 @@ import 'package:spotube/components/player/player_queue.dart'; import 'package:spotube/components/player/volume_slider.dart'; import 'package:spotube/components/shared/animated_gradient.dart'; import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; +import 'package:spotube/components/shared/links/artist_link.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/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/provider/volume_provider.dart'; +import 'package:spotube/services/sourced_track/sources/youtube.dart'; + import 'package:url_launcher/url_launcher_string.dart'; class PlayerView extends HookConsumerWidget { final PanelController panelController; final ScrollController scrollController; const PlayerView({ - Key? key, + super.key, required this.panelController, required this.scrollController, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { @@ -43,9 +47,7 @@ class PlayerView extends HookConsumerWidget { final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select( (value) => value.activeTrack, )); - final isLocalTrack = ref.watch(ProxyPlaylistNotifier.provider.select( - (value) => value.activeTrack is LocalTrack, - )); + final isLocalTrack = currentTrack is LocalTrack; final mediaQuery = MediaQuery.of(context); useEffect(() { @@ -58,8 +60,7 @@ class PlayerView extends HookConsumerWidget { }, [mediaQuery.lgAndUp]); String albumArt = useMemoized( - () => TypeConversionUtils.image_X_UrlString( - currentTrack?.album?.images, + () => (currentTrack?.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, ), [currentTrack?.album?.images], @@ -138,26 +139,31 @@ class PlayerView extends HookConsumerWidget { onPressed: panelController.close, ), actions: [ - IconButton( - icon: Assets.logos.songlink.image( - width: 20, - height: 20, - ), - tooltip: context.l10n.song_link, - onPressed: currentTrack == null - ? null - : () { - final url = - "https://song.link/s/${currentTrack.id}"; + if (currentTrack is YoutubeSourcedTrack) + TextButton.icon( + icon: Assets.logos.songlinkTransparent.image( + width: 20, + height: 20, + color: bodyTextColor, + ), + label: Text(context.l10n.song_link), + style: TextButton.styleFrom( + foregroundColor: bodyTextColor, + padding: EdgeInsets.zero, + ), + onPressed: () { + final url = + "https://song.link/s/${currentTrack.id}"; - launchUrlString(url); - }, - ), + launchUrlString(url); + }, + ), IconButton( icon: const Icon(SpotubeIcons.info, size: 18), tooltip: context.l10n.details, style: IconButton.styleFrom( - foregroundColor: bodyTextColor), + foregroundColor: bodyTextColor, + ), onPressed: currentTrack == null ? null : () { @@ -233,19 +239,15 @@ class PlayerView extends HookConsumerWidget { ), if (isLocalTrack) Text( - TypeConversionUtils.artists_X_String< - Artist>( - currentTrack?.artists ?? [], - ), + currentTrack.artists?.asString() ?? "", style: theme.textTheme.bodyMedium!.copyWith( fontWeight: FontWeight.bold, color: bodyTextColor, ), ) else - TypeConversionUtils - .artists_X_ClickableArtists( - currentTrack?.artists ?? [], + ArtistLink( + artists: currentTrack?.artists ?? [], textStyle: theme.textTheme.bodyMedium!.copyWith( fontWeight: FontWeight.bold, @@ -301,10 +303,25 @@ class PlayerView extends HookConsumerWidget { .height * .7, ), - builder: (context) { - return const PlayerQueue( - floating: false); - }, + builder: (context) => Consumer( + builder: (context, ref, _) { + final playlist = ref.watch( + ProxyPlaylistNotifier + .provider, + ); + final playlistNotifier = + ref.read( + ProxyPlaylistNotifier + .notifier, + ); + return PlayerQueue + .fromProxyPlaylistNotifier( + floating: false, + playlist: playlist, + notifier: playlistNotifier, + ); + }, + ), ); } : null), @@ -362,11 +379,21 @@ class PlayerView extends HookConsumerWidget { enabledThumbRadius: 8, ), ), - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: VolumeSlider( - fullWidth: true, - ), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16), + child: Consumer(builder: (context, ref, _) { + final volume = ref.watch(volumeProvider); + return VolumeSlider( + fullWidth: true, + value: volume, + onChanged: (value) { + ref + .read(volumeProvider.notifier) + .setVolume(value); + }, + ); + }), ), ), ], diff --git a/lib/components/player/player_actions.dart b/lib/components/player/player_actions.dart index 7a248aa5..4102e2ba 100644 --- a/lib/components/player/player_actions.dart +++ b/lib/components/player/player_actions.dart @@ -3,12 +3,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart' hide Offset; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/player/sibling_tracks_sheet.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/heart_button.dart'; -import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/models/local_track.dart'; @@ -17,7 +16,6 @@ import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/sleep_timer_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerActions extends HookConsumerWidget { final MainAxisAlignment mainAxisAlignment; @@ -29,13 +27,12 @@ class PlayerActions extends HookConsumerWidget { this.floatingQueue = true, this.showQueue = true, this.extraActions, - Key? key, - }) : super(key: key); + super.key, + }); final logger = getLogger(PlayerActions); @override Widget build(BuildContext context, ref) { - final mediaQuery = MediaQuery.of(context); final playlist = ref.watch(ProxyPlaylistNotifier.provider); final isLocalTrack = playlist.activeTrack is LocalTrack; ref.watch(downloadManagerProvider); @@ -58,10 +55,8 @@ class PlayerActions extends HookConsumerWidget { (element) => element.name == playlist.activeTrack?.name && element.album?.name == playlist.activeTrack?.album?.name && - TypeConversionUtils.artists_X_String( - element.artists ?? []) == - TypeConversionUtils.artists_X_String( - playlist.activeTrack?.artists ?? []), + element.artists?.asString() == + playlist.activeTrack?.artists?.asString(), ) == true; }, [localTracks, playlist.activeTrack]); diff --git a/lib/components/player/player_controls.dart b/lib/components/player/player_controls.dart index 1000af18..0190e2e6 100644 --- a/lib/components/player/player_controls.dart +++ b/lib/components/player/player_controls.dart @@ -21,8 +21,8 @@ class PlayerControls extends HookConsumerWidget { PlayerControls({ this.palette, this.compact = false, - Key? key, - }) : super(key: key); + super.key, + }); final logger = getLogger(PlayerControls); @@ -256,20 +256,16 @@ class PlayerControls extends HookConsumerWidget { onPressed: playlist.isFetching == true ? null : () async { - switch (await audioPlayer.loopMode) { - case PlaybackLoopMode.all: - audioPlayer - .setLoopMode(PlaybackLoopMode.one); - break; - case PlaybackLoopMode.one: - audioPlayer - .setLoopMode(PlaybackLoopMode.none); - break; - case PlaybackLoopMode.none: - audioPlayer - .setLoopMode(PlaybackLoopMode.all); - break; - } + audioPlayer.setLoopMode( + switch (loopMode) { + PlaybackLoopMode.all => + PlaybackLoopMode.one, + PlaybackLoopMode.one => + PlaybackLoopMode.none, + PlaybackLoopMode.none => + PlaybackLoopMode.all, + }, + ); }, ); }), diff --git a/lib/components/player/player_overlay.dart b/lib/components/player/player_overlay.dart index 2d63811e..e2ca9674 100644 --- a/lib/components/player/player_overlay.dart +++ b/lib/components/player/player_overlay.dart @@ -19,8 +19,8 @@ class PlayerOverlay extends HookConsumerWidget { const PlayerOverlay({ required this.albumArt, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { @@ -115,7 +115,7 @@ class PlayerOverlay extends HookConsumerWidget { width: double.infinity, color: Colors.transparent, child: PlayerTrackDetails( - albumArt: albumArt, + track: playlist.activeTrack, color: textColor, ), ), diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 2784fb5f..0bf61da4 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -5,30 +5,56 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerQueue extends HookConsumerWidget { final bool floating; + final ProxyPlaylist playlist; + + final Future Function(Track track) onJump; + final Future Function(String trackId) onRemove; + final Future Function(int oldIndex, int newIndex) onReorder; + final Future Function() onStop; + const PlayerQueue({ this.floating = true, - Key? key, - }) : super(key: key); + required this.playlist, + required this.onJump, + required this.onRemove, + required this.onReorder, + required this.onStop, + super.key, + }); + + PlayerQueue.fromProxyPlaylistNotifier({ + this.floating = true, + required this.playlist, + required ProxyPlaylistNotifier notifier, + super.key, + }) : onJump = notifier.jumpToTrack, + onRemove = notifier.removeTrack, + onReorder = notifier.moveTrack, + onStop = notifier.stop; @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final mediaQuery = MediaQuery.of(context); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final playlist = ref.watch(ProxyPlaylistNotifier.provider); final controller = useAutoScrollController(); final searchText = useState(''); @@ -44,7 +70,6 @@ class PlayerQueue extends HookConsumerWidget { topRight: Radius.circular(10), ); final theme = Theme.of(context); - final mediaQuery = MediaQuery.of(context); final headlineColor = theme.textTheme.headlineSmall?.color; final filteredTracks = useMemoized( @@ -55,7 +80,7 @@ class PlayerQueue extends HookConsumerWidget { return tracks .map((e) => ( weightedRatio( - '${e.name!} - ${TypeConversionUtils.artists_X_String(e.artists!)}', + '${e.name!} - ${e.artists?.asString() ?? ""}', searchText.value, ), e @@ -83,201 +108,204 @@ class PlayerQueue extends HookConsumerWidget { return const NotFound(vertical: true); } - return ClipRRect( - borderRadius: borderRadius, - clipBehavior: Clip.hardEdge, - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 15, - sigmaY: 15, - ), - child: Container( - padding: const EdgeInsets.only( - top: 5.0, - ), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceVariant.withOpacity(0.5), - borderRadius: borderRadius, - ), - child: CallbackShortcuts( - bindings: { - LogicalKeySet(LogicalKeyboardKey.escape): () { - if (!isSearching.value) { - Navigator.of(context).pop(); - } - isSearching.value = false; - searchText.value = ''; - } - }, - child: Column( - children: [ - if (!floating) - Container( - height: 5, - width: 100, - margin: const EdgeInsets.only(bottom: 5, top: 2), - decoration: BoxDecoration( - color: headlineColor, - borderRadius: BorderRadius.circular(20), - ), - ), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (mediaQuery.mdAndUp || !isSearching.value) ...[ - const SizedBox(width: 10), - Text( - context.l10n.tracks_in_queue(tracks.length), - style: TextStyle( - color: headlineColor, - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - const Spacer(), - ], - if (mediaQuery.mdAndUp || isSearching.value) - TextField( - onChanged: (value) { - searchText.value = value; - }, - decoration: InputDecoration( - hintText: context.l10n.search, - isDense: true, - prefixIcon: mediaQuery.smAndDown - ? IconButton( - icon: const Icon( - Icons.arrow_back_ios_new_outlined, - ), - onPressed: () { - isSearching.value = false; - searchText.value = ''; - }, - style: IconButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: const Size.square(20), - ), - ) - : const Icon(SpotubeIcons.filter), - constraints: BoxConstraints( - maxHeight: 40, - maxWidth: mediaQuery.smAndDown - ? mediaQuery.size.width - 40 - : 300, - ), - ), - ) - else - IconButton.filledTonal( - icon: const Icon(SpotubeIcons.filter), - onPressed: () { - isSearching.value = !isSearching.value; - }, - ), - if (mediaQuery.mdAndUp || !isSearching.value) ...[ - const SizedBox(width: 10), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: - theme.scaffoldBackgroundColor.withOpacity(0.5), - foregroundColor: theme.textTheme.headlineSmall?.color, - ), - child: Row( - children: [ - const Icon(SpotubeIcons.playlistRemove), - const SizedBox(width: 5), - Text(context.l10n.clear_all), - ], - ), - onPressed: () { - playlistNotifier.stop(); - Navigator.of(context).pop(); - }, - ), - const SizedBox(width: 10), - ], - ], - ), - const SizedBox(height: 10), - if (!isSearching.value && searchText.value.isEmpty) - Flexible( - child: ReorderableListView.builder( - onReorder: (oldIndex, newIndex) { - playlistNotifier.moveTrack(oldIndex, newIndex); - }, - scrollController: controller, - itemCount: tracks.length, - shrinkWrap: true, - buildDefaultDragHandles: false, - onReorderStart: (index) { - HapticFeedback.selectionClick(); - }, - onReorderEnd: (index) { - HapticFeedback.selectionClick(); - }, - itemBuilder: (context, i) { - final track = tracks.elementAt(i); - return AutoScrollTag( - key: ValueKey(i), - controller: controller, - index: i, - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8.0), - child: TrackTile( - index: i, - track: track, - onTap: () async { - if (playlist.activeTrack?.id == track.id) { - return; - } - await playlistNotifier.jumpToTrack(track); - }, - leadingActions: [ - ReorderableDragStartListener( - index: i, - child: const Icon(SpotubeIcons.dragHandle), - ), - ], + return LayoutBuilder( + builder: (context, constrains) { + return ClipRRect( + borderRadius: borderRadius, + clipBehavior: Clip.hardEdge, + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 15, + sigmaY: 15, + ), + child: Container( + padding: const EdgeInsets.only( + top: 5.0, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceVariant.withOpacity(0.5), + borderRadius: borderRadius, + ), + child: CallbackShortcuts( + bindings: { + LogicalKeySet(LogicalKeyboardKey.escape): () { + if (!isSearching.value) { + Navigator.of(context).pop(); + } + isSearching.value = false; + searchText.value = ''; + } + }, + child: InterScrollbar( + controller: controller, + child: CustomScrollView( + controller: controller, + slivers: [ + if (!floating) + SliverToBoxAdapter( + child: Center( + child: Container( + height: 5, + width: 100, + margin: const EdgeInsets.only(bottom: 5, top: 2), + decoration: BoxDecoration( + color: headlineColor, + borderRadius: BorderRadius.circular(20), + ), ), ), - ); - }, - ), - ) - else - Flexible( - child: InterScrollbar( - controller: controller, - child: ListView.builder( - controller: controller, + ), + SliverAppBar( + floating: true, + pinned: false, + snap: false, + backgroundColor: Colors.transparent, + elevation: 0, + automaticallyImplyLeading: !isSearching.value, + title: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 10, + sigmaY: 10, + ), + child: SizedBox( + height: kToolbarHeight, + child: mediaQuery.mdAndUp || !isSearching.value + ? Align( + alignment: Alignment.centerLeft, + child: Text( + context.l10n + .tracks_in_queue(tracks.length), + style: TextStyle( + color: headlineColor, + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + ) + : null, + ), + ), + actions: [ + if (mediaQuery.mdAndUp || isSearching.value) + TextField( + onChanged: (value) { + searchText.value = value; + }, + decoration: InputDecoration( + hintText: context.l10n.search, + isDense: true, + prefixIcon: mediaQuery.smAndDown + ? IconButton( + icon: const Icon( + Icons.arrow_back_ios_new_outlined, + ), + onPressed: () { + isSearching.value = false; + searchText.value = ''; + }, + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size.square(20), + ), + ) + : const Icon(SpotubeIcons.filter), + constraints: BoxConstraints( + maxHeight: 40, + maxWidth: mediaQuery.smAndDown + ? mediaQuery.size.width - 40 + : 300, + ), + ), + ) + else + IconButton.filledTonal( + icon: const Icon(SpotubeIcons.filter), + onPressed: () { + isSearching.value = !isSearching.value; + }, + ), + if (mediaQuery.mdAndUp || !isSearching.value) ...[ + const SizedBox(width: 10), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: theme.scaffoldBackgroundColor + .withOpacity(0.5), + foregroundColor: + theme.textTheme.headlineSmall?.color, + ), + child: Row( + children: [ + const Icon(SpotubeIcons.playlistRemove), + const SizedBox(width: 5), + Text(context.l10n.clear_all), + ], + ), + onPressed: () { + playlistNotifier.stop(); + Navigator.of(context).pop(); + }, + ), + const SizedBox(width: 10), + ], + ], + ), + const SliverGap(10), + SliverReorderableList( + onReorder: (oldIndex, newIndex) { + playlistNotifier.moveTrack(oldIndex, newIndex); + }, itemCount: filteredTracks.length, + onReorderStart: (index) { + HapticFeedback.selectionClick(); + }, + onReorderEnd: (index) { + HapticFeedback.selectionClick(); + }, 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); - }, + return AutoScrollTag( + key: ValueKey(i), + controller: controller, + index: i, + child: Material( + color: Colors.transparent, + child: TrackTile( + playlist: playlist, + index: i, + track: track, + onTap: () async { + if (playlist.activeTrack?.id == track.id) { + return; + } + await playlistNotifier.jumpToTrack(track); + }, + leadingActions: [ + if (!isSearching.value && + searchText.value.isEmpty) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: ReorderableDragStartListener( + index: i, + child: const Icon( + SpotubeIcons.dragHandle, + ), + ), + ), + ], + ), ), ); }, ), - ), + const SliverGap(100), + ], ), - ], + ), + ), ), ), - ), - ), + ); + }, ); } } diff --git a/lib/components/player/player_track_details.dart b/lib/components/player/player_track_details.dart index 66cb9ef5..65e40fe6 100644 --- a/lib/components/player/player_track_details.dart +++ b/lib/components/player/player_track_details.dart @@ -4,17 +4,18 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/components/shared/links/link_text.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerTrackDetails extends HookConsumerWidget { - final String? albumArt; final Color? color; - const PlayerTrackDetails({Key? key, this.albumArt, this.color}) - : super(key: key); + final Track? track; + const PlayerTrackDetails({super.key, this.color, this.track}); @override Widget build(BuildContext context, ref) { @@ -34,7 +35,8 @@ class PlayerTrackDetails extends HookConsumerWidget { child: ClipRRect( borderRadius: BorderRadius.circular(4), child: UniversalImage( - path: albumArt ?? "", + path: (track?.album?.images) + .asUrlString(placeholder: ImagePlaceholder.albumArt), placeholder: Assets.albumPlaceholder.path, ), ), @@ -55,9 +57,7 @@ class PlayerTrackDetails extends HookConsumerWidget { ), ), Text( - TypeConversionUtils.artists_X_String( - playback.activeTrack?.artists ?? [], - ), + playback.activeTrack?.artists?.asString() ?? "", overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall!.copyWith(color: color), ) @@ -76,8 +76,8 @@ class PlayerTrackDetails extends HookConsumerWidget { overflow: TextOverflow.ellipsis, style: TextStyle(fontWeight: FontWeight.bold, color: color), ), - TypeConversionUtils.artists_X_ClickableArtists( - playback.activeTrack?.artists ?? [], + ArtistLink( + artists: playback.activeTrack?.artists ?? [], onRouteChange: (route) { ServiceUtils.push(context, route); }, diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index 58b1ca8c..99ab223f 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -10,6 +10,7 @@ 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/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; @@ -24,7 +25,6 @@ import 'package:spotube/services/sourced_track/sources/jiosaavn.dart'; import 'package:spotube/services/sourced_track/sources/piped.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; final sourceInfoToIconMap = { YoutubeSourceInfo: const Icon(SpotubeIcons.youtube, color: Color(0xFFFF0000)), @@ -45,9 +45,9 @@ final sourceInfoToIconMap = { class SiblingTracksSheet extends HookConsumerWidget { final bool floating; const SiblingTracksSheet({ - Key? key, + super.key, this.floating = true, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { @@ -67,7 +67,7 @@ class SiblingTracksSheet extends HookConsumerWidget { ).trim(); final defaultSearchTerm = - "$title - ${TypeConversionUtils.artists_X_String(playlist.activeTrack?.artists ?? [])}"; + "$title - ${playlist.activeTrack?.artists?.asString() ?? ""}"; final searchController = useTextEditingController( text: defaultSearchTerm, ); diff --git a/lib/components/player/volume_slider.dart b/lib/components/player/volume_slider.dart index 75445125..102bbef6 100644 --- a/lib/components/player/volume_slider.dart +++ b/lib/components/player/volume_slider.dart @@ -3,37 +3,39 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/provider/volume_provider.dart'; class VolumeSlider extends HookConsumerWidget { final bool fullWidth; + + final double value; + final ValueChanged onChanged; + const VolumeSlider({ - Key? key, + super.key, this.fullWidth = false, - }) : super(key: key); + required this.value, + required this.onChanged, + }); @override Widget build(BuildContext context, ref) { - final volume = ref.watch(volumeProvider); - final volumeNotifier = ref.watch(volumeProvider.notifier); - var slider = Listener( onPointerSignal: (event) async { if (event is PointerScrollEvent) { if (event.scrollDelta.dy > 0) { - final value = volume - .2; - volumeNotifier.setVolume(value < 0 ? 0 : value); + final newValue = value - .2; + onChanged(newValue < 0 ? 0 : newValue); } else { - final value = volume + .2; - volumeNotifier.setVolume(value > 1 ? 1 : value); + final newValue = value + .2; + onChanged(newValue > 1 ? 1 : newValue); } } }, child: Slider( min: 0, max: 1, - value: volume, - onChanged: volumeNotifier.setVolume, + value: value, + onChanged: onChanged, ), ); return Row( @@ -42,20 +44,20 @@ class VolumeSlider extends HookConsumerWidget { children: [ IconButton( icon: Icon( - volume == 0 + value == 0 ? SpotubeIcons.volumeMute - : volume <= 0.2 + : value <= 0.2 ? SpotubeIcons.volumeLow - : volume <= 0.6 + : value <= 0.6 ? SpotubeIcons.volumeMedium : SpotubeIcons.volumeHigh, size: 16, ), onPressed: () { - if (volume == 0) { - volumeNotifier.setVolume(1); + if (value == 0) { + onChanged(1); } else { - volumeNotifier.setVolume(0); + onChanged(0); } }, ), diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index f429a0ab..e5b87d6d 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -1,77 +1,59 @@ -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; -import 'package:spotube/extensions/infinite_query.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistCard extends HookConsumerWidget { final PlaylistSimple playlist; const PlaylistCard( this.playlist, { - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { final playlistQueue = ref.watch(ProxyPlaylistNotifier.provider); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; - final queryClient = QueryClient.of(context); - final tracks = useState?>(null); bool isPlaylistPlaying = useMemoized( () => playlistQueue.containsCollection(playlist.id!), [playlistQueue, playlist.id], ); final updating = useState(false); - final spotify = ref.watch(spotifyProvider); - final me = useQueries.user.me(ref); + final me = ref.watch(meProvider); Future> fetchAllTracks() async { if (playlist.id == 'user-liked-tracks') { - return await queryClient.fetchQuery( - "user-liked-tracks", - () => useQueries.playlist.likedTracks(spotify), - ) ?? - []; + return await ref.read(likedTracksProvider.future); } - final query = queryClient.createInfiniteQuery, dynamic, int>( - "playlist-tracks/${playlist.id}", - (page) => useQueries.playlist.tracksOf(page, spotify, playlist.id!), - initialPage: 0, - nextPage: useQueries.playlist.tracksOfQueryNextPage, - ); + await ref.read(playlistTracksProvider(playlist.id!).future); - return await query.fetchAllTracks( - getAllTracks: () async { - final res = - await spotify.playlists.getTracksByPlaylistId(playlist.id!).all(); - return res.toList(); - }, - ); + return ref.read(playlistTracksProvider(playlist.id!).notifier).fetchAll(); } return PlaybuttonCard( margin: const EdgeInsets.symmetric(horizontal: 10), title: playlist.name!, description: playlist.description, - imageUrl: TypeConversionUtils.image_X_UrlString( - playlist.images, + imageUrl: playlist.images.asUrlString( placeholder: ImagePlaceholder.collection, ), isPlaying: isPlaylistPlaying, isLoading: (isPlaylistPlaying && playlistQueue.isFetching) || updating.value, - isOwner: playlist.owner?.id == me.data?.id && me.data?.id != null, + isOwner: playlist.owner?.id == me.asData?.value.id && + me.asData?.value.id != null, onTap: () { ServiceUtils.push( context, @@ -92,9 +74,19 @@ class PlaylistCard extends HookConsumerWidget { if (fetchedTracks.isEmpty) return; - await playlistNotifier.load(fetchedTracks, autoPlay: true); - playlistNotifier.addCollection(playlist.id!); - tracks.value = fetchedTracks; + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: fetchedTracks, + collectionId: playlist.id!, + ), + ); + } else { + await playlistNotifier.load(fetchedTracks, autoPlay: true); + playlistNotifier.addCollection(playlist.id!); + } } finally { if (context.mounted) { updating.value = false; @@ -112,10 +104,9 @@ class PlaylistCard extends HookConsumerWidget { playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addCollection(playlist.id!); - tracks.value = fetchedTracks; if (context.mounted) { final snackbar = SnackBar( - content: Text("Added ${tracks.value?.length} tracks to queue"), + content: Text("Added ${fetchedTracks.length} tracks to queue"), action: SnackBarAction( label: "Undo", onPressed: () { diff --git a/lib/components/playlist/playlist_create_dialog.dart b/lib/components/playlist/playlist_create_dialog.dart index 2e11a209..bac98b64 100644 --- a/lib/components/playlist/playlist_create_dialog.dart +++ b/lib/components/playlist/playlist_create_dialog.dart @@ -5,6 +5,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:form_validator/form_validator.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:spotify/spotify.dart'; @@ -13,21 +14,19 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/services/mutations/playlist.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistCreateDialog extends HookConsumerWidget { /// Track ids to add to the playlist final List trackIds; final String? playlistId; PlaylistCreateDialog({ - Key? key, + super.key, this.trackIds = const [], this.playlistId, - }) : super(key: key); + }); final formKey = GlobalKey(); @@ -37,13 +36,16 @@ class PlaylistCreateDialog extends HookConsumerWidget { child: Scaffold( backgroundColor: Colors.transparent, body: HookBuilder(builder: (context) { - final userPlaylists = useQueries.playlist.ofMine(ref); + final userPlaylists = ref.watch(favoritePlaylistsProvider); + final playlist = ref.watch(playlistProvider(playlistId ?? "")); + final playlistNotifier = + ref.watch(playlistProvider(playlistId ?? "").notifier); + final updatingPlaylist = useMemoized( - () => userPlaylists.pages - .expand((p) => p.items ?? []) + () => userPlaylists.asData?.value.items .firstWhereOrNull((playlist) => playlist.id == playlistId), [ - userPlaylists.pages, + userPlaylists.asData?.value.items, playlistId, ], ); @@ -84,28 +86,10 @@ class PlaylistCreateDialog extends HookConsumerWidget { } }, [scaffold, l10n, theme]); - final playlistCreateMutation = useMutations.playlist.create( - ref, - trackIds: trackIds, - onData: (value) { - Navigator.pop(context); - }, - onError: onError, - ); - - final playlistUpdateMutation = useMutations.playlist.update( - ref, - playlistId: playlistId, - onData: (value) { - Navigator.pop(context); - }, - onError: onError, - ); - Future onCreate() async { if (!formKey.currentState!.validate()) return; - final PlaylistCRUDVariables payload = ( + final PlaylistInput payload = ( playlistName: playlistName.text, collaborative: collaborative.value, public: public.value, @@ -118,9 +102,14 @@ class PlaylistCreateDialog extends HookConsumerWidget { ); if (isUpdatingPlaylist) { - await playlistUpdateMutation.mutate(payload); + await playlistNotifier.modify(payload, onError); } else { - await playlistCreateMutation.mutate(payload); + await playlistNotifier.create(payload, onError); + } + + if (context.mounted && + !ref.read(playlistProvider(playlistId ?? "")).hasError) { + context.pop(); } } @@ -138,7 +127,7 @@ class PlaylistCreateDialog extends HookConsumerWidget { }, ), FilledButton( - onPressed: onCreate, + onPressed: playlist.isLoading ? null : onCreate, child: Text( isUpdatingPlaylist ? context.l10n.update @@ -174,8 +163,7 @@ class PlaylistCreateDialog extends HookConsumerWidget { children: [ UniversalImage( path: field.value?.path ?? - TypeConversionUtils.image_X_UrlString( - updatingPlaylist?.images, + (updatingPlaylist?.images).asUrlString( placeholder: ImagePlaceholder.collection, ), height: 200, @@ -275,7 +263,7 @@ class PlaylistCreateDialog extends HookConsumerWidget { } class PlaylistCreateDialogButton extends HookConsumerWidget { - const PlaylistCreateDialogButton({Key? key}) : super(key: key); + const PlaylistCreateDialogButton({super.key}); showPlaylistDialog(BuildContext context, SpotifyApi spotify) { showDialog( diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index 617e760b..19fa7c93 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -14,18 +14,20 @@ import 'package:spotube/components/player/player_controls.dart'; import 'package:spotube/components/player/volume_slider.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/models/logger.dart'; import 'package:flutter/material.dart'; import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/connect/connect.dart' hide volumeProvider; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/provider/volume_provider.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class BottomPlayer extends HookConsumerWidget { - BottomPlayer({Key? key}) : super(key: key); + BottomPlayer({super.key}); final logger = getLogger(BottomPlayer); @override @@ -34,13 +36,13 @@ class BottomPlayer extends HookConsumerWidget { final playlist = ref.watch(ProxyPlaylistNotifier.provider); final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); + final remoteControl = ref.watch(connectProvider); final mediaQuery = MediaQuery.of(context); String albumArt = useMemoized( () => playlist.activeTrack?.album?.images?.isNotEmpty == true - ? TypeConversionUtils.image_X_UrlString( - playlist.activeTrack?.album?.images, + ? (playlist.activeTrack?.album?.images).asUrlString( index: (playlist.activeTrack?.album?.images?.length ?? 1) - 1, placeholder: ImagePlaceholder.albumArt, ) @@ -74,7 +76,9 @@ class BottomPlayer extends HookConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Expanded(child: PlayerTrackDetails(albumArt: albumArt)), + Expanded( + child: PlayerTrackDetails(track: playlist.activeTrack), + ), // controls Flexible( flex: 3, @@ -122,10 +126,20 @@ class BottomPlayer extends HookConsumerWidget { Container( height: 40, constraints: const BoxConstraints(maxWidth: 250), - child: const VolumeSlider(), + padding: const EdgeInsets.only(right: 10), + child: Consumer(builder: (context, ref, _) { + final volume = ref.watch(volumeProvider); + return VolumeSlider( + fullWidth: true, + value: volume, + onChanged: (value) { + ref.read(volumeProvider.notifier).setVolume(value); + }, + ); + }), ) ], - ) + ), ], ), ), diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index a55ef947..903e812e 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -11,16 +11,16 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; -import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class Sidebar extends HookConsumerWidget { final int? selectedIndex; @@ -31,8 +31,8 @@ class Sidebar extends HookConsumerWidget { required this.selectedIndex, required this.onSelectedIndexChanged, required this.child, - Key? key, - }) : super(key: key); + super.key, + }); static Widget brandLogo() { return Container( @@ -195,7 +195,7 @@ class Sidebar extends HookConsumerWidget { } class SidebarHeader extends HookWidget { - const SidebarHeader({Key? key}) : super(key: key); + const SidebarHeader({super.key}); @override Widget build(BuildContext context) { @@ -234,18 +234,17 @@ class SidebarHeader extends HookWidget { class SidebarFooter extends HookConsumerWidget { const SidebarFooter({ - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final me = useQueries.user.me(ref); - final data = me.data; + final me = ref.watch(meProvider); + final data = me.asData?.value; - final avatarImg = TypeConversionUtils.image_X_UrlString( - data?.images, + final avatarImg = (data?.images).asUrlString( index: (data?.images?.length ?? 1) - 1, placeholder: ImagePlaceholder.artist, ); diff --git a/lib/components/root/spotube_navigation_bar.dart b/lib/components/root/spotube_navigation_bar.dart index 0853c60c..489399e5 100644 --- a/lib/components/root/spotube_navigation_bar.dart +++ b/lib/components/root/spotube_navigation_bar.dart @@ -23,8 +23,8 @@ class SpotubeNavigationBar extends HookConsumerWidget { const SpotubeNavigationBar({ required this.selectedIndex, required this.onSelectedIndexChanged, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/settings/color_scheme_picker_dialog.dart b/lib/components/settings/color_scheme_picker_dialog.dart index e0c3d618..8d098375 100644 --- a/lib/components/settings/color_scheme_picker_dialog.dart +++ b/lib/components/settings/color_scheme_picker_dialog.dart @@ -8,9 +8,9 @@ import 'package:system_theme/system_theme.dart'; class SpotubeColor extends Color { final String name; - const SpotubeColor(int color, {required this.name}) : super(color); + const SpotubeColor(super.color, {required this.name}); - const SpotubeColor.from(int value, {required this.name}) : super(value); + const SpotubeColor.from(super.value, {required this.name}); factory SpotubeColor.fromString(String string) { final slices = string.split(":"); @@ -44,7 +44,7 @@ final Set colorsMap = { }; class ColorSchemePickerDialog extends HookConsumerWidget { - const ColorSchemePickerDialog({Key? key}) : super(key: key); + const ColorSchemePickerDialog({super.key}); @override Widget build(BuildContext context, ref) { @@ -119,8 +119,8 @@ class ColorTile extends StatelessWidget { this.onPressed, this.tooltip = "", this.isCompact = false, - Key? key, - }) : super(key: key); + super.key, + }); factory ColorTile.compact({ required Color color, diff --git a/lib/components/shared/adaptive/adaptive_popup_menu_button.dart b/lib/components/shared/adaptive/adaptive_popup_menu_button.dart index 45f22825..02fced52 100644 --- a/lib/components/shared/adaptive/adaptive_popup_menu_button.dart +++ b/lib/components/shared/adaptive/adaptive_popup_menu_button.dart @@ -12,13 +12,13 @@ class Action extends StatelessWidget { final bool isExpanded; final Color? backgroundColor; const Action({ - Key? key, + super.key, required this.icon, required this.text, required this.onPressed, this.isExpanded = true, this.backgroundColor, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/adaptive/adaptive_select_tile.dart b/lib/components/shared/adaptive/adaptive_select_tile.dart index 58666e46..3f6d2700 100644 --- a/lib/components/shared/adaptive/adaptive_select_tile.dart +++ b/lib/components/shared/adaptive/adaptive_select_tile.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/extensions/constrains.dart'; class AdaptiveSelectTile extends HookWidget { @@ -38,11 +39,22 @@ class AdaptiveSelectTile extends HookWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final rawControl = DropdownButton( - items: options, - value: value, - onChanged: onChanged, - menuMaxHeight: mediaQuery.size.height * 0.6, + final rawControl = DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: DropdownButton( + items: options, + value: value, + onChanged: onChanged, + menuMaxHeight: mediaQuery.size.height * 0.6, + underline: const SizedBox.shrink(), + padding: const EdgeInsets.symmetric(horizontal: 10), + borderRadius: BorderRadius.circular(10), + icon: const Icon(SpotubeIcons.angleDown), + dropdownColor: theme.colorScheme.secondaryContainer, + ), ); final controlPlaceholder = useMemoized( () => options diff --git a/lib/components/shared/animated_gradient.dart b/lib/components/shared/animated_gradient.dart index b6485f6b..aaba2ff9 100644 --- a/lib/components/shared/animated_gradient.dart +++ b/lib/components/shared/animated_gradient.dart @@ -3,7 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; class AnimateGradient extends HookWidget { const AnimateGradient({ - Key? key, + super.key, required this.primaryColors, required this.secondaryColors, this.child, @@ -17,8 +17,7 @@ class AnimateGradient extends HookWidget { this.reverse = true, }) : assert(primaryColors.length >= 2), assert(primaryColors.length == secondaryColors.length), - _controller = controller, - super(key: key); + _controller = controller; /// [controller]: pass this to have a fine control over the [Animation] final AnimationController? _controller; diff --git a/lib/components/shared/compact_search.dart b/lib/components/shared/compact_search.dart index 70815291..d37cb673 100644 --- a/lib/components/shared/compact_search.dart +++ b/lib/components/shared/compact_search.dart @@ -11,12 +11,12 @@ class CompactSearch extends HookWidget { final Color? iconColor; const CompactSearch({ - Key? key, + super.key, this.onChanged, this.placeholder = "Search...", this.icon = SpotubeIcons.search, this.iconColor, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/dialogs/confirm_download_dialog.dart b/lib/components/shared/dialogs/confirm_download_dialog.dart index c371e803..486310a7 100644 --- a/lib/components/shared/dialogs/confirm_download_dialog.dart +++ b/lib/components/shared/dialogs/confirm_download_dialog.dart @@ -5,7 +5,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; class ConfirmDownloadDialog extends StatelessWidget { - const ConfirmDownloadDialog({Key? key}) : super(key: key); + const ConfirmDownloadDialog({super.key}); @override Widget build(BuildContext context) { @@ -82,7 +82,7 @@ class ConfirmDownloadDialog extends StatelessWidget { class BulletPoint extends StatelessWidget { final String text; - const BulletPoint(this.text, {Key? key}) : super(key: key); + const BulletPoint(this.text, {super.key}); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/dialogs/piped_down_dialog.dart b/lib/components/shared/dialogs/piped_down_dialog.dart index 6220adeb..b1717a2a 100644 --- a/lib/components/shared/dialogs/piped_down_dialog.dart +++ b/lib/components/shared/dialogs/piped_down_dialog.dart @@ -5,7 +5,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; class PipedDownDialog extends HookConsumerWidget { - const PipedDownDialog({Key? key}) : super(key: key); + const PipedDownDialog({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/dialogs/playlist_add_track_dialog.dart b/lib/components/shared/dialogs/playlist_add_track_dialog.dart index 51b77c76..5d493a68 100644 --- a/lib/components/shared/dialogs/playlist_add_track_dialog.dart +++ b/lib/components/shared/dialogs/playlist_add_track_dialog.dart @@ -1,4 +1,3 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; @@ -8,9 +7,8 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/components/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class PlaylistAddTrackDialog extends HookConsumerWidget { /// The id of the playlist this dialog was opened from @@ -19,33 +17,40 @@ class PlaylistAddTrackDialog extends HookConsumerWidget { const PlaylistAddTrackDialog({ required this.tracks, required this.openFromPlaylist, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { final ThemeData(:textTheme) = Theme.of(context); - final spotify = ref.watch(spotifyProvider); - final userPlaylists = useQueries.playlist.ofMineAll(ref); + final userPlaylists = ref.watch(favoritePlaylistsProvider); + final favoritePlaylistsNotifier = + ref.watch(favoritePlaylistsProvider.notifier); - final me = useQueries.user.me(ref); + final me = ref.watch(meProvider); final filteredPlaylists = useMemoized( () => - userPlaylists.data - ?.where( + userPlaylists.asData?.value.items + .where( (playlist) => playlist.owner?.id != null && - playlist.owner!.id == me.data?.id && + playlist.owner!.id == me.asData?.value.id && playlist.id != openFromPlaylist, ) .toList() ?? [], - [userPlaylists.data, me.data?.id, openFromPlaylist], + [userPlaylists.asData?.value, me.asData?.value.id, openFromPlaylist], ); final playlistsCheck = useState({}); - final queryClient = useQueryClient(); + + useEffect(() { + if (userPlaylists.asData?.value != null) { + favoritePlaylistsNotifier.fetchAll(); + } + return null; + }, [userPlaylists.asData?.value]); Future onAdd() async { final selectedPlaylists = playlistsCheck.value.entries @@ -54,21 +59,12 @@ class PlaylistAddTrackDialog extends HookConsumerWidget { await Future.wait( selectedPlaylists.map( - (playlistId) => spotify.playlists.addTracks( - tracks - .map( - (track) => track.uri!, - ) - .toList(), - playlistId), + (playlistId) => favoritePlaylistsNotifier.addTracks( + playlistId, + tracks.map((e) => e.id!).toList(), + ), ), ).then((_) => Navigator.pop(context, true)); - - await queryClient.refreshQueries( - selectedPlaylists - .map((playlistId) => "playlist-tracks/$playlistId") - .toList(), - ); } return AlertDialog( @@ -109,8 +105,7 @@ class PlaylistAddTrackDialog extends HookConsumerWidget { return CheckboxListTile( secondary: CircleAvatar( backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - playlist.images, + playlist.images.asUrlString( placeholder: ImagePlaceholder.collection, ), ), diff --git a/lib/components/shared/dialogs/replace_downloaded_dialog.dart b/lib/components/shared/dialogs/replace_downloaded_dialog.dart index 77721041..00461d34 100644 --- a/lib/components/shared/dialogs/replace_downloaded_dialog.dart +++ b/lib/components/shared/dialogs/replace_downloaded_dialog.dart @@ -8,8 +8,7 @@ final replaceDownloadedFileState = StateProvider((ref) => null); class ReplaceDownloadedDialog extends ConsumerWidget { final Track track; - const ReplaceDownloadedDialog({required this.track, Key? key}) - : super(key: key); + const ReplaceDownloadedDialog({required this.track, super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/dialogs/select_device_dialog.dart b/lib/components/shared/dialogs/select_device_dialog.dart new file mode 100644 index 00000000..cd8dedb7 --- /dev/null +++ b/lib/components/shared/dialogs/select_device_dialog.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/connect/clients.dart'; + +class SelectDeviceDialog extends HookConsumerWidget { + const SelectDeviceDialog({super.key}); + + @override + Widget build(BuildContext context, ref) { + final isRemoteService = useState(false); + + final connectClients = ref.watch(connectClientsProvider); + final remoteService = connectClients.asData!.value.resolvedService!; + + return AlertDialog( + title: const Text("Choose the device:"), + insetPadding: const EdgeInsets.all(16), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "There are multiple device connected.\n" + "Choose the device you want this action to take place", + ), + RadioListTile.adaptive( + title: Text(remoteService.name), + value: true, + groupValue: isRemoteService.value, + onChanged: (value) { + isRemoteService.value = value!; + }, + ), + RadioListTile.adaptive( + title: const Text("This Device"), + value: false, + groupValue: isRemoteService.value, + onChanged: (value) { + isRemoteService.value = !value!; + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(isRemoteService.value); + }, + child: Text(context.l10n.select), + ), + ], + ); + } +} + +Future showSelectDeviceDialog(BuildContext context, WidgetRef ref) async { + final connectClients = ref.read(connectClientsProvider); + + if (connectClients.asData?.value.resolvedService == null) { + return false; + } + + final isRemote = await showDialog( + context: context, + builder: (context) => const SelectDeviceDialog(), + ); + + return isRemote ?? false; +} diff --git a/lib/components/shared/dialogs/track_details_dialog.dart b/lib/components/shared/dialogs/track_details_dialog.dart index 8634776f..da2a140b 100644 --- a/lib/components/shared/dialogs/track_details_dialog.dart +++ b/lib/components/shared/dialogs/track_details_dialog.dart @@ -2,20 +2,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/components/shared/links/hyper_link.dart'; import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/extensions/duration.dart'; class TrackDetailsDialog extends HookWidget { final Track track; const TrackDetailsDialog({ - Key? key, + super.key, required this.track, - }) : super(key: key); + }); @override Widget build(BuildContext context) { @@ -24,8 +24,8 @@ class TrackDetailsDialog extends HookWidget { final detailsMap = { context.l10n.title: track.name!, - context.l10n.artist: TypeConversionUtils.artists_X_ClickableArtists( - track.artists ?? [], + context.l10n.artist: ArtistLink( + artists: track.artists ?? [], mainAxisAlignment: WrapAlignment.start, textStyle: const TextStyle(color: Colors.blue), ), diff --git a/lib/components/shared/expandable_search/expandable_search.dart b/lib/components/shared/expandable_search/expandable_search.dart index 75ac6841..157e180f 100644 --- a/lib/components/shared/expandable_search/expandable_search.dart +++ b/lib/components/shared/expandable_search/expandable_search.dart @@ -10,12 +10,12 @@ class ExpandableSearchField extends StatelessWidget { final FocusNode searchFocus; const ExpandableSearchField({ - Key? key, + super.key, required this.isFiltering, required this.onChangeFiltering, required this.searchController, required this.searchFocus, - }) : super(key: key); + }); @override Widget build(BuildContext context) { @@ -60,12 +60,12 @@ class ExpandableSearchButton extends StatelessWidget { final ValueChanged? onPressed; const ExpandableSearchButton({ - Key? key, + super.key, required this.isFiltering, required this.searchFocus, this.icon = const Icon(SpotubeIcons.filter), this.onPressed, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/fallbacks/anonymous_fallback.dart b/lib/components/shared/fallbacks/anonymous_fallback.dart index aea7bf38..ace7ec64 100644 --- a/lib/components/shared/fallbacks/anonymous_fallback.dart +++ b/lib/components/shared/fallbacks/anonymous_fallback.dart @@ -8,9 +8,9 @@ import 'package:spotube/utils/service_utils.dart'; class AnonymousFallback extends ConsumerWidget { final Widget? child; const AnonymousFallback({ - Key? key, + super.key, this.child, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/fallbacks/not_found.dart b/lib/components/shared/fallbacks/not_found.dart index f45573ad..5a74f672 100644 --- a/lib/components/shared/fallbacks/not_found.dart +++ b/lib/components/shared/fallbacks/not_found.dart @@ -3,7 +3,7 @@ import 'package:spotube/collections/assets.gen.dart'; class NotFound extends StatelessWidget { final bool vertical; - const NotFound({Key? key, this.vertical = false}) : super(key: key); + const NotFound({super.key, this.vertical = false}); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/heart_button.dart b/lib/components/shared/heart_button.dart index 81ccffdb..9475f9e3 100644 --- a/lib/components/shared/heart_button.dart +++ b/lib/components/shared/heart_button.dart @@ -1,5 +1,3 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -8,8 +6,7 @@ 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'; +import 'package:spotube/provider/spotify/spotify.dart'; class HeartButton extends HookConsumerWidget { final bool isLiked; @@ -23,8 +20,8 @@ class HeartButton extends HookConsumerWidget { this.color, this.tooltip, this.icon, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { @@ -60,90 +57,50 @@ class HeartButton extends HookConsumerWidget { typedef UseTrackToggleLike = ({ bool isLiked, - Mutation toggleTrackLike, - Query me, + Future Function(Track track) toggleTrackLike, }); UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) { - final me = useQueries.user.me(ref); - - final savedTracks = useQueries.playlist.likedTracksQuery(ref); + final savedTracks = ref.watch(likedTracksProvider); + final savedTracksNotifier = ref.watch(likedTracksProvider.notifier); final isLiked = useMemoized( - () => savedTracks.data?.any((element) => element.id == track.id) ?? false, - [savedTracks.data, track.id], + () => + savedTracks.asData?.value.any((element) => element.id == track.id) ?? + false, + [savedTracks.asData?.value, track.id], ); - final mounted = useIsMounted(); - final scrobblerNotifier = ref.read(scrobblerProvider.notifier); - final toggleTrackLike = useMutations.track.toggleFavorite( - ref, - track.id!, - onMutate: (isLiked) { - if (isLiked) { - savedTracks.setData( - savedTracks.data - ?.where((element) => element.id != track.id) - .toList() ?? - [], - ); - } else { - savedTracks.setData( - [ - ...?savedTracks.data, - track, - ], - ); - } - return isLiked; - }, - onData: (isLiked, recoveryData) async { - await savedTracks.refresh(); - if (isLiked) { + return ( + isLiked: isLiked, + toggleTrackLike: (track) async { + await savedTracksNotifier.toggleFavorite(track); + + if (!isLiked) { await scrobblerNotifier.love(track); } else { await scrobblerNotifier.unlove(track); } }, - onError: (payload, isLiked) { - if (!mounted()) return; - - if (isLiked != true) { - savedTracks.setData( - savedTracks.data - ?.where((element) => element.id != track.id) - .toList() ?? - [], - ); - } else { - savedTracks.setData( - [ - ...?savedTracks.data, - track, - ], - ); - } - }, ); - - return (isLiked: isLiked, toggleTrackLike: toggleTrackLike, me: me); } class TrackHeartButton extends HookConsumerWidget { final Track track; const TrackHeartButton({ - Key? key, + super.key, required this.track, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { - final savedTracks = useQueries.playlist.likedTracksQuery(ref); - final (:me, :isLiked, :toggleTrackLike) = useTrackToggleLike(track, ref); + final savedTracks = ref.watch(likedTracksProvider); + final me = ref.watch(meProvider); + final (:isLiked, :toggleTrackLike) = useTrackToggleLike(track, ref); - if (me.isLoading || !me.hasData) { + if (me.isLoading) { return const CircularProgressIndicator(); } @@ -152,104 +109,9 @@ class TrackHeartButton extends HookConsumerWidget { ? context.l10n.remove_from_favorites : context.l10n.save_as_favorite, isLiked: isLiked, - onPressed: savedTracks.hasData + onPressed: savedTracks.asData?.value != null ? () { - toggleTrackLike.mutate(isLiked); - } - : null, - ); - } -} - -class PlaylistHeartButton extends HookConsumerWidget { - final PlaylistSimple playlist; - final IconData? icon; - final ValueChanged? onData; - - const PlaylistHeartButton({ - required this.playlist, - Key? key, - this.icon, - this.onData, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final me = useQueries.user.me(ref); - - final isLikedQuery = useQueries.playlist.doesUserFollow( - ref, - playlist.id!, - me.data?.id ?? '', - ); - - final togglePlaylistLike = useMutations.playlist.toggleFavorite( - ref, - playlist.id!, - refreshQueries: [ - isLikedQuery.key, - ], - onData: onData, - ); - - if (me.isLoading || !me.hasData) { - return const CircularProgressIndicator(); - } - - return HeartButton( - isLiked: isLikedQuery.data ?? false, - tooltip: isLikedQuery.data ?? false - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - color: Colors.white, - icon: icon, - onPressed: isLikedQuery.hasData - ? () { - togglePlaylistLike.mutate(isLikedQuery.data!); - } - : null, - ); - } -} - -class AlbumHeartButton extends HookConsumerWidget { - final AlbumSimple album; - - const AlbumHeartButton({ - required this.album, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final client = useQueryClient(); - final me = useQueries.user.me(ref); - - final albumIsSaved = useQueries.album.isSavedForMe(ref, album.id!); - final isLiked = albumIsSaved.data ?? false; - - final toggleAlbumLike = useMutations.album.toggleFavorite( - ref, - album.id!, - refreshQueries: [albumIsSaved.key], - onData: (_, __) async { - await client.refreshInfiniteQueryAllPages("current-user-albums"); - }, - ); - - if (me.isLoading || !me.hasData) { - return const CircularProgressIndicator(); - } - - return HeartButton( - isLiked: isLiked, - tooltip: isLiked - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - color: Colors.white, - onPressed: albumIsSaved.hasData - ? () { - toggleAlbumLike.mutate(isLiked); + toggleTrackLike(track); } : null, ); diff --git a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart index dc9d30da..8f0e6048 100644 --- a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart +++ b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart @@ -24,13 +24,12 @@ class HorizontalPlaybuttonCardView extends HookWidget { required this.hasNextPage, required this.onFetchMore, required this.isLoadingNextPage, - Key? key, - }) : assert( + super.key, + }) : assert( items is List || items is List || items is List, - ), - super(key: key); + ); @override Widget build(BuildContext context) { @@ -85,11 +84,11 @@ class HorizontalPlaybuttonCardView extends HookWidget { itemBuilder: (context, index) { final item = items[index]; - return switch (item.runtimeType) { - PlaylistSimple => + return switch (item) { + PlaylistSimple() => PlaylistCard(item as PlaylistSimple), - Album => AlbumCard(item as Album), - Artist => Padding( + Album() => AlbumCard(item as Album), + Artist() => Padding( padding: const EdgeInsets.symmetric( horizontal: 12.0), child: ArtistCard(item as Artist), diff --git a/lib/components/shared/hover_builder.dart b/lib/components/shared/hover_builder.dart index ec60848e..7793e744 100644 --- a/lib/components/shared/hover_builder.dart +++ b/lib/components/shared/hover_builder.dart @@ -7,8 +7,8 @@ class HoverBuilder extends HookWidget { const HoverBuilder({ required this.builder, this.permanentState, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/image/universal_image.dart b/lib/components/shared/image/universal_image.dart index 04c62478..d8902e63 100644 --- a/lib/components/shared/image/universal_image.dart +++ b/lib/components/shared/image/universal_image.dart @@ -20,8 +20,8 @@ class UniversalImage extends HookWidget { this.placeholder, this.fit, this.scale = 1, - Key? key, - }) : super(key: key); + super.key, + }); static ImageProvider imageProvider( String path, { diff --git a/lib/components/shared/links/anchor_button.dart b/lib/components/shared/links/anchor_button.dart index b1b1cfea..d78bbf96 100644 --- a/lib/components/shared/links/anchor_button.dart +++ b/lib/components/shared/links/anchor_button.dart @@ -11,13 +11,13 @@ class AnchorButton extends HookWidget { const AnchorButton( this.text, { - Key? key, + super.key, this.onTap, this.textAlign, this.overflow, this.maxLines, this.style = const TextStyle(), - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/links/artist_link.dart b/lib/components/shared/links/artist_link.dart new file mode 100644 index 00000000..af8b186a --- /dev/null +++ b/lib/components/shared/links/artist_link.dart @@ -0,0 +1,57 @@ +import 'package:flutter/widgets.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/links/anchor_button.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class ArtistLink extends StatelessWidget { + final List artists; + final WrapCrossAlignment crossAxisAlignment; + final WrapAlignment mainAxisAlignment; + final TextStyle textStyle; + final void Function(String route)? onRouteChange; + + const ArtistLink({ + super.key, + required this.artists, + this.crossAxisAlignment = WrapCrossAlignment.center, + this.mainAxisAlignment = WrapAlignment.center, + this.textStyle = const TextStyle(), + this.onRouteChange, + }); + + @override + Widget build(BuildContext context) { + return Wrap( + crossAxisAlignment: crossAxisAlignment, + alignment: mainAxisAlignment, + children: artists + .asMap() + .entries + .map( + (artist) => Builder(builder: (context) { + if (artist.value.name == null) { + return Text("Spotify", style: textStyle); + } + return AnchorButton( + (artist.key != artists.length - 1) + ? "${artist.value.name}, " + : artist.value.name!, + onTap: () { + if (onRouteChange != null) { + onRouteChange?.call("/artist/${artist.value.id}"); + } else { + ServiceUtils.push( + context, + "/artist/${artist.value.id}", + ); + } + }, + overflow: TextOverflow.ellipsis, + style: textStyle, + ); + }), + ) + .toList(), + ); + } +} diff --git a/lib/components/shared/links/hyper_link.dart b/lib/components/shared/links/hyper_link.dart index fd31298e..f84517b4 100644 --- a/lib/components/shared/links/hyper_link.dart +++ b/lib/components/shared/links/hyper_link.dart @@ -13,12 +13,12 @@ class Hyperlink extends StatelessWidget { const Hyperlink( this.text, this.url, { - Key? key, + super.key, this.textAlign, this.overflow, this.style = const TextStyle(), this.maxLines, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/links/link_text.dart b/lib/components/shared/links/link_text.dart index d7b00b72..db7b6358 100644 --- a/lib/components/shared/links/link_text.dart +++ b/lib/components/shared/links/link_text.dart @@ -15,14 +15,14 @@ class LinkText extends StatelessWidget { const LinkText( this.text, this.route, { - Key? key, + super.key, this.textAlign, this.extra, this.overflow, this.style = const TextStyle(), this.maxLines, this.push = false, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart index 9aa2d4a8..f956fa28 100644 --- a/lib/components/shared/page_window_title_bar.dart +++ b/lib/components/shared/page_window_title_bar.dart @@ -26,8 +26,10 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget final double? titleWidth; final Widget? title; + final bool _sliver; + const PageWindowTitleBar({ - Key? key, + super.key, this.actions, this.title, this.toolbarOpacity = 1, @@ -42,7 +44,38 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget this.titleTextStyle, this.titleWidth, this.toolbarTextStyle, - }) : super(key: key); + }) : _sliver = false, + pinned = false, + floating = false, + snap = false, + stretch = false; + + final bool pinned; + final bool floating; + final bool snap; + final bool stretch; + + const PageWindowTitleBar.sliver({ + super.key, + this.actions, + this.title, + this.backgroundColor, + this.actionsIconTheme, + this.automaticallyImplyLeading = false, + this.centerTitle, + this.foregroundColor, + this.leading, + this.leadingWidth, + this.titleSpacing, + this.titleTextStyle, + this.titleWidth, + this.toolbarTextStyle, + this.pinned = false, + this.floating = false, + this.snap = false, + this.stretch = false, + }) : _sliver = true, + toolbarOpacity = 1; @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); @@ -64,6 +97,48 @@ class _PageWindowTitleBarState extends ConsumerState { Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); + if (widget._sliver) { + return SliverLayoutBuilder( + builder: (context, constraints) { + final hasFullscreen = + mediaQuery.size.width == constraints.crossAxisExtent; + final hasLeadingOrCanPop = + widget.leading != null || Navigator.canPop(context); + + return SliverPadding( + padding: EdgeInsets.only( + left: DesktopTools.platform.isMacOS && + hasFullscreen && + hasLeadingOrCanPop + ? 65 + : 0, + ), + sliver: SliverAppBar( + leading: widget.leading, + automaticallyImplyLeading: widget.automaticallyImplyLeading, + actions: [ + ...?widget.actions, + WindowTitleBarButtons(foregroundColor: widget.foregroundColor), + ], + backgroundColor: widget.backgroundColor, + foregroundColor: widget.foregroundColor, + actionsIconTheme: widget.actionsIconTheme, + centerTitle: widget.centerTitle, + titleSpacing: widget.titleSpacing, + leadingWidth: widget.leadingWidth, + toolbarTextStyle: widget.toolbarTextStyle, + titleTextStyle: widget.titleTextStyle, + title: widget.title, + pinned: widget.pinned, + floating: widget.floating, + snap: widget.snap, + stretch: widget.stretch, + ), + ); + }, + ); + } + return LayoutBuilder(builder: (context, constrains) { final hasFullscreen = mediaQuery.size.width == constrains.maxWidth; final hasLeadingOrCanPop = @@ -107,9 +182,9 @@ class _PageWindowTitleBarState extends ConsumerState { class WindowTitleBarButtons extends HookConsumerWidget { final Color? foregroundColor; const WindowTitleBarButtons({ - Key? key, + super.key, this.foregroundColor, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { @@ -277,14 +352,13 @@ class WindowButton extends StatelessWidget { final VoidCallback? onPressed; WindowButton( - {Key? key, + {super.key, WindowButtonColors? colors, this.builder, @required this.iconBuilder, this.padding, this.onPressed, - this.animate = false}) - : super(key: key) { + this.animate = false}) { this.colors = colors ?? _defaultButtonColors; } @@ -350,49 +424,30 @@ class WindowButton extends StatelessWidget { class MinimizeWindowButton extends WindowButton { MinimizeWindowButton( - {Key? key, - WindowButtonColors? colors, - VoidCallback? onPressed, - bool? animate}) + {super.key, super.colors, super.onPressed, bool? animate}) : super( - key: key, - colors: colors, animate: animate ?? false, iconBuilder: (buttonContext) => MinimizeIcon(color: buttonContext.iconColor), - onPressed: onPressed, ); } class MaximizeWindowButton extends WindowButton { MaximizeWindowButton( - {Key? key, - WindowButtonColors? colors, - VoidCallback? onPressed, - bool? animate}) + {super.key, super.colors, super.onPressed, bool? animate}) : super( - key: key, - colors: colors, animate: animate ?? false, iconBuilder: (buttonContext) => MaximizeIcon(color: buttonContext.iconColor), - onPressed: onPressed, ); } class RestoreWindowButton extends WindowButton { - RestoreWindowButton( - {Key? key, - WindowButtonColors? colors, - VoidCallback? onPressed, - bool? animate}) + RestoreWindowButton({super.key, super.colors, super.onPressed, bool? animate}) : super( - key: key, - colors: colors, animate: animate ?? false, iconBuilder: (buttonContext) => RestoreIcon(color: buttonContext.iconColor), - onPressed: onPressed, ); } @@ -404,17 +459,12 @@ final _defaultCloseButtonColors = WindowButtonColors( class CloseWindowButton extends WindowButton { CloseWindowButton( - {Key? key, - WindowButtonColors? colors, - VoidCallback? onPressed, - bool? animate}) + {super.key, WindowButtonColors? colors, super.onPressed, bool? animate}) : super( - key: key, colors: colors ?? _defaultCloseButtonColors, animate: animate ?? false, iconBuilder: (buttonContext) => CloseIcon(color: buttonContext.iconColor), - onPressed: onPressed, ); } @@ -423,7 +473,7 @@ class CloseWindowButton extends WindowButton { /// Close class CloseIcon extends StatelessWidget { final Color color; - const CloseIcon({Key? key, required this.color}) : super(key: key); + const CloseIcon({super.key, required this.color}); @override Widget build(BuildContext context) => Align( alignment: Alignment.topLeft, @@ -444,13 +494,13 @@ class CloseIcon extends StatelessWidget { /// Maximize class MaximizeIcon extends StatelessWidget { final Color color; - const MaximizeIcon({Key? key, required this.color}) : super(key: key); + const MaximizeIcon({super.key, required this.color}); @override Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color)); } class _MaximizePainter extends _IconPainter { - _MaximizePainter(Color color) : super(color); + _MaximizePainter(super.color); @override void paint(Canvas canvas, Size size) { Paint p = getPaint(color); @@ -462,15 +512,15 @@ class _MaximizePainter extends _IconPainter { class RestoreIcon extends StatelessWidget { final Color color; const RestoreIcon({ - Key? key, + super.key, required this.color, - }) : super(key: key); + }); @override Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color)); } class _RestorePainter extends _IconPainter { - _RestorePainter(Color color) : super(color); + _RestorePainter(super.color); @override void paint(Canvas canvas, Size size) { Paint p = getPaint(color); @@ -487,13 +537,13 @@ class _RestorePainter extends _IconPainter { /// Minimize class MinimizeIcon extends StatelessWidget { final Color color; - const MinimizeIcon({Key? key, required this.color}) : super(key: key); + const MinimizeIcon({super.key, required this.color}); @override Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color)); } class _MinimizePainter extends _IconPainter { - _MinimizePainter(Color color) : super(color); + _MinimizePainter(super.color); @override void paint(Canvas canvas, Size size) { Paint p = getPaint(color); @@ -512,7 +562,7 @@ abstract class _IconPainter extends CustomPainter { } class _AlignedPaint extends StatelessWidget { - const _AlignedPaint(this.painter, {Key? key}) : super(key: key); + const _AlignedPaint(this.painter); final CustomPainter painter; @override @@ -547,8 +597,7 @@ T? _ambiguate(T? value) => value; class MouseStateBuilder extends StatefulWidget { final MouseStateBuilderCB builder; final VoidCallback? onPressed; - const MouseStateBuilder({Key? key, required this.builder, this.onPressed}) - : super(key: key); + const MouseStateBuilder({super.key, required this.builder, this.onPressed}); @override _MouseStateBuilderState createState() => _MouseStateBuilderState(); } diff --git a/lib/components/shared/panels/helpers.dart b/lib/components/shared/panels/helpers.dart index 2e754bdf..7dad96d5 100644 --- a/lib/components/shared/panels/helpers.dart +++ b/lib/components/shared/panels/helpers.dart @@ -47,8 +47,7 @@ class ForceDraggableWidgetRenderBox extends RenderPointerListener { /// To make [ForceDraggableWidget] work in [Scrollable] widgets class PanelScrollPhysics extends ScrollPhysics { final PanelController controller; - const PanelScrollPhysics({required this.controller, ScrollPhysics? parent}) - : super(parent: parent); + const PanelScrollPhysics({required this.controller, super.parent}); @override PanelScrollPhysics applyTo(ScrollPhysics? ancestor) { return PanelScrollPhysics( diff --git a/lib/components/shared/panels/sliding_up_panel.dart b/lib/components/shared/panels/sliding_up_panel.dart index 137d5eb7..e99fe261 100644 --- a/lib/components/shared/panels/sliding_up_panel.dart +++ b/lib/components/shared/panels/sliding_up_panel.dart @@ -146,7 +146,7 @@ class SlidingUpPanel extends StatefulWidget { final BoxDecoration? panelDecoration; const SlidingUpPanel( - {Key? key, + {super.key, this.body, this.collapsed, this.minHeight = 100.0, @@ -176,8 +176,7 @@ class SlidingUpPanel extends StatefulWidget { this.panelBuilder}) : assert(panelBuilder != null), assert(0 <= backdropOpacity && backdropOpacity <= 1.0), - assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0), - super(key: key); + assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0); @override SlidingUpPanelState createState() => SlidingUpPanelState(); diff --git a/lib/components/shared/playbutton_card.dart b/lib/components/shared/playbutton_card.dart index a8a75d30..80a27eb0 100644 --- a/lib/components/shared/playbutton_card.dart +++ b/lib/components/shared/playbutton_card.dart @@ -43,8 +43,8 @@ class PlaybuttonCard extends HookWidget { this.onAddToQueuePressed, this.onTap, this.isOwner = false, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/shimmers/shimmer_lyrics.dart b/lib/components/shared/shimmers/shimmer_lyrics.dart index b225c008..03816202 100644 --- a/lib/components/shared/shimmers/shimmer_lyrics.dart +++ b/lib/components/shared/shimmers/shimmer_lyrics.dart @@ -5,7 +5,7 @@ import 'package:gap/gap.dart'; import 'package:skeletonizer/skeletonizer.dart'; class ShimmerLyrics extends HookWidget { - const ShimmerLyrics({Key? key}) : super(key: key); + const ShimmerLyrics({super.key}); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/sort_tracks_dropdown.dart b/lib/components/shared/sort_tracks_dropdown.dart index ab35b2e3..be72d689 100644 --- a/lib/components/shared/sort_tracks_dropdown.dart +++ b/lib/components/shared/sort_tracks_dropdown.dart @@ -11,8 +11,8 @@ class SortTracksDropdown extends StatelessWidget { const SortTracksDropdown({ this.onChanged, this.value, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/themed_button_tab_bar.dart b/lib/components/shared/themed_button_tab_bar.dart index d5798189..017f04aa 100644 --- a/lib/components/shared/themed_button_tab_bar.dart +++ b/lib/components/shared/themed_button_tab_bar.dart @@ -5,7 +5,7 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart'; class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { final List tabs; - const ThemedButtonsTabBar({Key? key, required this.tabs}) : super(key: key); + const ThemedButtonsTabBar({super.key, required this.tabs}); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart index a094259d..29349602 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart' hide Page; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -16,17 +15,18 @@ import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/services/queries/search.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; + import 'package:url_launcher/url_launcher_string.dart'; enum TrackOptionValue { @@ -53,13 +53,13 @@ class TrackOptions extends HookConsumerWidget { final ObjectRef?>? showMenuCbRef; final Widget? icon; const TrackOptions({ - Key? key, + super.key, required this.track, this.showMenuCbRef, this.userPlaylist = false, this.playlistId, this.icon, - }) : super(key: key); + }); void actionShare(BuildContext context, Track track) { final data = "https://open.spotify.com/track/${track.id}"; @@ -99,21 +99,10 @@ class TrackOptions extends HookConsumerWidget { final playlist = ref.read(ProxyPlaylistNotifier.provider); final spotify = ref.read(spotifyProvider); final query = "${track.name} Radio"; - final pages = await QueryClient.of(context) - .fetchInfiniteQueryJob, dynamic, int, SearchParams>( - job: SearchQueries.queryJob(query), - args: ( - spotify: spotify, - searchType: SearchType.playlist, - query: query, - ), - ) ?? - []; + final pages = + await spotify.search.get(query, types: [SearchType.playlist]).first(); - final radios = pages - .expand((e) => e.items?.toList() ?? []) - .toList() - .cast(); + final radios = pages.map((e) => e.items).toList().cast(); final artists = track.artists!.map((e) => e.name); @@ -176,6 +165,7 @@ class TrackOptions extends HookConsumerWidget { ref.watch(downloadManagerProvider); final downloadManager = ref.watch(downloadManagerProvider.notifier); final blacklist = ref.watch(BlackListNotifier.provider); + final me = ref.watch(meProvider); final favorites = useTrackToggleLike(track, ref); @@ -190,10 +180,8 @@ class TrackOptions extends HookConsumerWidget { ); final removingTrack = useState(null); - final removeTrack = useMutations.playlist.removeTrackOf( - ref, - playlistId ?? "", - ); + final favoritePlaylistsNotifier = + ref.watch(favoritePlaylistsProvider.notifier); final isInQueue = useMemoized(() { if (playlist.activeTrack == null) return false; @@ -220,7 +208,7 @@ class TrackOptions extends HookConsumerWidget { break; case TrackOptionValue.delete: await File((track as LocalTrack).path).delete(); - ref.refresh(localTracksProvider); + ref.invalidate(localTracksProvider); break; case TrackOptionValue.addToQueue: await playback.addTrack(track); @@ -257,14 +245,15 @@ class TrackOptions extends HookConsumerWidget { ); break; case TrackOptionValue.favorite: - favorites.toggleTrackLike.mutate(favorites.isLiked); + favorites.toggleTrackLike(track); break; case TrackOptionValue.addToPlaylist: actionAddToPlaylist(context, track); break; case TrackOptionValue.removeFromPlaylist: removingTrack.value = track.uri; - removeTrack.mutate(track.uri!); + favoritePlaylistsNotifier + .removeTracks(playlistId ?? "", [track.id!]); break; case TrackOptionValue.blacklist: if (isBlackListed) { @@ -307,8 +296,8 @@ class TrackOptions extends HookConsumerWidget { child: ClipRRect( borderRadius: BorderRadius.circular(10), child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString(track.album!.images, - placeholder: ImagePlaceholder.albumArt), + path: track.album!.images + .asUrlString(placeholder: ImagePlaceholder.albumArt), fit: BoxFit.cover, ), ), @@ -321,14 +310,12 @@ class TrackOptions extends HookConsumerWidget { ), subtitle: Align( alignment: Alignment.centerLeft, - child: TypeConversionUtils.artists_X_ClickableArtists( - track.artists!, - ), + child: ArtistLink(artists: track.artists!), ), ), ], children: switch (track.runtimeType) { - LocalTrack => [ + LocalTrack() => [ PopSheetEntry( value: TrackOptionValue.delete, leading: const Icon(SpotubeIcons.trash), @@ -361,7 +348,7 @@ class TrackOptions extends HookConsumerWidget { leading: const Icon(SpotubeIcons.queueRemove), title: Text(context.l10n.remove_from_queue), ), - if (favorites.me.hasData) + if (me.asData?.value != null) PopSheetEntry( value: TrackOptionValue.favorite, leading: favorites.isLiked @@ -391,10 +378,7 @@ class TrackOptions extends HookConsumerWidget { if (userPlaylist && auth != null) PopSheetEntry( value: TrackOptionValue.removeFromPlaylist, - leading: (removeTrack.isMutating || !removeTrack.hasData) && - removingTrack.value == track.uri - ? const CircularProgressIndicator() - : const Icon(SpotubeIcons.removeFilled), + leading: const Icon(SpotubeIcons.removeFilled), title: Text(context.l10n.remove_from_playlist), ), PopSheetEntry( diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart index d268c783..61061d24 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -9,14 +9,16 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/hover_builder.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/track_tile/track_options.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/duration.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; class TrackTile extends HookConsumerWidget { /// [index] will not be shown if null @@ -28,25 +30,26 @@ class TrackTile extends HookConsumerWidget { final VoidCallback? onLongPress; final bool userPlaylist; final String? playlistId; + final ProxyPlaylist playlist; final List? leadingActions; const TrackTile({ - Key? key, + super.key, this.index, required this.track, this.selected = false, + required this.playlist, this.onTap, this.onLongPress, this.onChanged, this.userPlaylist = false, this.playlistId, this.leadingActions, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); final theme = Theme.of(context); final blacklist = ref.watch(BlackListNotifier.provider); @@ -63,10 +66,10 @@ class TrackTile extends HookConsumerWidget { final showOptionCbRef = useRef?>(null); - final isPlaying = track.id == playlist.activeTrack?.id; - final isLoading = useState(false); + final isPlaying = playlist.activeTrack?.id == track.id; + final isSelected = isPlaying || isLoading.value; return LayoutBuilder(builder: (context, constrains) { @@ -135,8 +138,7 @@ class TrackTile extends HookConsumerWidget { child: AspectRatio( aspectRatio: 1, child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString( - track.album?.images, + path: (track.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, ), fit: BoxFit.cover, @@ -230,16 +232,12 @@ class TrackTile extends HookConsumerWidget { alignment: Alignment.centerLeft, child: track is LocalTrack ? Text( - TypeConversionUtils.artists_X_String( - track.artists ?? [], - ), + track.artists?.asString() ?? '', ) : ClipRect( child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 40), - child: TypeConversionUtils.artists_X_ClickableArtists( - track.artists ?? [], - ), + child: ArtistLink(artists: track.artists ?? []), ), ), ), diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body.dart b/lib/components/shared/tracks_view/sections/body/track_view_body.dart index 33c8fa82..80368445 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_body.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_body.dart @@ -8,18 +8,21 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart'; import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; class TrackViewBodySection extends HookConsumerWidget { - const TrackViewBodySection({Key? key}) : super(key: key); + const TrackViewBodySection({super.key}); @override Widget build(BuildContext context, ref) { @@ -89,6 +92,7 @@ class TrackViewBodySection extends HookConsumerWidget { loadingBuilder: (context) => Skeletonizer( enabled: true, child: TrackTile( + playlist: playlist, track: FakeData.track, index: 0, ), @@ -98,13 +102,18 @@ class TrackViewBodySection extends HookConsumerWidget { child: Column( children: List.generate( 10, - (index) => TrackTile(track: FakeData.track, index: index), + (index) => TrackTile( + track: FakeData.track, + index: index, + playlist: playlist, + ), ), ), ), itemBuilder: (context, index) { final track = tracks[index]; return TrackTile( + playlist: playlist, track: track, index: index, selected: trackViewState.selectedTrackIds.contains(track.id!), @@ -125,16 +134,37 @@ class TrackViewBodySection extends HookConsumerWidget { return; } - if (isActive || playlist.tracks.contains(track)) { - await playlistNotifier.jumpToTrack(track); + final isRemoteDevice = + await showSelectDeviceDialog(context, ref); + + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final remoteQueue = ref.read(queueProvider); + if (remoteQueue.collections.contains(props.collectionId) || + remoteQueue.tracks.any((s) => s.id == track.id)) { + await playlistNotifier.jumpToTrack(track); + } else { + final tracks = await props.pagination.onFetchAll(); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: tracks, + collectionId: props.collectionId, + initialIndex: index, + ), + ); + } } else { - final tracks = await props.pagination.onFetchAll(); - await playlistNotifier.load( - tracks, - initialIndex: index, - autoPlay: true, - ); - playlistNotifier.addCollection(props.collectionId); + if (isActive || playlist.tracks.contains(track)) { + await playlistNotifier.jumpToTrack(track); + } else { + final tracks = await props.pagination.onFetchAll(); + await playlistNotifier.load( + tracks, + initialIndex: index, + autoPlay: true, + ); + playlistNotifier.addCollection(props.collectionId); + } } }, ); diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart b/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart index 7e4522a0..3a1538a3 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart @@ -13,10 +13,10 @@ class TrackViewBodyHeaders extends HookConsumerWidget { final FocusNode searchFocus; const TrackViewBodyHeaders({ - Key? key, + super.key, required this.isFiltering, required this.searchFocus, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/sections/body/track_view_options.dart b/lib/components/shared/tracks_view/sections/body/track_view_options.dart index 583c9107..5560ef3f 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_options.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_options.dart @@ -13,7 +13,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; class TrackViewBodyOptions extends HookConsumerWidget { - const TrackViewBodyOptions({Key? key}) : super(key: key); + const TrackViewBodyOptions({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart b/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart index ca3c6706..2f87ccc8 100644 --- a/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart +++ b/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart @@ -1,18 +1,18 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; bool useIsUserPlaylist(WidgetRef ref, String playlistId) { - final userPlaylistsQuery = useQueries.playlist.ofMineAll(ref); - final me = useQueries.user.me(ref); + final userPlaylistsQuery = ref.watch(favoritePlaylistsProvider); + final me = ref.watch(meProvider); return useMemoized( () => - userPlaylistsQuery.data?.any((e) => + userPlaylistsQuery.asData?.value.items.any((e) => e.id == playlistId && - me.data != null && - e.owner?.id == me.data?.id) ?? + me.asData?.value != null && + e.owner?.id == me.asData?.value.id) ?? false, - [userPlaylistsQuery.data, playlistId, me.data], + [userPlaylistsQuery.asData?.value, playlistId, me.asData?.value], ); } diff --git a/lib/components/shared/tracks_view/sections/header/flexible_header.dart b/lib/components/shared/tracks_view/sections/header/flexible_header.dart index 19241dc6..4a704302 100644 --- a/lib/components/shared/tracks_view/sections/header/flexible_header.dart +++ b/lib/components/shared/tracks_view/sections/header/flexible_header.dart @@ -14,7 +14,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; class TrackViewFlexHeader extends HookConsumerWidget { - const TrackViewFlexHeader({Key? key}) : super(key: key); + const TrackViewFlexHeader({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/sections/header/header_actions.dart b/lib/components/shared/tracks_view/sections/header/header_actions.dart index 75aa3f61..a16dd750 100644 --- a/lib/components/shared/tracks_view/sections/header/header_actions.dart +++ b/lib/components/shared/tracks_view/sections/header/header_actions.dart @@ -12,7 +12,7 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; class TrackViewHeaderActions extends HookConsumerWidget { - const TrackViewHeaderActions({Key? key}) : super(key: key); + const TrackViewHeaderActions({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/sections/header/header_buttons.dart b/lib/components/shared/tracks_view/sections/header/header_buttons.dart index bae47f12..f505f765 100644 --- a/lib/components/shared/tracks_view/sections/header/header_buttons.dart +++ b/lib/components/shared/tracks_view/sections/header/header_buttons.dart @@ -6,8 +6,11 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -15,10 +18,10 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final PaletteColor color; final bool compact; const TrackViewHeaderButtons({ - Key? key, + super.key, required this.color, this.compact = false, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { @@ -43,13 +46,25 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final allTracks = await props.pagination.onFetchAll(); - await playlistNotifier.load( - allTracks, - autoPlay: true, - initialIndex: Random().nextInt(allTracks.length), - ); - await audioPlayer.setShuffle(true); - playlistNotifier.addCollection(props.collectionId); + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: allTracks, + collectionId: props.collectionId, + initialIndex: Random().nextInt(allTracks.length)), + ); + await remotePlayback.setShuffle(true); + } else { + await playlistNotifier.load( + allTracks, + autoPlay: true, + initialIndex: Random().nextInt(allTracks.length), + ); + await audioPlayer.setShuffle(true); + playlistNotifier.addCollection(props.collectionId); + } } finally { isLoading.value = false; } @@ -61,8 +76,19 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final allTracks = await props.pagination.onFetchAll(); - await playlistNotifier.load(allTracks, autoPlay: true); - playlistNotifier.addCollection(props.collectionId); + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: allTracks, + collectionId: props.collectionId, + ), + ); + } else { + await playlistNotifier.load(allTracks, autoPlay: true); + playlistNotifier.addCollection(props.collectionId); + } } finally { isLoading.value = false; } diff --git a/lib/components/shared/tracks_view/track_view.dart b/lib/components/shared/tracks_view/track_view.dart index 4103573c..eb8f6871 100644 --- a/lib/components/shared/tracks_view/track_view.dart +++ b/lib/components/shared/tracks_view/track_view.dart @@ -10,7 +10,7 @@ import 'package:spotube/components/shared/tracks_view/sections/body/track_view_b import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; class TrackView extends HookConsumerWidget { - const TrackView({Key? key}) : super(key: key); + const TrackView({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/track_view_props.dart b/lib/components/shared/tracks_view/track_view_props.dart index 21bbaec7..a1a07f84 100644 --- a/lib/components/shared/tracks_view/track_view_props.dart +++ b/lib/components/shared/tracks_view/track_view_props.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart' hide Page; import 'package:spotify/spotify.dart'; @@ -19,19 +18,6 @@ class PaginationProps { required this.onRefresh, }); - factory PaginationProps.fromQuery( - InfiniteQuery, dynamic, int> query, { - required Future> Function() onFetchAll, - }) { - return PaginationProps( - hasNextPage: query.hasNextPage, - isLoading: query.isLoadingNextPage, - onFetchMore: query.fetchNext, - onFetchAll: onFetchAll, - onRefresh: query.refreshAll, - ); - } - @override operator ==(Object other) { return other is PaginationProps && diff --git a/lib/components/shared/waypoint.dart b/lib/components/shared/waypoint.dart index abd9f98d..08e9088a 100644 --- a/lib/components/shared/waypoint.dart +++ b/lib/components/shared/waypoint.dart @@ -11,12 +11,12 @@ class Waypoint extends HookWidget { final bool isGrid; const Waypoint({ - Key? key, + super.key, required this.controller, this.isGrid = false, this.onTouchEdge, this.child, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/extensions/album_simple.dart b/lib/extensions/album_simple.dart index 00db4dca..7c8ae09e 100644 --- a/lib/extensions/album_simple.dart +++ b/lib/extensions/album_simple.dart @@ -1,6 +1,6 @@ import 'package:spotify/spotify.dart'; -extension AlbumJson on AlbumSimple { +extension AlbumExtensions on AlbumSimple { Map toJson() { return { "albumType": albumType?.name, @@ -15,4 +15,22 @@ extension AlbumJson on AlbumSimple { .toList(), }; } + + Album toAlbum() { + Album album = Album(); + album.albumType = albumType; + album.artists = artists; + album.availableMarkets = availableMarkets; + album.externalUrls = externalUrls; + album.href = href; + album.id = id; + album.images = images; + album.name = name; + album.releaseDate = releaseDate; + album.releaseDatePrecision = releaseDatePrecision; + album.tracks = tracks; + album.type = type; + album.uri = uri; + return album; + } } diff --git a/lib/extensions/artist_simple.dart b/lib/extensions/artist_simple.dart index caf2e510..6a80300e 100644 --- a/lib/extensions/artist_simple.dart +++ b/lib/extensions/artist_simple.dart @@ -11,3 +11,9 @@ extension ArtistJson on ArtistSimple { }; } } + +extension ArtistExtension on List { + String asString() { + return map((e) => e.name?.replaceAll(",", " ")).join(", "); + } +} diff --git a/lib/extensions/image.dart b/lib/extensions/image.dart new file mode 100644 index 00000000..ee78653a --- /dev/null +++ b/lib/extensions/image.dart @@ -0,0 +1,34 @@ +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/utils/primitive_utils.dart'; +import 'package:collection/collection.dart'; + +enum ImagePlaceholder { + albumArt, + artist, + collection, + online, +} + +extension SpotifyImageExtensions on List? { + String asUrlString({ + int index = 1, + required ImagePlaceholder placeholder, + }) { + final String placeholderUrl = { + ImagePlaceholder.albumArt: Assets.albumPlaceholder.path, + ImagePlaceholder.artist: Assets.userPlaceholder.path, + ImagePlaceholder.collection: Assets.placeholder.path, + ImagePlaceholder.online: + "https://avatars.dicebear.com/api/bottts/${PrimitiveUtils.uuid.v4()}.png", + }[placeholder]!; + + final sortedImage = this?.sorted((a, b) => a.width!.compareTo(b.width!)); + + return sortedImage != null && sortedImage.isNotEmpty + ? sortedImage[ + index > sortedImage.length - 1 ? sortedImage.length - 1 : index] + .url! + : placeholderUrl; + } +} diff --git a/lib/extensions/infinite_query.dart b/lib/extensions/infinite_query.dart deleted file mode 100644 index 2181ab3c..00000000 --- a/lib/extensions/infinite_query.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:spotify/spotify.dart'; - -extension FetchAllTracks on InfiniteQuery, dynamic, int> { - Future> fetchAllTracks({ - required Future> Function() getAllTracks, - }) async { - if (pages.isNotEmpty && !hasNextPage) { - return pages.expand((page) => page).toList(); - } - final tracks = await getAllTracks(); - - final numOfPages = (tracks.length / 20).round(); - - final Map> pagedTracks = {}; - - for (var i = 0; i < numOfPages; i++) { - if (i == numOfPages - 1) { - final pageTracks = tracks.sublist(i * 20); - pagedTracks[i] = pageTracks; - break; - } - - final pageTracks = tracks.sublist(i * 20, (i + 1) * 20); - pagedTracks[i] = pageTracks; - } - - for (final group in pagedTracks.entries) { - setPageData(group.key, group.value); - } - - return tracks.toList(); - } -} diff --git a/lib/extensions/string.dart b/lib/extensions/string.dart index b7ab7514..0aa41dc6 100644 --- a/lib/extensions/string.dart +++ b/lib/extensions/string.dart @@ -9,3 +9,9 @@ extension UnescapeHtml on String { extension NullableUnescapeHtml on String? { String? unescapeHtml() => this == null ? null : htmlEscape.convert(this!); } + +extension StringExtension on String { + String capitalize() { + return "${this[0].toUpperCase()}${substring(1)}"; + } +} diff --git a/lib/extensions/track.dart b/lib/extensions/track.dart index 51498b33..d8258a6d 100644 --- a/lib/extensions/track.dart +++ b/lib/extensions/track.dart @@ -1,10 +1,46 @@ +import 'dart:io'; + +import 'package:metadata_god/metadata_god.dart'; +import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/artist_simple.dart'; -extension TrackJson on Track { +extension TrackExtensions on Track { + Track fromFile( + File file, { + Metadata? metadata, + String? art, + }) { + album = Album() + ..name = metadata?.album ?? "Unknown" + ..images = [if (art != null) Image()..url = art] + ..genres = [if (metadata?.genre != null) metadata!.genre!] + ..artists = [ + Artist() + ..name = metadata?.albumArtist ?? "Unknown" + ..id = metadata?.albumArtist ?? "Unknown" + ..type = "artist", + ] + ..id = metadata?.album + ..releaseDate = metadata?.year?.toString(); + artists = [ + Artist() + ..name = metadata?.artist ?? "Unknown" + ..id = metadata?.artist ?? "Unknown" + ]; + + id = metadata?.title ?? basenameWithoutExtension(file.path); + name = metadata?.title ?? basenameWithoutExtension(file.path); + type = "track"; + uri = file.path; + durationMs = (metadata?.durationMs?.toInt() ?? 0); + + return this; + } + Map toJson() { - return TrackJson.trackToJson(this); + return TrackExtensions.trackToJson(this); } static Map trackToJson(Track track) { @@ -30,3 +66,27 @@ extension TrackJson on Track { }; } } + +extension TrackSimpleExtensions on TrackSimple { + Track asTrack(AlbumSimple album) { + Track track = Track(); + track.name = name; + track.album = album; + track.artists = artists; + track.availableMarkets = availableMarkets; + track.discNumber = discNumber; + track.durationMs = durationMs; + track.explicit = explicit; + track.externalUrls = externalUrls; + track.href = href; + track.id = id; + track.isPlayable = isPlayable; + track.linkedFrom = linkedFrom; + track.name = name; + track.previewUrl = previewUrl; + track.trackNumber = trackNumber; + track.type = type; + track.uri = uri; + return track; + } +} diff --git a/lib/hooks/configurators/use_deep_linking.dart b/lib/hooks/configurators/use_deep_linking.dart index f11a1cff..2650b05c 100644 --- a/lib/hooks/configurators/use_deep_linking.dart +++ b/lib/hooks/configurators/use_deep_linking.dart @@ -1,10 +1,8 @@ import 'dart:async'; import 'package:app_links/app_links.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/routes.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:flutter_sharing_intent/flutter_sharing_intent.dart'; @@ -17,8 +15,6 @@ final linkStream = appLinks.allStringLinkStream.asBroadcastStream(); void useDeepLinking(WidgetRef ref) { // single instance no worries final spotify = ref.watch(spotifyProvider); - final queryClient = useQueryClient(); - final router = ref.watch(routerProvider); useEffect(() { @@ -32,10 +28,7 @@ void useDeepLinking(WidgetRef ref) { case "album": router.push( "/album/${url.pathSegments.last}", - extra: await queryClient.fetchQuery( - "album/${url.pathSegments.last}", - () => spotify.albums.get(url.pathSegments.last), - ), + extra: await spotify.albums.get(url.pathSegments.last), ); break; case "artist": @@ -44,10 +37,7 @@ void useDeepLinking(WidgetRef ref) { case "playlist": router.push( "/playlist/${url.pathSegments.last}", - extra: await queryClient.fetchQuery( - "playlist/${url.pathSegments.last}", - () => spotify.playlists.get(url.pathSegments.last), - ), + extra: await spotify.playlists.get(url.pathSegments.last), ); break; case "track": @@ -78,10 +68,7 @@ void useDeepLinking(WidgetRef ref) { case "spotify:album": await router.push( "/album/$endSegment", - extra: await queryClient.fetchQuery( - "album/$endSegment", - () => spotify.albums.get(endSegment), - ), + extra: await spotify.albums.get(endSegment), ); break; case "spotify:artist": @@ -93,10 +80,7 @@ void useDeepLinking(WidgetRef ref) { case "spotify:playlist": await router.push( "/playlist/$endSegment", - extra: await queryClient.fetchQuery( - "playlist/$endSegment", - () => spotify.playlists.get(endSegment), - ), + extra: await spotify.playlists.get(endSegment), ); break; default: @@ -108,5 +92,5 @@ void useDeepLinking(WidgetRef ref) { mediaStream?.cancel(); subscription.cancel(); }; - }, [spotify, queryClient]); + }, [spotify]); } diff --git a/lib/hooks/configurators/use_disable_battery_optimizations.dart b/lib/hooks/configurators/use_disable_battery_optimizations.dart index c1155d19..a9afef45 100644 --- a/lib/hooks/configurators/use_disable_battery_optimizations.dart +++ b/lib/hooks/configurators/use_disable_battery_optimizations.dart @@ -1,47 +1,21 @@ import 'package:disable_battery_optimization/disable_battery_optimization.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotube/hooks/utils/use_async_effect.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; -bool _asked = false; void useDisableBatteryOptimizations() { useAsyncEffect(() async { - if (!DesktopTools.platform.isAndroid || _asked) return; - final localStorage = await SharedPreferences.getInstance(); + if (!DesktopTools.platform.isAndroid || + KVStoreService.askedForBatteryOptimization) return; - final rawIsBatteryOptimizationDisabled = - localStorage.getBool("isBatteryOptimizationDisabled"); - final isBatteryOptimizationDisabled = - await DisableBatteryOptimization.isBatteryOptimizationDisabled; - if (rawIsBatteryOptimizationDisabled != false && - isBatteryOptimizationDisabled == false) { - final hasDisabled = await DisableBatteryOptimization - .showDisableBatteryOptimizationSettings(); + await DisableBatteryOptimization.showDisableBatteryOptimizationSettings(); - localStorage.setBool( - "isBatteryOptimizationDisabled", - hasDisabled == true, - ); - } + await DisableBatteryOptimization + .showDisableManufacturerBatteryOptimizationSettings( + "Your device has additional battery optimization", + "Follow the steps and disable the optimizations to allow smooth functioning of this app", + ); - final rawIsManBatteryOptimizationDisabled = - localStorage.getBool("isManufacturerBatteryOptimizationDisabled"); - final isManBatteryOptimizationDisabled = await DisableBatteryOptimization - .isManufacturerBatteryOptimizationDisabled; - - if (rawIsManBatteryOptimizationDisabled != false && - isManBatteryOptimizationDisabled == false) { - final hasDisabled = await DisableBatteryOptimization - .showDisableManufacturerBatteryOptimizationSettings( - "Your device has additional battery optimization", - "Follow the steps and disable the optimizations to allow smooth functioning of this app", - ); - - localStorage.setBool( - "isManufacturerBatteryOptimizationDisabled", - hasDisabled == true, - ); - } - _asked = true; + await KVStoreService.setAskedForBatteryOptimization(true); }, null, []); } diff --git a/lib/hooks/configurators/use_endless_playback.dart b/lib/hooks/configurators/use_endless_playback.dart index f5d11829..3cd55e40 100644 --- a/lib/hooks/configurators/use_endless_playback.dart +++ b/lib/hooks/configurators/use_endless_playback.dart @@ -1,5 +1,4 @@ import 'package:catcher_2/catcher_2.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; @@ -8,7 +7,6 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/search.dart'; void useEndlessPlayback(WidgetRef ref) { final auth = ref.watch(AuthenticationNotifier.provider); @@ -18,7 +16,6 @@ void useEndlessPlayback(WidgetRef ref) { final endlessPlayback = ref.watch(userPreferencesProvider.select((s) => s.endlessPlayback)); - final queryClient = useQueryClient(); useEffect( () { @@ -32,16 +29,8 @@ void useEndlessPlayback(WidgetRef ref) { final track = playlist.tracks.last; final query = "${track.name} Radio"; - final pages = await queryClient.fetchInfiniteQueryJob, - dynamic, int, SearchParams>( - job: SearchQueries.queryJob(query), - args: ( - spotify: spotify, - searchType: SearchType.playlist, - query: query - ), - ) ?? - []; + final pages = await spotify.search + .get(query, types: [SearchType.playlist]).first(); final radios = pages .expand((e) => e.items?.toList() ?? []) @@ -94,7 +83,6 @@ void useEndlessPlayback(WidgetRef ref) { [ spotify, playback, - queryClient, playlist.tracks, endlessPlayback, auth, diff --git a/lib/hooks/configurators/use_get_storage_perms.dart b/lib/hooks/configurators/use_get_storage_perms.dart index 3fcb369b..86b495c4 100644 --- a/lib/hooks/configurators/use_get_storage_perms.dart +++ b/lib/hooks/configurators/use_get_storage_perms.dart @@ -25,11 +25,11 @@ void useGetStoragePermissions(WidgetRef ref) { if (hasNoStoragePerm) { await Permission.storage.request(); - if (isMounted()) ref.refresh(localTracksProvider); + if (isMounted()) ref.invalidate(localTracksProvider); } if (hasNoAudioPerm) { await Permission.audio.request(); - if (isMounted()) ref.refresh(localTracksProvider); + if (isMounted()) ref.invalidate(localTracksProvider); } }, null, diff --git a/lib/hooks/controllers/use_auto_scroll_controller.dart b/lib/hooks/controllers/use_auto_scroll_controller.dart index 8edfb041..0c7119e4 100644 --- a/lib/hooks/controllers/use_auto_scroll_controller.dart +++ b/lib/hooks/controllers/use_auto_scroll_controller.dart @@ -39,8 +39,8 @@ class _AutoScrollControllerHook extends Hook { this.copyTagsFrom, this.suggestedRowHeight, this.debugLabel, - List? keys, - }) : super(keys: keys); + super.keys, + }); final double initialScrollOffset; final bool keepScrollOffset; diff --git a/lib/hooks/controllers/use_package_info.dart b/lib/hooks/controllers/use_package_info.dart index 9b142ced..b3c05665 100644 --- a/lib/hooks/controllers/use_package_info.dart +++ b/lib/hooks/controllers/use_package_info.dart @@ -44,8 +44,8 @@ class _PackageInfoHook extends Hook { required this.version, required this.buildNumber, this.buildSignature = '', - List? keys, - }) : super(keys: keys); + super.keys, + }); @override HookState> createState() => diff --git a/lib/hooks/controllers/use_sidebarx_controller.dart b/lib/hooks/controllers/use_sidebarx_controller.dart index 5af921b7..a14c3305 100644 --- a/lib/hooks/controllers/use_sidebarx_controller.dart +++ b/lib/hooks/controllers/use_sidebarx_controller.dart @@ -24,8 +24,8 @@ class _SidebarXControllerHook extends Hook { const _SidebarXControllerHook({ required this.selectedIndex, this.extended, - List? keys, - }) : super(keys: keys); + super.keys, + }); final int selectedIndex; final bool? extended; diff --git a/lib/hooks/spotify/use_spotify_infinite_query.dart b/lib/hooks/spotify/use_spotify_infinite_query.dart deleted file mode 100644 index 2063b083..00000000 --- a/lib/hooks/spotify/use_spotify_infinite_query.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'dart:async'; - -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/spotify_provider.dart'; - -InfiniteQuery - useSpotifyInfiniteQuery( - String queryKey, - FutureOr Function(PageType page, SpotifyApi spotify) queryFn, { - required WidgetRef ref, - required InfiniteQueryNextPage nextPage, - required PageType initialPage, - RetryConfig? retryConfig, - RefreshConfig? refreshConfig, - JsonConfig? jsonConfig, - ValueChanged>? onData, - ValueChanged>? onError, - bool enabled = true, - List? keys, -}) { - final spotify = ref.watch(spotifyProvider); - final query = useInfiniteQuery( - queryKey, - (page) => queryFn(page, spotify), - nextPage: nextPage, - initialPage: initialPage, - retryConfig: retryConfig, - refreshConfig: refreshConfig, - jsonConfig: jsonConfig, - onData: onData, - onError: onError, - enabled: enabled, - keys: keys, - ); - - useEffect(() { - return ref.listenManual( - spotifyProvider, - (previous, next) { - if (previous != next) { - query.refreshAll(); - } - }, - ).close; - }, [query]); - - return query; -} diff --git a/lib/hooks/spotify/use_spotify_mutation.dart b/lib/hooks/spotify/use_spotify_mutation.dart deleted file mode 100644 index 637f778f..00000000 --- a/lib/hooks/spotify/use_spotify_mutation.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/spotify_provider.dart'; - -Mutation - useSpotifyMutation( - String mutationKey, - Future Function(VariablesType variables, SpotifyApi spotify) - mutationFn, { - required WidgetRef ref, - RetryConfig? retryConfig, - MutationOnDataFn? onData, - MutationOnErrorFn? onError, - MutationOnMutationFn? onMutate, - List? refreshQueries, - List? refreshInfiniteQueries, - List? keys, -}) { - final spotify = ref.watch(spotifyProvider); - final mutation = - useMutation( - mutationKey, - (variables) => mutationFn(variables, spotify), - retryConfig: retryConfig, - onData: onData, - onError: onError, - onMutate: onMutate, - refreshQueries: refreshQueries, - refreshInfiniteQueries: refreshInfiniteQueries, - keys: keys, - ); - - return mutation; -} diff --git a/lib/hooks/spotify/use_spotify_query.dart b/lib/hooks/spotify/use_spotify_query.dart deleted file mode 100644 index 0c79de91..00000000 --- a/lib/hooks/spotify/use_spotify_query.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'dart:async'; - -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/spotify_provider.dart'; - -typedef SpotifyQueryFn = FutureOr Function( - SpotifyApi spotify); - -Query useSpotifyQuery( - final String queryKey, - final SpotifyQueryFn queryFn, { - required WidgetRef ref, - final DataType? initial, - final RetryConfig? retryConfig, - final RefreshConfig? refreshConfig, - final JsonConfig? jsonConfig, - final ValueChanged? onData, - final ValueChanged? onError, - final bool enabled = true, -}) { - final spotify = ref.watch(spotifyProvider); - - final query = useQuery( - queryKey, - () => queryFn(spotify), - initial: initial, - retryConfig: retryConfig, - refreshConfig: refreshConfig, - jsonConfig: jsonConfig, - onData: onData, - onError: onError, - enabled: enabled, - ); - - useEffect(() { - return ref.listenManual( - spotifyProvider, - (previous, next) { - if (previous != next) { - query.refresh(); - } - }, - ).close; - }, [query]); - - return query; -} diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index eebede99..41fab083 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -286,5 +286,32 @@ "step_3_steps": "انسخ قيمة الكوكي \"sp_dc\"", "step_4_steps": "الصق قيمة \"sp_dc\" المنسوخة", "friends": "أصدقاء", - "no_lyrics_available": "عذرًا، تعذر العثور على كلمات الأغنية لهذه العنصر" + "no_lyrics_available": "عذرًا، تعذر العثور على كلمات الأغنية لهذه العنصر", + "sort_duration": "ترتيب حسب المدة", + "start_a_radio": "بدء راديو", + "how_to_start_radio": "كيف تريد بدء الراديو؟", + "replace_queue_question": "هل تريد استبدال قائمة التشغيل الحالية أم إضافة إليها؟", + "endless_playback": "تشغيل بلا نهاية", + "delete_playlist": "حذف قائمة التشغيل", + "delete_playlist_confirmation": "هل أنت متأكد أنك تريد حذف هذه قائمة التشغيل؟", + "local_tracks": "المسارات المحلية", + "song_link": "رابط الأغنية", + "skip_this_nonsense": "تخطي هذه الهراء", + "freedom_of_music": "“حرية الموسيقى”", + "freedom_of_music_palm": "“حرية الموسيقى في متناول يدك”", + "get_started": "لنبدأ", + "youtube_source_description": "موصى به ويعمل بشكل أفضل.", + "piped_source_description": "تشعر بالحرية؟ نفس يوتيوب ولكن أكثر حرية.", + "jiosaavn_source_description": "الأفضل لمنطقة جنوب آسيا.", + "highest_quality": "أعلى جودة: {quality}", + "select_audio_source": "اختر مصدر الصوت", + "endless_playback_description": "إلحاق الأغاني الجديدة تلقائيًا\nإلى نهاية قائمة التشغيل", + "choose_your_region": "اختر منطقتك", + "choose_your_region_description": "سيساعدك هذا في عرض المحتوى المناسب\nلموقعك.", + "choose_your_language": "اختر لغتك", + "help_project_grow": "ساعد في نمو هذا المشروع", + "help_project_grow_description": "Spotube هو مشروع مفتوح المصدر. يمكنك مساعدة هذا المشروع في النمو عن طريق المساهمة في المشروع، أو الإبلاغ عن الأخطاء، أو اقتراح ميزات جديدة.", + "contribute_on_github": "المساهمة على GitHub", + "donate_on_open_collective": "التبرع على Open Collective", + "browse_anonymously": "تصفح بشكل مجهول" } \ No newline at end of file diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 2711f8d2..353ca617 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -286,5 +286,32 @@ "step_3_steps": "কুকি \"sp_dc\" এর মানটি কপি করুন", "step_4_steps": "কপি করা \"sp_dc\" মানটি পেস্ট করুন", "friends": "বন্ধু", - "no_lyrics_available": "দুঃখিত, এই ট্র্যাকের জন্য কথা খুঁজে পাওয়া গেলনা" + "no_lyrics_available": "দুঃখিত, এই ট্র্যাকের জন্য কথা খুঁজে পাওয়া গেলনা", + "sort_duration": "দৈর্ঘ্য অনুযায়ী বাছাই করুন", + "start_a_radio": "রেডিও শুরু করুন", + "how_to_start_radio": "রেডিও কিভাবে শুরু করতে চান?", + "replace_queue_question": "আপনি বর্তমান কিউটি প্রতিস্থাপন করতে চান কিনা বা এর সাথে যুক্ত করতে চান?", + "endless_playback": "অবিরাম প্রচার", + "delete_playlist": "প্লেলিস্ট মুছুন", + "delete_playlist_confirmation": "আপনি কি নিশ্চিত যে আপনি এই প্লেলিস্টটি মুছতে চান?", + "local_tracks": "স্থানীয় ট্র্যাক", + "song_link": "গানের লিংক", + "skip_this_nonsense": "এই বাকবাস পালান", + "freedom_of_music": "“সংগীতের স্বাধীনতা”", + "freedom_of_music_palm": "“তোমার হাতের কাছে সংগীতের স্বাধীনতা”", + "get_started": "শুরু করা যাক", + "youtube_source_description": "প্রস্তাবিত এবং সেরা কাজ করে।", + "piped_source_description": "মন খারাপ? ইউটিউবের মতো আবার ফ্রি।", + "jiosaavn_source_description": "দক্ষিণ এশিয়ান অঞ্চলের জন্য সেরা।", + "highest_quality": "সর্বোচ্চ গুণগতি: {quality}", + "select_audio_source": "অডিও উৎস নির্বাচন করুন", + "endless_playback_description": "নতুন গান নিজে নিজে প্লেলিস্টের শেষে\nসংযুক্ত করুন", + "choose_your_region": "আপনার অঞ্চল নির্বাচন করুন", + "choose_your_region_description": "এটি স্পটুবে আপনাকে আপনার অবস্থানের জন্য ঠিক কন্টেন্ট দেখানোর সাহায্য করবে।", + "choose_your_language": "আপনার ভাষা নির্বাচন করুন", + "help_project_grow": "এই প্রকল্পের বৃদ্ধি করুন", + "help_project_grow_description": "স্পটুব একটি ওপেন সোর্স প্রকল্প। আপনি প্রকল্পে অবদান রাখেন, বাগ রিপোর্ট করেন, বা নতুন বৈশিষ্ট্যগুলি সুপারিশ করেন।", + "contribute_on_github": "গিটহাবে অবদান রাখুন", + "donate_on_open_collective": "ওপেন কলেক্টিভে অনুদান করুন", + "browse_anonymously": "অজানে ব্রাউজ করুন" } \ No newline at end of file diff --git a/lib/l10n/app_ca.arb b/lib/l10n/app_ca.arb index f46cfae4..9848954a 100644 --- a/lib/l10n/app_ca.arb +++ b/lib/l10n/app_ca.arb @@ -286,5 +286,32 @@ "step_3_steps": "Copia el valor de la cookie \"sp_dc\"", "step_4_steps": "Pega el valor copiado de \"sp_dc\"", "friends": "Amics", - "no_lyrics_available": "Ho sentim, no es poden trobar les lletres d'aquesta pista" + "no_lyrics_available": "Ho sentim, no es poden trobar les lletres d'aquesta pista", + "sort_duration": "Ordenar per Durada", + "start_a_radio": "Inicia una ràdio", + "how_to_start_radio": "Com vols començar la ràdio?", + "replace_queue_question": "Voleu substituir la cua actual o afegir-hi?", + "endless_playback": "Reproducció infinita", + "delete_playlist": "Suprimeix la llista de reproducció", + "delete_playlist_confirmation": "Esteu segur que voleu suprimir aquesta llista de reproducció?", + "local_tracks": "Pistes locals", + "song_link": "Enllaç de la cançó", + "skip_this_nonsense": "Omet aquesta tonteria", + "freedom_of_music": "“Llibertat de la música”", + "freedom_of_music_palm": "“Llibertat de la música a la palma de la mà”", + "get_started": "Comencem", + "youtube_source_description": "Recomanat i funciona millor.", + "piped_source_description": "Et sents lliure? El mateix que YouTube però més lliure.", + "jiosaavn_source_description": "El millor per a la regió del sud d'Àsia.", + "highest_quality": "Qualitat més alta: {quality}", + "select_audio_source": "Seleccioneu la font d'àudio", + "endless_playback_description": "Afegiu automàticament noves cançons\nal final de la cua", + "choose_your_region": "Trieu la vostra regió", + "choose_your_region_description": "Això ajudarà a Spotube a mostrar-vos el contingut adequat\nper a la vostra ubicació.", + "choose_your_language": "Trieu el vostre idioma", + "help_project_grow": "Ajuda a fer créixer aquest projecte", + "help_project_grow_description": "Spotube és un projecte de codi obert. Podeu ajudar a fer créixer aquest projecte contribuint al projecte, informant d'errors o suggerint noves funcionalitats.", + "contribute_on_github": "Contribueix a GitHub", + "donate_on_open_collective": "Fes una donació a Open Collective", + "browse_anonymously": "Navega de manera anònima" } \ No newline at end of file diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index ebaa0329..b058d41a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -286,5 +286,32 @@ "step_3_steps": "Kopiere den Wert des Cookies \"sp_dc\"", "step_4_steps": "Füge den kopierten Wert von \"sp_dc\" ein", "friends": "Freunde", - "no_lyrics_available": "Entschuldigung, Texte für diesen Track konnten nicht gefunden werden" + "no_lyrics_available": "Entschuldigung, Texte für diesen Track konnten nicht gefunden werden", + "sort_duration": "Nach Dauer sortieren", + "start_a_radio": "Radio starten", + "how_to_start_radio": "Wie möchten Sie das Radio starten?", + "replace_queue_question": "Möchten Sie die aktuelle Wiedergabeliste ersetzen oder hinzufügen?", + "endless_playback": "Endlose Wiedergabe", + "delete_playlist": "Wiedergabeliste löschen", + "delete_playlist_confirmation": "Sind Sie sicher, dass Sie diese Wiedergabeliste löschen möchten?", + "local_tracks": "Lokale Titel", + "song_link": "Lied-Link", + "skip_this_nonsense": "Diesen Unsinn überspringen", + "freedom_of_music": "“Freiheit der Musik”", + "freedom_of_music_palm": "“Freiheit der Musik in Ihrer Handfläche”", + "get_started": "Lass uns anfangen", + "youtube_source_description": "Empfohlen und funktioniert am besten.", + "piped_source_description": "Fühlen Sie sich frei? Wie YouTube, aber viel freier.", + "jiosaavn_source_description": "Am besten für die südasiatische Region.", + "highest_quality": "Höchste Qualität: {quality}", + "select_audio_source": "Audioquelle auswählen", + "endless_playback_description": "Neue Lieder automatisch\nam Ende der Wiedergabeliste hinzufügen", + "choose_your_region": "Wählen Sie Ihre Region", + "choose_your_region_description": "Dies wird Spotube helfen, Ihnen den richtigen Inhalt\nfür Ihren Standort anzuzeigen.", + "choose_your_language": "Wählen Sie Ihre Sprache", + "help_project_grow": "Helfen Sie diesem Projekt zu wachsen", + "help_project_grow_description": "Spotube ist ein Open-Source-Projekt. Sie können diesem Projekt helfen, indem Sie zum Projekt beitragen, Fehler melden oder neue Funktionen vorschlagen.", + "contribute_on_github": "Auf GitHub beitragen", + "donate_on_open_collective": "Auf Open Collective spenden", + "browse_anonymously": "Anonym durchsuchen" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8257eac9..832862c0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -313,5 +313,12 @@ "help_project_grow_description": "Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.", "contribute_on_github": "Contribute on GitHub", "donate_on_open_collective": "Donate on Open Collective", - "browse_anonymously": "Browse Anonymously" + "browse_anonymously": "Browse Anonymously", + "enable_connect": "Enable Connect", + "enable_connect_description": "Control Spotube from other devices", + "devices": "Devices", + "select": "Select", + "connect_client_alert": "You're being controlled by {client}", + "this_device": "This Device", + "remote": "Remote" } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 476056cb..0b4cbb2a 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -286,5 +286,32 @@ "step_3_steps": "Copia el valor de la cookie \"sp_dc\"", "step_4_steps": "Pega el valor copiado de \"sp_dc\"", "friends": "Amigos", - "no_lyrics_available": "Lo siento, no se pueden encontrar las letras de esta pista" + "no_lyrics_available": "Lo siento, no se pueden encontrar las letras de esta pista", + "sort_duration": "Ordenar por Duración", + "start_a_radio": "Iniciar una Radio", + "how_to_start_radio": "¿Cómo quieres iniciar la radio?", + "replace_queue_question": "¿Quieres reemplazar la lista de reproducción actual o añadir a ella?", + "endless_playback": "Reproducción Infinita", + "delete_playlist": "Eliminar Lista de Reproducción", + "delete_playlist_confirmation": "¿Estás seguro de que quieres eliminar esta lista de reproducción?", + "local_tracks": "Pistas Locales", + "song_link": "Enlace de la Canción", + "skip_this_nonsense": "Saltar esta tontería", + "freedom_of_music": "“Libertad de la Música”", + "freedom_of_music_palm": "“Libertad de la Música en la palma de tu mano”", + "get_started": "Empecemos", + "youtube_source_description": "Recomendado y funciona mejor.", + "piped_source_description": "¿Te sientes libre? Igual que YouTube pero más libre.", + "jiosaavn_source_description": "Lo mejor para la región del sur de Asia.", + "highest_quality": "Mayor Calidad: {quality}", + "select_audio_source": "Seleccionar Fuente de Audio", + "endless_playback_description": "Añadir automáticamente nuevas canciones\nal final de la cola de reproducción", + "choose_your_region": "Elige tu región", + "choose_your_region_description": "Esto ayudará a Spotube a mostrarte el contenido adecuado\npara tu ubicación.", + "choose_your_language": "Elige tu idioma", + "help_project_grow": "Ayuda a que este proyecto crezca", + "help_project_grow_description": "Spotube es un proyecto de código abierto. Puedes ayudar a que este proyecto crezca contribuyendo al proyecto, informando errores o sugiriendo nuevas funciones.", + "contribute_on_github": "Contribuir en GitHub", + "donate_on_open_collective": "Donar en Open Collective", + "browse_anonymously": "Navegar Anónimamente" } \ No newline at end of file diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 3a2bcb4b..629238cc 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -286,5 +286,32 @@ "step_3_steps": "مقدار کوکی \"sp_dc\" را کپی کنید", "step_4_steps": "مقدار کپی شده \"sp_dc\" را الصاق کنید", "friends": "دوستان", - "no_lyrics_available": "متاسفیم، قادر به یافتن متن این قطعه نیستیم" + "no_lyrics_available": "متاسفیم، قادر به یافتن متن این قطعه نیستیم", + "sort_duration": "مرتب کردن بر اساس مدت زمان", + "start_a_radio": "شروع یک رادیو", + "how_to_start_radio": "چگونه می‌خواهید رادیو را شروع کنید؟", + "replace_queue_question": "آیا می‌خواهید لیست پخش فعلی را جایگزین کنید یا به آن اضافه کنید؟", + "endless_playback": "پخش بی‌پایان", + "delete_playlist": "حذف لیست پخش", + "delete_playlist_confirmation": "آیا مطمئن هستید که می‌خواهید این لیست پخش را حذف کنید؟", + "local_tracks": "موسیقی‌های محلی", + "song_link": "پیوند آهنگ", + "skip_this_nonsense": "این احمقانه را بگذرانید", + "freedom_of_music": "“آزادی موسیقی”", + "freedom_of_music_palm": "“آزادی موسیقی در دستان شما”", + "get_started": "بیایید شروع کنیم", + "youtube_source_description": "پیشنهاد شده و بهترین عمل می‌کند.", + "piped_source_description": "احساس آزادی می‌کنید؟ مانند یوتیوب اما بیشتر آزاد.", + "jiosaavn_source_description": "بهترین برای منطقه جنوب آسیا.", + "highest_quality": "بالاترین کیفیت: {quality}", + "select_audio_source": "انتخاب منبع صوتی", + "endless_playback_description": "خودکار اضافه کردن آهنگ‌های جدید\nبه انتهای صف", + "choose_your_region": "منطقه خود را انتخاب کنید", + "choose_your_region_description": "این به Spotube کمک می‌کند تا محتوای مناسبی را برای موقعیت شما نشان دهد.", + "choose_your_language": "زبان خود را انتخاب کنید", + "help_project_grow": "کمک به رشد این پروژه", + "help_project_grow_description": "Spotube یک پروژه متن باز است. شما می‌توانید با به پروژه کمک کردن، گزارش دادن اشکالات یا پیشنهاد ویژگی‌های جدید، به این پروژه کمک کنید.", + "contribute_on_github": "مشارکت در GitHub", + "donate_on_open_collective": "کمک مالی در Open Collective", + "browse_anonymously": "مرور به صورت ناشناس" } \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 5c24d0fe..69b2bb69 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -286,5 +286,32 @@ "step_3_steps": "Copiez la valeur du cookie \"sp_dc\"", "step_4_steps": "Collez la valeur copiée de \"sp_dc\"", "friends": "Amis", - "no_lyrics_available": "Désolé, impossible de trouver les paroles de cette piste" + "no_lyrics_available": "Désolé, impossible de trouver les paroles de cette piste", + "sort_duration": "Trier par durée", + "start_a_radio": "Démarrer une radio", + "how_to_start_radio": "Comment voulez-vous démarrer la radio ?", + "replace_queue_question": "Voulez-vous remplacer la file d'attente actuelle ou y ajouter ?", + "endless_playback": "Lecture sans fin", + "delete_playlist": "Supprimer la playlist", + "delete_playlist_confirmation": "Êtes-vous sûr de vouloir supprimer cette playlist ?", + "local_tracks": "Titres locaux", + "song_link": "Lien de la chanson", + "skip_this_nonsense": "Passer cette absurdité", + "freedom_of_music": "“Liberté de la musique”", + "freedom_of_music_palm": "“Liberté de la musique dans la paume de votre main”", + "get_started": "Commençons", + "youtube_source_description": "Recommandé et fonctionne mieux.", + "piped_source_description": "Vous vous sentez libre ? Comme YouTube mais beaucoup plus gratuit.", + "jiosaavn_source_description": "Le meilleur pour la région d'Asie du Sud.", + "highest_quality": "Meilleure qualité : {quality}", + "select_audio_source": "Sélectionner la source audio", + "endless_playback_description": "Ajouter automatiquement de nouvelles chansons à la fin de la file d'attente", + "choose_your_region": "Choisissez votre région", + "choose_your_region_description": "Cela aidera Spotube à vous montrer le bon contenu pour votre emplacement.", + "choose_your_language": "Choisissez votre langue", + "help_project_grow": "Aidez ce projet à grandir", + "help_project_grow_description": "Spotube est un projet open-source. Vous pouvez aider ce projet à grandir en contribuant au projet, en signalant des bugs ou en suggérant de nouvelles fonctionnalités.", + "contribute_on_github": "Contribuer sur GitHub", + "donate_on_open_collective": "Faire un don sur Open Collective", + "browse_anonymously": "Naviguer anonymement" } \ No newline at end of file diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 1cf62398..b442da37 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -286,5 +286,32 @@ "step_3_steps": "\"sp_dc\" कुकी का मूल्य कॉपी करें", "step_4_steps": "कॉपी किए गए \"sp_dc\" मूल्य को पेस्ट करें", "friends": "दोस्त", - "no_lyrics_available": "क्षमा करें, इस ट्रैक के लिए गाने नहीं मिल सके" + "no_lyrics_available": "क्षमा करें, इस ट्रैक के लिए गाने नहीं मिल सके", + "sort_duration": "समय के आधार पर क्रमबद्ध करें", + "start_a_radio": "रेडियो शुरू करें", + "how_to_start_radio": "रेडियो कैसे शुरू करना चाहते हैं?", + "replace_queue_question": "क्या आप वर्तमान कतार को बदलना चाहते हैं या इसे जोड़ना चाहते हैं?", + "endless_playback": "अंतहीन प्लेबैक", + "delete_playlist": "प्लेलिस्ट हटाएं", + "delete_playlist_confirmation": "क्या आप वाकई इस प्लेलिस्ट को हटाना चाहते हैं?", + "local_tracks": "स्थानीय ट्रैक्स", + "song_link": "गाने का लिंक", + "skip_this_nonsense": "इस माया को छोड़ें", + "freedom_of_music": "“संगीत की स्वतंत्रता”", + "freedom_of_music_palm": "“हाथ में संगीत की स्वतंत्रता”", + "get_started": "आइए शुरू करें", + "youtube_source_description": "सिफारिश किया गया और सबसे अच्छा काम करता है।", + "piped_source_description": "मुफ्त महसूस कर रहे हैं? YouTube के समान लेकिन काफी अधिक मुफ्त।", + "jiosaavn_source_description": "दक्षिण एशियाई क्षेत्र के लिए सर्वोत्तम।", + "highest_quality": "सर्वोत्तम गुणवत्ता: {quality}", + "select_audio_source": "ऑडियो स्रोत चुनें", + "endless_playback_description": "क्रमबद्ध कतार के अंत में नए गाने स्वचालित रूप से जोड़ें", + "choose_your_region": "अपना क्षेत्र चुनें", + "choose_your_region_description": "यह Spotube को आपके स्थान के लिए सही सामग्री दिखाने में मदद करेगा।", + "choose_your_language": "अपनी भाषा चुनें", + "help_project_grow": "इस परियोजना को बढ़ावा दें", + "help_project_grow_description": "Spotube एक ओपन सोर्स परियोजना है। आप इस परियोजना को योगदान देकर, बग रिपोर्ट करके या नई विशेषताओं का सुझाव देकर इस परियोजना को बढ़ा सकते हैं।", + "contribute_on_github": "GitHub पर योगदान करें", + "donate_on_open_collective": "ओपन कलेक्टिव पर दान करें", + "browse_anonymously": "बिना नाम के ब्राउज़ करें" } \ No newline at end of file diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index ec76b914..f8440cd0 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -287,5 +287,32 @@ "step_3_steps": "Copia il valore del cookie \"sp_dc\"", "step_4_steps": "Incolla il valore copiato di \"sp_dc\"", "friends": "Amici", - "no_lyrics_available": "Spiacente, impossibile trovare il testo di questa traccia" + "no_lyrics_available": "Spiacente, impossibile trovare il testo di questa traccia", + "sort_duration": "Ordina per Durata", + "start_a_radio": "Avvia una Radio", + "how_to_start_radio": "Come vuoi avviare la radio?", + "replace_queue_question": "Vuoi sostituire la coda attuale o aggiungerla?", + "endless_playback": "Riproduzione Infinita", + "delete_playlist": "Elimina Playlist", + "delete_playlist_confirmation": "Sei sicuro di voler eliminare questa playlist?", + "local_tracks": "Tracce Locali", + "song_link": "Link della Canzone", + "skip_this_nonsense": "Salta questa sciocchezza", + "freedom_of_music": "“Libertà della Musica”", + "freedom_of_music_palm": "“Libertà della Musica nel palmo della tua mano”", + "get_started": "Cominciamo", + "youtube_source_description": "Consigliato e funziona meglio.", + "piped_source_description": "Ti senti libero? Come YouTube ma molto più gratuito.", + "jiosaavn_source_description": "Il migliore per la regione dell'Asia meridionale.", + "highest_quality": "Massima Qualità: {quality}", + "select_audio_source": "Seleziona Sorgente Audio", + "endless_playback_description": "Aggiungi automaticamente nuove canzoni alla fine della coda", + "choose_your_region": "Scegli la tua regione", + "choose_your_region_description": "Questo aiuterà Spotube a mostrarti il contenuto giusto per la tua posizione.", + "choose_your_language": "Scegli la tua lingua", + "help_project_grow": "Aiuta questo progetto a crescere", + "help_project_grow_description": "Spotube è un progetto open-source. Puoi aiutare questo progetto a crescere contribuendo al progetto, segnalando bug o suggerendo nuove funzionalità.", + "contribute_on_github": "Contribuisci su GitHub", + "donate_on_open_collective": "Dona su Open Collective", + "browse_anonymously": "Naviga in modo anonimo" } \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index d16708d7..ecdc77a2 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -286,5 +286,32 @@ "step_3_steps": "\"sp_dc\" Cookieの値をコピー", "step_4_steps": "コピーした\"sp_dc\"の値を貼り付け", "friends": "友達", - "no_lyrics_available": "申し訳ありませんが、このトラックの歌詞を見つけることができません" + "no_lyrics_available": "申し訳ありませんが、このトラックの歌詞を見つけることができません", + "sort_duration": "時間で並べ替え", + "start_a_radio": "ラジオを開始", + "how_to_start_radio": "ラジオをどのように開始しますか?", + "replace_queue_question": "現在のキューを置き換えるか、追加しますか?", + "endless_playback": "エンドレス再生", + "delete_playlist": "プレイリストを削除", + "delete_playlist_confirmation": "このプレイリストを削除してもよろしいですか?", + "local_tracks": "ローカルトラック", + "song_link": "曲のリンク", + "skip_this_nonsense": "この愚かなことをスキップ", + "freedom_of_music": "“音楽の自由”", + "freedom_of_music_palm": "“手のひらの中の音楽の自由”", + "get_started": "さあ始めましょう", + "youtube_source_description": "推奨され、最適に機能します。", + "piped_source_description": "自由に感じますか? YouTubeと同じですが、はるかに無料です。", + "jiosaavn_source_description": "南アジア地域向けの最適です。", + "highest_quality": "最高品質:{quality}", + "select_audio_source": "オーディオソースを選択", + "endless_playback_description": "新しい曲をキューの最後に自動的に追加", + "choose_your_region": "地域を選択", + "choose_your_region_description": "これにより、Spotubeがあなたの場所に適したコンテンツを表示できます。", + "choose_your_language": "言語を選択してください", + "help_project_grow": "このプロジェクトの成長を支援する", + "help_project_grow_description": "Spotubeはオープンソースプロジェクトです。プロジェクトに貢献したり、バグを報告したり、新しい機能を提案することで、このプロジェクトの成長に貢献できます。", + "contribute_on_github": "GitHubで貢献する", + "donate_on_open_collective": "Open Collectiveで寄付する", + "browse_anonymously": "匿名で閲覧する" } \ No newline at end of file diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb new file mode 100644 index 00000000..5a3ee8bc --- /dev/null +++ b/lib/l10n/app_ko.arb @@ -0,0 +1,318 @@ +{ + "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": "플레이리스트를 생성", + "create": "생성", + "cancel": "취소", + "playlist_name": "플레이리스트명", + "name_of_playlist": "플레이리스트의 이름", + "description": "설명", + "public": "공개", + "collaborative": "공유 플레이리스트", + "search_local_tracks": "기기에 저장된 곡을 검색하기", + "play": "재생", + "delete": "삭제", + "none": "없음", + "sort_a_z": "A-Z 순으로 정렬", + "sort_z_a": "Z-A 순으로 정렬", + "sort_artist": "아티스트 순으로 정렬", + "sort_album": "앨범 순으로 정렬", + "sort_tracks": "곡명 순으로 정렬", + "currently_downloading": "현재 ({tracks_length}) 곡 다운로드 중", + "cancel_all": "모두 취소", + "filter_artist": "아티스트 필터링", + "followers": "{followers} 팔로워", + "add_artist_to_blacklist": "이 아티스트를 블랙리스트에 추가", + "top_tracks": "인기곡", + "fans_also_like": "애청자들이 좋아하는 곡", + "loading": "불러오는 중...", + "artist": "아티스트", + "blacklisted": "블랙리스트", + "following": "팔로우 중", + "follow": "팔로우하기", + "artist_url_copied": "아티스트의 URL 주소를 클립보드에 복사함", + "added_to_queue": "{tracks} 곡을 대기열에 추가함", + "filter_albums": "앨범 필터링", + "synced": "동기화됨", + "plain": "그대로", + "shuffle": "셔플", + "search_tracks": "곡 검색하기", + "released": "공개일", + "error": "에러", + "title": "타이틀", + "time": "길이", + "more_actions": "다른 작업", + "download_count": "({count}) 곡 다운로드", + "add_count_to_playlist": "플레이리스트에 ({count}) 곡을 추가", + "add_count_to_queue": "대기열에 ({count}) 곡을 추가", + "play_count_next": "이 다음에 ({count}) 곡을 재생", + "album": "앨범", + "copied_to_clipboard": "{data} 를 클립보드에 복사함", + "add_to_following_playlists": "{track} 을 이 플레이리스트에 추가", + "add": "추가", + "added_track_to_queue": "대기열에 {track} 을 추가함", + "add_to_queue": "대기열에 추가", + "track_will_play_next": "{track} 을 이 다음에 재생", + "play_next": "이 다음에 재생", + "removed_track_from_queue": "대기열에서 {track} 를 제거함", + "remove_from_queue": "대기열에서 제거", + "remove_from_favorites": "즐겨찾기에서 제거", + "save_as_favorite": "즐겨찾기에 추가", + "add_to_playlist": "플레이리스트에 추가", + "remove_from_playlist": "플레이리스트에서 제거", + "add_to_blacklist": "블랙리스트에 추가", + "remove_from_blacklist": "블랙리스트에서 제거", + "share": "공유", + "mini_player": "미니 플레이어", + "slide_to_seek": "앞뒤로 슬라이드하여 탐색", + "shuffle_playlist": "플레이리스트를 섞기", + "unshuffle_playlist": "플레이리스트를 섞지 않기", + "previous_track": "이전 곡", + "next_track": "다음 곡", + "pause_playback": "일시정지", + "resume_playback": "재개", + "loop_track": "반복 재생", + "repeat_playlist": "플레이리스트 반복", + "queue": "재생 대기열", + "alternative_track_sources": "대체가능한 음악 서버", + "download_track": "곡 다운로드", + "tracks_in_queue": "대기열에 {tracks} 곡이 있음", + "clear_all": "모두 제거", + "show_hide_ui_on_hover": "마우스를 올리면 UI를 표시/숨김", + "always_on_top": "항상 위에 표시", + "exit_mini_player": "미니 플레이어 닫기", + "download_location": "다운로드 경로", + "account": "계정", + "login_with_spotify": "Spotify 계정으로 로그인", + "connect_with_spotify": "Spotify에 연결", + "logout": "로그아웃", + "logout_of_this_account": "이 계정에서 로그아웃", + "language_region": "언어 & 지역", + "language": "언어", + "system_default": "시스템 기본설정", + "market_place_region": "마켓플레이스 지역", + "recommendation_country": "추천 국가", + "appearance": "디자인", + "layout_mode": "레이아웃 모드", + "override_layout_settings": "반응형 레이아웃 모드 설정 덮어씌우기", + "adaptive": "적응형", + "compact": "컴팩트", + "extended": "확장", + "theme": "테마", + "dark": "다크", + "light": "라이트", + "system": "시스템과 동일", + "accent_color": "보조색", + "sync_album_color": "앨범 색상", + "sync_album_color_description": "앨범아트의 주요 색상을 보조색으로 사용", + "playback": "재생", + "audio_quality": "음질", + "high": "높음", + "low": "낮음", + "pre_download_play": "재생할 곡을 미리 다운로드", + "pre_download_play_description": "스트리밍 방식을 쓰는 대신 파일 단위로 다운로드 받고 재생 (인터넷 대역폭이 높은 환경에서 추천)", + "skip_non_music": "음악이 아닌 부분을 스킵 (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": "먼저 Spotify의 로그인정보를 추가하기", + "credentials_will_not_be_shared_disclaimer": "걱정마세요. 개인정보를 수집하거나 공유하지 않습니다.", + "know_how_to_login": "어떻게 하는건지 모르겠나요?", + "follow_step_by_step_guide": "사용법 확인하기", + "spotify_cookie": "Spotify {name} Cookies", + "cookie_name_cookie": "{name} Cookies", + "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)을 눌러 브라우저의 개발자 도구(devtools)를 열어주세요.\n2. 애플리케이션 (Application) 탭 (Chrome, Edge, Brave 등) 또는 스토리지 탭 (Firefox, Palemoon 등)을 열어주세요.\n3. 쿠키 (Cookies) 섹션으로 들어가서, https://accounts.spotify.com 서브섹션으로 들어가주세요.", + "step_3": "3단계", + "success_emoji": "성공🥳", + "success_message": "성공적으로 스포티파이 게정으로 로그인했습니다. 잘했어요!", + "step_4": "4단계", + "something_went_wrong": "알 수 없는 이유로 동작에 실패했습니다.", + "piped_instance": "Piped 서버의 인스턴스", + "piped_description": "곡 탐색에 사용할 Piped 서버 인스턴스", + "piped_warning": "몇몇 서버는 제대로 동작하지 않을 수 있습니다. 본인 책임 하에 이용해주세요.", + "generate_playlist": "플레이리스트 생성", + "track_exists": "곡 {track} 은 이미 리스트에 있습니다", + "replace_downloaded_tracks": "다운로드한 모든 곡을 교체", + "skip_download_tracks": "다운로드가 끝난 곡을 모두 건너뛰기", + "do_you_want_to_replace": "현재 곡을 교체하시겠습니까?", + "replace": "교체", + "skip": "건너뛰기", + "select_up_to_count_type": "{type}을 {count}개까지 선택", + "select_genres": "장르 선택", + "add_genres": "장르 추가", + "country": "국가", + "number_of_tracks_generate": "생성할 곡 수", + "acousticness": "반주 구간 (Acousticness)", + "danceability": "흥겨운 정도 (Danceability)", + "energy": "에너지 (Energy)", + "instrumentalness": "기악성 (Instrumentalness)", + "liveness": "생동감 (Liveness)", + "loudness": "라우드니스 (Loudness)", + "speechiness": "회화성 (Speechniss)", + "valence": "감정가 (Valence)", + "popularity": "인기도 (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": "참고로, 평소보다 과도한 다운로드 요청으로 인해 YouTube에서 IP가 차단될 수 있습니다. IP 차단은 해당 IP 기기에서 최소 2~3개월 동안 (로그인한 상태에서도) YouTube를 사용할 수 없음을 의미합니다. 그리고 이런 일이 발생하더라도 스포튜브는 어떠한 책임도 지지 않습니다.", + "by_clicking_accept_terms": "'동의'를 클릭하면 다음 약관에 동의하는 것입니다:", + "download_agreement_1": "알고 있습니다. 전 나쁜 사람입니다.", + "download_agreement_2": "제가 할 수 있는 모든 곳에서 아티스트를 지원할 것이며, 저는 그들의 작품을 살 돈이 없기 때문에 이렇게 하는 것뿐입니다.", + "download_agreement_3": "본인은 YouTube에서 내 IP가 차단될 수 있음을 완전히 알고 있으며, 현재 내 행동으로 인해 발생하는 사고에 대해 Spotube 또는 그 소유자/기여자에게 책임을 묻지 않습니다.", + "decline": "거절", + "accept": "동의", + "details": "상세", + "youtube": "YouTube", + "channel": "채널", + "likes": "좋아요", + "dislikes": "싫어요", + "views": "조회수", + "streamUrl": "스트림 URL", + "stop": "중지", + "sort_newest": "최근에 추가된 순으로 정렬", + "sort_oldest": "예전에 추가된 순으로 정렬", + "sleep_timer": "취침 타이머", + "mins": "{minutes} 분", + "hours": "{hours} 시간", + "hour": "{hours} 시간", + "custom_hours": "시간 설정", + "logs": "로그", + "developers": "개발", + "not_logged_in": "로그인하지 않았습니다", + "search_mode": "검색 모드", + "audio_source": "오디오 출처", + "ok": "알겠습니다", + "failed_to_encrypt": "암호화에 실패했습니다", + "encryption_failed_warning": "Spotube는 암호화를 사용하여 데이터를 안전하게 저장합니다. 하지만 그렇게 하지 못했습니다. 따라서 안전하지 않은 저장소로 대체됩니다.\n리눅스를 사용하는 경우, 비밀 서비스(gnome-keyring, kde-wallet, keepassxc 등)가 설치되어 있는지 확인하세요.", + "querying_info": "정보를 얻는 중...", + "piped_api_down": "Piped API가 응답하지 않습니다", + "piped_down_error_instructions": "Piped 인스턴스 {pipedInstance}가 현재 다운되었습니다.\n\n인스턴스를 변경하거나 'API 유형'을 공식 YouTube API로 변경하세요.\n\n변경 후 앱을 다시 시작해야 합니다.", + "you_are_offline": "현재 오프라인입니다", + "connection_restored": "인터넷에 다시 연결되었습니다", + "use_system_title_bar": "시스템 타이틀바를 사용", + "update_playlist": "플레이리스트를 업데이트", + "update": "업데이트", + "crunching_results": "결과를 처리하는 중...", + "search_to_get_results": "결과를 얻으려면 검색해주세요", + "use_amoled_mode": "AMOLED모드를 사용", + "pitch_dark_theme": "검정색 기반의 어두운 테마", + "normalize_audio": "오디오 노멀라이즈", + "change_cover": "커버 변경", + "add_cover": "커버 추가", + "restore_defaults": "기본값으로 복원", + "download_music_codec": "다운로드 음악 코덱", + "streaming_music_codec": "스트리밍 음악 코덱", + "login_with_lastfm": "Last.fm에 로그인", + "connect": "연결", + "disconnect_lastfm": "Last.fm에서 연결 해제", + "disconnect": "연결 해제", + "username": "사용자명", + "password": "비밀번호", + "login": "로그인", + "login_with_your_lastfm": "내 Last.fm 계정으로로그인", + "scrobble_to_lastfm": "Scrobble to Last.fm", + "go_to_album": "앨범으로 이동", + "discord_rich_presence": "Discord Rich Presence", + "browse_all": "모두 탐색", + "genres": "장르", + "explore_genres": "장르 탐색", + "step_3_steps": "\"sp_dc\" 쿠키의 값을 복사", + "step_4_steps": "복사한 \"sp_dc\"값을 붙여넣기", + "friends": "친구", + "no_lyrics_available": "죄송하지만 이 곡의 가사를 찾지 못했습니다", + "@@locale": "ko", + "sort_duration": "시간순 정렬", + "start_a_radio": "라디오 시작", + "how_to_start_radio": "라디오를 어떻게 시작하시겠습니까?", + "replace_queue_question": "현재 큐를 대체하시겠습니까 아니면 추가하시겠습니까?", + "endless_playback": "끝없는 재생", + "delete_playlist": "재생 목록 삭제", + "delete_playlist_confirmation": "이 재생 목록을 삭제하시겠습니까?", + "local_tracks": "로컬 트랙", + "song_link": "곡 링크", + "skip_this_nonsense": "이 허튼소리 건너뛰기", + "freedom_of_music": "“음악의 자유”", + "freedom_of_music_palm": "“손바닥 안의 음악의 자유”", + "get_started": "시작합시다", + "youtube_source_description": "추천되며 가장 잘 작동합니다.", + "piped_source_description": "자유로운 기분이 듭니까? YouTube와 같지만 훨씬 더 무료합니다.", + "jiosaavn_source_description": "남아시아 지역에 최적입니다.", + "highest_quality": "최고 품질: {quality}", + "select_audio_source": "오디오 소스 선택", + "endless_playback_description": "자동으로 새로운 노래를 대기열의 끝에 추가", + "choose_your_region": "지역 선택", + "choose_your_region_description": "이것은 Spotube가 위치에 맞는 콘텐츠를 표시하는 데 도움이 됩니다.", + "choose_your_language": "언어 선택", + "help_project_grow": "이 프로젝트 성장에 도움을 주세요", + "help_project_grow_description": "Spotube는 오픈 소스 프로젝트입니다. 프로젝트에 기여하거나 버그를 보고하거나 새로운 기능을 제안하여이 프로젝트의 성장에 도움을 줄 수 있습니다.", + "contribute_on_github": "GitHub에서 기여하기", + "donate_on_open_collective": "Open Collective에 기부하기", + "browse_anonymously": "익명으로 둘러보기" +} \ No newline at end of file diff --git a/lib/l10n/app_ne.arb b/lib/l10n/app_ne.arb index 2d20fc9c..d921f3ba 100644 --- a/lib/l10n/app_ne.arb +++ b/lib/l10n/app_ne.arb @@ -286,5 +286,32 @@ "genres": "शैलीहरू", "explore_genres": "शैलीहरू अन्वेषण गर्नुहोस्", "friends": "साथीहरू", - "no_lyrics_available": "क्षमा गर्दैछौं, यस ट्र्याकका लागि गीतका शब्दहरू फेला परेन" + "no_lyrics_available": "क्षमा गर्दैछौं, यस ट्र्याकका लागि गीतका शब्दहरू फेला परेन", + "sort_duration": "अवधिको अनुसार क्रमबद्ध गर्नुहोस्", + "start_a_radio": "रेडियो सुरु गर्नुहोस्", + "how_to_start_radio": "तपाईं रेडियो कसरी सुरु गर्न चाहानुहुन्छ?", + "replace_queue_question": "के तपाईं वर्तमान कताक्ष कोट बदल्न चाहानुहुन्छ वा यसलाई थप्नुहुन्छ?", + "endless_playback": "अनन्त प्लेब्याक", + "delete_playlist": "प्लेलिस्ट मेटाउनुहोस्", + "delete_playlist_confirmation": "के तपाईं यो प्लेलिस्ट मेटाउन निश्चित हुनुहुन्छ?", + "local_tracks": "स्थानिय ट्र्याकहरू", + "song_link": "गीत लिंक", + "skip_this_nonsense": "यस अबश्यकता छोड्नुहोस्", + "freedom_of_music": "“संगीतको स्वतन्त्रता”", + "freedom_of_music_palm": "“तपाईंको हातमा संगीतको स्वतन्त्रता”", + "get_started": "आइयाँ प्रारम्भ गरौं", + "youtube_source_description": "सिफारिस गरिएको र बेस्ट काम गर्दछ।", + "piped_source_description": "मुक्त सुस्त? YouTube जस्तै तर धेरै मुक्त।", + "jiosaavn_source_description": "दक्षिण एशियाली क्षेत्रको लागि सर्वोत्तम।", + "highest_quality": "उच्चतम गुणस्तर: {quality}", + "select_audio_source": "आडियो स्रोत चयन गर्नुहोस्", + "endless_playback_description": "नयाँ गीतहरूलाई स्वचालित रूपमा कताक्षको अन्तमा जोड्नुहोस्", + "choose_your_region": "तपाईंको क्षेत्र छनौट गर्नुहोस्", + "choose_your_region_description": "यो Spotubeलाई तपाईंको स्थानका लागि सहि सामग्री देखाउने मद्दत गर्नेछ।", + "choose_your_language": "तपाईंको भाषा छनौट गर्नुहोस्", + "help_project_grow": "यस परियोजनामा वृद्धि गराउनुहोस्", + "help_project_grow_description": "Spotube एक खुला स्रोतको परियोजना हो। तपाईं परियोजनामा योगदान गरेर, त्रुटिहरू सूचिकै, वा नयाँ सुविधाहरू सुझाव दिएर यस परियोजनामा वृद्धि गर्न सक्नुहुन्छ।", + "contribute_on_github": "GitHubमा योगदान गर्नुहोस्", + "donate_on_open_collective": "खुला संगठनमा दान गर्नुहोस्", + "browse_anonymously": "अनामित रूपमा ब्राउज़ गर्नुहोस्" } \ No newline at end of file diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 3bece8be..33e94a2e 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -286,5 +286,33 @@ "genres": "Genres", "explore_genres": "Genres verkennen", "friends": "Vrienden", - "no_lyrics_available": "Sorry, geen teksten gevonden voor dit nummer" -} + "no_lyrics_available": "Sorry, geen teksten gevonden voor dit nummer", + "sort_duration": "Sorteer op Duur", + "audio_source": "Audiobron", + "start_a_radio": "Start een Radio", + "how_to_start_radio": "Hoe wilt u de radio starten?", + "replace_queue_question": "Wilt u de huidige wachtrij vervangen of eraan toevoegen?", + "endless_playback": "Eindeloze Afspelen", + "delete_playlist": "Verwijder Afspeellijst", + "delete_playlist_confirmation": "Weet u zeker dat u deze afspeellijst wilt verwijderen?", + "local_tracks": "Lokale Nummers", + "song_link": "Nummer Link", + "skip_this_nonsense": "Sla deze onzin over", + "freedom_of_music": "“Vrijheid van Muziek”", + "freedom_of_music_palm": "“Vrijheid van Muziek in de palm van je hand”", + "get_started": "Laten we beginnen", + "youtube_source_description": "Aanbevolen en werkt het beste.", + "piped_source_description": "Voel je vrij? Hetzelfde als YouTube maar veel gratis.", + "jiosaavn_source_description": "Het beste voor de Zuid-Aziatische regio.", + "highest_quality": "Hoogste Kwaliteit: {quality}", + "select_audio_source": "Selecteer Audiobron", + "endless_playback_description": "Voeg automatisch nieuwe nummers toe aan het einde van de wachtrij", + "choose_your_region": "Kies uw regio", + "choose_your_region_description": "Dit zal Spotube helpen om de juiste inhoud voor uw locatie te tonen.", + "choose_your_language": "Kies uw taal", + "help_project_grow": "Help dit project groeien", + "help_project_grow_description": "Spotube is een open-source project. U kunt dit project helpen groeien door bij te dragen aan het project, bugs te melden of nieuwe functies voor te stellen.", + "contribute_on_github": "Bijdragen op GitHub", + "donate_on_open_collective": "Doneren op Open Collective", + "browse_anonymously": "Anoniem Bladeren" +} \ No newline at end of file diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index b7ce8923..a1bc5de6 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -286,5 +286,32 @@ "step_3_steps": "Skopiuj wartość ciasteczka \"sp_dc\"", "step_4_steps": "Wklej skopiowaną wartość \"sp_dc\"", "friends": "Przyjaciele", - "no_lyrics_available": "Przepraszamy, nie można znaleźć tekstu dla tego utworu" + "no_lyrics_available": "Przepraszamy, nie można znaleźć tekstu dla tego utworu", + "sort_duration": "Sortuj według Czasu Trwania", + "start_a_radio": "Uruchom radio", + "how_to_start_radio": "Jak chcesz uruchomić radio?", + "replace_queue_question": "Czy chcesz zastąpić bieżącą kolejkę czy dodać do niej?", + "endless_playback": "Nieskończona Odtwarzanie", + "delete_playlist": "Usuń Playlistę", + "delete_playlist_confirmation": "Czy na pewno chcesz usunąć tę listę odtwarzania?", + "local_tracks": "Lokalne Utwory", + "song_link": "Link do Utworu", + "skip_this_nonsense": "Pomiń tę bzdurę", + "freedom_of_music": "“Wolność Muzyki”", + "freedom_of_music_palm": "“Wolność Muzyki w Twojej dłoni”", + "get_started": "Zacznijmy", + "youtube_source_description": "Polecane i działa najlepiej.", + "piped_source_description": "Czujesz się wolny? To samo co YouTube, ale dużo za darmo.", + "jiosaavn_source_description": "Najlepszy dla regionu Azji Południowej.", + "highest_quality": "Najwyższa Jakość: {quality}", + "select_audio_source": "Wybierz Źródło Audio", + "endless_playback_description": "Automatycznie dodaj nowe utwory na koniec kolejki", + "choose_your_region": "Wybierz swoją region", + "choose_your_region_description": "To pomoże Spotube pokazać Ci odpowiednią treść dla Twojej lokalizacji.", + "choose_your_language": "Wybierz swój język", + "help_project_grow": "Pomóż temu projektowi rosnąć", + "help_project_grow_description": "Spotube to projekt open-source. Możesz pomóc temu projektowi rosnąć, przyczyniając się do projektu, zgłaszając błędy lub sugerując nowe funkcje.", + "contribute_on_github": "Przyczyniaj się na GitHubie", + "donate_on_open_collective": "Dotuj na Open Collective", + "browse_anonymously": "Przeglądaj Anonimowo" } \ No newline at end of file diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 1c75f734..7f290a1d 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -286,5 +286,32 @@ "step_3_steps": "Copie o valor do cookie \"sp_dc\"", "step_4_steps": "Cole o valor copiado de \"sp_dc\"", "friends": "Amigos", - "no_lyrics_available": "Desculpe, não foi possível encontrar a letra desta faixa" + "no_lyrics_available": "Desculpe, não foi possível encontrar a letra desta faixa", + "sort_duration": "Ordenar por Duração", + "start_a_radio": "Iniciar uma Rádio", + "how_to_start_radio": "Como você deseja iniciar a rádio?", + "replace_queue_question": "Você deseja substituir a fila atual ou acrescentar a ela?", + "endless_playback": "Reprodução sem fim", + "delete_playlist": "Excluir Lista de Reprodução", + "delete_playlist_confirmation": "Tem certeza de que deseja excluir esta lista de reprodução?", + "local_tracks": "Faixas Locais", + "song_link": "Link da Música", + "skip_this_nonsense": "Pular essa bobagem", + "freedom_of_music": "“Liberdade da Música”", + "freedom_of_music_palm": "“Liberdade da Música na palma da sua mão”", + "get_started": "Vamos começar", + "youtube_source_description": "Recomendado e funciona melhor.", + "piped_source_description": "Sentindo-se livre? Igual ao YouTube, mas muito mais grátis.", + "jiosaavn_source_description": "Melhor para a região da Ásia do Sul.", + "highest_quality": "Melhor Qualidade: {quality}", + "select_audio_source": "Selecionar Fonte de Áudio", + "endless_playback_description": "Adicionar automaticamente novas músicas\nao final da fila", + "choose_your_region": "Escolha sua região", + "choose_your_region_description": "Isso ajudará o Spotube a mostrar o conteúdo certo\npara sua localização.", + "choose_your_language": "Escolha seu idioma", + "help_project_grow": "Ajude este projeto a crescer", + "help_project_grow_description": "Spotube é um projeto de código aberto. Você pode ajudar este projeto a crescer contribuindo para o projeto, relatando bugs ou sugerindo novos recursos.", + "contribute_on_github": "Contribuir no GitHub", + "donate_on_open_collective": "Doar no Open Collective", + "browse_anonymously": "Navegar Anonimamente" } \ No newline at end of file diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 7ed67f4f..c9139a90 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -286,5 +286,32 @@ "step_3_steps": "Скопируйте значение файла cookie \"sp_dc\"", "step_4_steps": "Вставьте скопированное значение \"sp_dc\"", "friends": "Друзья", - "no_lyrics_available": "Извините, не удается найти текст для этого трека" + "no_lyrics_available": "Извините, не удается найти текст для этого трека", + "sort_duration": "Сортировка по Длительности", + "start_a_radio": "Запустить радио", + "how_to_start_radio": "Как вы хотите запустить радио?", + "replace_queue_question": "Хотите заменить текущую очередь или добавить к ней?", + "endless_playback": "Бесконечное воспроизведение", + "delete_playlist": "Удалить плейлист", + "delete_playlist_confirmation": "Вы уверены, что хотите удалить этот плейлист?", + "local_tracks": "Локальные треки", + "song_link": "Ссылка на песню", + "skip_this_nonsense": "Пропустить этот бред", + "freedom_of_music": "“Свобода музыки”", + "freedom_of_music_palm": "“Свобода музыки в вашей ладони”", + "get_started": "Начнем", + "youtube_source_description": "Рекомендуется и лучше всего работает.", + "piped_source_description": "Чувствуете себя свободно? То же самое, что и YouTube, но намного бесплатно.", + "jiosaavn_source_description": "Лучший для Южно-Азиатского региона.", + "highest_quality": "Наивысшее качество: {quality}", + "select_audio_source": "Выберите аудиоисточник", + "endless_playback_description": "Автоматически добавляйте новые песни\nв конец очереди", + "choose_your_region": "Выберите ваш регион", + "choose_your_region_description": "Это поможет Spotube показать вам правильный контент\nдля вашего местоположения.", + "choose_your_language": "Выберите ваш язык", + "help_project_grow": "Помогите этому проекту расти", + "help_project_grow_description": "Spotube - это проект с открытым исходным кодом. Вы можете помочь этому проекту развиваться, внося вклад в проект, сообщая ошибках или предлагая новые функции.", + "contribute_on_github": "Внести вклад на GitHub", + "donate_on_open_collective": "Пожертвовать на Open Collective", + "browse_anonymously": "Анонимно просматривать" } \ No newline at end of file diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb new file mode 100644 index 00000000..5df6bc20 --- /dev/null +++ b/lib/l10n/app_th.arb @@ -0,0 +1,317 @@ +{ + "guest": "ผู้มาเยือน", + "browse": "เรียกดู", + "search": "ค้นหา", + "library": "คลัง", + "lyrics": "เนื้อเพลง", + "settings": "ตั้งค่า", + "genre_categories_filter": "กรองประเภทหรือแนวเพลง...", + "genre": "ประเภท", + "personalized": "ปรับแต่ง", + "featured": "เด่น", + "new_releases": "เพิ่งปล่อยใหม่", + "songs": "เพลง", + "playing_track": "กำลังเล่น {track}", + "queue_clear_alert": "การดำเนินการนี้จะล้างคิวปัจจุบัน {track length} แทร็ก จะถูกลบออก\nคุณต้องการดำเนินการต่อหรือไม่?", + "load_more": "โหลดเพิ่มเติม", + "playlists": "เพลย์ลิสต์", + "artists": "ศิลปิน", + "albums": "อัลบั้ม", + "tracks": "แทร็ก", + "downloads": "ดาวน์โหลด", + "filter_playlists": "กรองเพลย์ลิสต์...", + "liked_tracks": "เพลงที่ชอบ", + "liked_tracks_description": "เพลงที่คุณชื่นชอบทั้งหมด", + "create_playlist": "สร้างเพลย์ลิสต์", + "create_a_playlist": "สร้างเพลย์ลิสต์", + "update_playlist": "อัพเดทเพลย์ลิสต์", + "create": "สร้าง", + "cancel": "ยกเลิก", + "update": "อัพเดท", + "playlist_name": "ชื่อเพลย์ลิสต์", + "name_of_playlist": "ชื่อของเพลย์ลิสต์", + "description": "คำอธิบาย", + "public": "สาธารณะ", + "collaborative": "ร่วมมือกัน", + "search_local_tracks": "ค้นหาเพลงในเครื่อง...", + "play": "เล่น", + "delete": "ลบ", + "none": "ไม่มี", + "sort_a_z": "เรียงตาม A-Z", + "sort_z_a": "เรียงตาม Z-A", + "sort_artist": "เรียงตามศิลปิน", + "sort_album": "เรียงตามอัลบั้ม", + "sort_duration": "เรียงตามความยาว", + "sort_tracks": "เรียงตามเพลง", + "currently_downloading": "กำลังดาวน์โหลด ({tracks_length})", + "cancel_all": "ยกเลิกทั้งหมด", + "filter_artist": "กรองศิลปิน...", + "followers": "{followers} ผู้ติดตาม", + "add_artist_to_blacklist": "เพิ่มศิลปินในบัญชีดำ", + "top_tracks": "เพลงฮิต", + "fans_also_like": "แฟนๆ ยังชอบ", + "loading": "กำลังโหลด...", + "artist": "ศิลปิน", + "blacklisted": "อยู่ในบัญชีดำ", + "following": "กำลังติดตาม", + "follow": "ติดตาม", + "artist_url_copied": "คัดลอก URL ศิลปินไปยังคลิปบอร์ด", + "added_to_queue": "เพิ่ม {tracks} เพลงลงในคิว", + "filter_albums": "กรองอัลบั้ม...", + "synced": "ซิงค์", + "plain": "เรียบง่าย", + "shuffle": "สุ่ม", + "search_tracks": "ค้นหาเพลง...", + "released": "เผยแพร่", + "error": "ข้อผิดพลาด {error}", + "title": "ชื่อ", + "time": "เวลา", + "more_actions": "เพิ่มเติม", + "download_count": "ดาวน์โหลด ({count})", + "add_count_to_playlist": "เพิ่ม ({count}) ลงในเพลย์ลิสต์", + "add_count_to_queue": "เพิ่ม ({count}) ลงในคิว", + "play_count_next": "เล่น ({count}) ต่อไป", + "album": "อัลบั้ม", + "copied_to_clipboard": "คัดลอก {data} ไปยังคลิปบอร์ด", + "add_to_following_playlists": "เพิ่ม {track} ลงในเพลย์ลิสต์", + "add": "เพิ่ม", + "added_track_to_queue": "เพิ่ม {track} ลงในคิว", + "add_to_queue": "เพิ่มลงในคิว", + "track_will_play_next": "{track} จะเล่นต่อไป", + "play_next": "เล่นต่อไป", + "removed_track_from_queue": "ลบ {track} ออกจากคิว", + "remove_from_queue": "ลบออกจากคิว", + "remove_from_favorites": "ลบออกจากรายการโปรด", + "save_as_favorite": "บันทึกเป็นรายการโปรด", + "add_to_playlist": "เพิ่มลงในเพลย์ลิสต์", + "remove_from_playlist": "ลบออกจากเพลย์ลิสต์", + "add_to_blacklist": "เพิ่มลงในบัญชีดำ", + "remove_from_blacklist": "ลบออกจากบัญชีดำ", + "share": "แชร์", + "mini_player": "มินิเพลเยอร์", + "slide_to_seek": "เลื่อนเพื่อไปข้างหน้าหรือถอยหลัง", + "shuffle_playlist": "สุ่มเพลย์ลิสต์", + "unshuffle_playlist": "ยกเลิกการสุ่มเพลย์ลิสต์", + "previous_track": "แทร็กก่อนหน้า", + "next_track": "แทร็กถัดไป", + "pause_playback": "หยุดการเล่น", + "resume_playback": "เล่นต่อ", + "loop_track": "วนเพลง", + "repeat_playlist": "ซ้ำเพลย์ลิสต์", + "queue": "คิว", + "alternative_track_sources": "แหล่งแทร็กอื่น", + "download_track": "ดาวน์โหลดแทร็ก", + "tracks_in_queue": "{tracks} แทร็กในคิว", + "clear_all": "ล้างทั้งหมด", + "show_hide_ui_on_hover": "แสดง/ซ่อน UI เมื่อโฮเวอร์", + "always_on_top": "อยู่ด้านบนเสมอ", + "exit_mini_player": "ออกจากมินิเพลย์เยอร์", + "download_location": "ตำแหน่งดาวน์โหลด", + "account": "บัญชี", + "login_with_spotify": "เข้าสู่ระบบด้วยบัญชี Spotify", + "connect_with_spotify": "เชื่อมต่อกับ Spotify", + "logout": "ออกจากระบบ", + "logout_of_this_account": "ออกจากระบบบัญชีนี้", + "language_region": "ภาษาและภูมิภาค", + "language": "ภาษา", + "system_default": "ค่าเริ่มต้นของระบบ", + "market_place_region": "ภูมิภาค Marketplace", + "recommendation_country": "ประเทศที่แนะนำ", + "appearance": "ลักษณะที่ปรากฏ", + "layout_mode": "โหมดเค้าโครง", + "override_layout_settings": "แทนที่การตั้งค่าโหมดเค้าโครงแบบตอบสนอง", + "adaptive": "ปรับเปลี่ยน", + "compact": "กระชับ", + "extended": "ขยาย", + "theme": "ธีม", + "dark": "มืด", + "light": "สว่าง", + "system": "ระบบ", + "accent_color": "สีเน้น", + "sync_album_color": "ซิงค์สีอัลบั้ม", + "sync_album_color_description": "ใช้สีเด่นของอาร์ตอัลบั้มเป็นสีเน้น", + "playback": "การเล่น", + "audio_quality": "คุณภาพเสียง", + "high": "สูง", + "low": "ต่ำ", + "pre_download_play": "ดาวน์โหลดล่วงหน้าและเล่น", + "pre_download_play_description": "แทนที่จะสตรีมเสียง ดาวน์โหลดข้อมูลและเล่นแทน (แนะนำสำหรับผู้ใช้แบนด์วิดธ์สูง)", + "skip_non_music": "ข้ามส่วนที่ไม่ใช่เพลง (SponsorBlock)", + "blacklist_description": "แทร็กและศิลปินที่บล็อก", + "wait_for_download_to_finish": "โปรดรอให้การดาวน์โหลดปัจจุบันเสร็จสิ้น", + "desktop": "เดสก์ท็อป", + "close_behavior": "ปิดพฤติกรรม", + "close": "ปิด", + "minimize_to_tray": "ลดขนาดลงถาด", + "show_tray_icon": "แสดงไอคอนถาดระบบ", + "about": "เกี่ยวกับ", + "u_love_spotube": "เรารู้ว่าคุณรัก Spotube", + "check_for_updates": "ตรวจสอบการปรับปรุง", + "about_spotube": "เกี่ยวกับ Spotube", + "blacklist": "แบล็กลิสต์", + "please_sponsor": "กรุณาสนับสนุน/บริจาค", + "spotube_description": "Spotube โปรแกรมเล่น Spotify ฟรีสำหรับทุกคน น้ำหนักเบา รองรับหลายแพลตฟอร์ม", + "version": "รุ่น", + "build_number": "หมายเลขบิลด์", + "founder": "ผู้ก่อตั้ง", + "repository": "ที่เก็บ", + "bug_issues": "ข้อผิดพลาด+ปัญหา", + "made_with": "ทำด้วย❤️ใน บังคลาเทศ🇧🇩", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "ใบอนุญาต", + "add_spotify_credentials": "เพิ่มข้อมูลรับรอง Spotify ของคุณเพื่อเริ่มต้น", + "credentials_will_not_be_shared_disclaimer": "ไม่ต้องกังวล ข้อมูลรับรองใดๆ ของคุณจะไม่ถูกเก็บรวบรวมหรือแชร์กับใคร", + "know_how_to_login": "ไม่รู้จักวิธีดำเนินการนี้ใช่ไหม", + "follow_step_by_step_guide": "ทำตามคู่มือทีละขั้น", + "spotify_cookie": "คุกกี้ Spotify {name}", + "cookie_name_cookie": "คุกกี้ {name}", + "fill_in_all_fields": "กรุณากรอกข้อมูลทุกช่อง", + "submit": "ยื่น", + "exit": "ออก", + "previous": "ย้อนกลับ", + "next": "ถัดไป", + "done": "เสร็จ", + "step_1": "ขั้นที่ 1", + "first_go_to": "ก่อนอื่น ไปที่", + "login_if_not_logged_in": "ยังไม่ได้เข้าสู่ระบบ ให้เข้าสู่ระบบ/ลงทะเบียน", + "step_2": "ขั้นที่ 2", + "step_2_steps": "1. หลังจากเข้าสู่ระบบแล้ว กด F12 หรือ คลิกขวาที่เมาส์ > ตรวจสอบเพื่อเปิด Devtools เบราว์เซอร์\n2. จากนั้นไปที่แท็บ \"แอปพลิเคชัน\" (Chrome, Edge, Brave เป็นต้น) หรือแท็บ \"ที่เก็บข้อมูล\" (Firefox, Palemoon เป็นต้น)\n3. ไปที่ส่วน \"คุกกี้\" แล้วไปที่ subsection \"https: //accounts.spotify.com\"", + "step_3": "ขั้นที่ 3", + "step_3_steps": "คัดลอกค่าคุกกี้ \"sp_dc\"", + "success_emoji": "สำเร็จ", + "success_message": "ตอนนี้คุณเข้าสู่ระบบด้วยบัญชี Spotify ของคุณเรียบร้อยแล้ว ยอดเยี่ยม!", + "step_4": "ขั้นที่ 4", + "step_4_steps": "วางค่า \"sp_dc\" ที่คัดลอกมา", + "something_went_wrong": "มีอะไรผิดพลาด", + "piped_instance": "อินสแตนซ์เซิร์ฟเวอร์แบบ Pipe", + "piped_description": "อินสแตนซ์เซิร์ฟเวอร์แบบ Pipe ที่ใช้สำหรับการจับคู่แทร็ก", + "piped_warning": "บางอย่างอาจใช้งานไม่ได้ผล คุณจึงต้องรับความเสี่ยงเอง", + "generate_playlist": "สร้างเพลย์ลิสต์", + "track_exists": "แทร็ก {track} มีอยู่แล้ว", + "replace_downloaded_tracks": "แทนที่แทร็กที่ดาวน์โหลดทั้งหมด", + "skip_download_tracks": "ข้ามการดาวน์โหลดแทร็กที่ดาวน์โหลดทั้งหมด", + "do_you_want_to_replace": "คุณต้องการแทนที่แทร็กที่มีอยู่หรือไม่", + "replace": "แทนที่", + "skip": "ข้าม", + "select_up_to_count_type": "เลือกสูงสุด {count} {type}", + "select_genres": "เลือกประเภท", + "add_genres": "เพิ่มประเภท", + "country": "ประเทศ", + "number_of_tracks_generate": "จำนวนแทร็กที่จะสร้าง", + "acousticness": "อะคูสติก", + "danceability": "ความสามารถในการเต้น", + "energy": "พลัง", + "instrumentalness": "บรรเลง", + "liveness": "ความสด", + "loudness": "ความดัง", + "speechiness": "การพูด", + "valence": "ความสุข", + "popularity": "ความนิยม", + "key": "คีย์", + "duration": "ระยะเวลา (วินาที)", + "tempo": "ความเร็ว (BPM)", + "mode": "โหมด", + "time_signature": "ลายเซ็นเวลา", + "short": "สั้น", + "medium": "กลาง", + "long": "ยาว", + "min": "ต่ำสุด", + "max": "สูงสุด", + "target": "เป้าหมาย", + "moderate": "ปานกลาง", + "deselect_all": "ยกเลิกการเลือกทั้งหมด", + "select_all": "เลือกทั้งหมด", + "are_you_sure": "คุณแน่ใจไหม?", + "generating_playlist": "กำลังสร้างเพลย์ลิสต์ที่คุณกำหนดเอง...", + "selected_count_tracks": "เลือก {count} แทร็ก", + "download_warning": "ถ้าคุณดาวน์โหลดเพลงทั้งหมดเป็นจำนวนมาก คุณกำลังละเมิดลิขสิทธิ์เพลงและสร้างความเสียหายให้กับสังคมดนตรี สร้างสรรค์ หวังว่าคุณจะรับรู้เรื่องนี้ เสมอ พยายามเคารพและสนับสนุนผลงานหนักของศิลปิน", + "download_ip_ban_warning": "นอกเหนือจากนั้น IP ของคุณอาจถูกบล็อกบน YouTube เนื่องจากคำขอดาวน์โหลดมากเกินกว่าปกติ การบล็อก IP หมายความว่าคุณไม่สามารถใช้ YouTube (แม้ว่าคุณจะล็อกอินอยู่) เป็นเวลาอย่างน้อย 2-3 เดือนจากอุปกรณ์ IP นั้น และ Spotube จะไม่รับผิดชอบใด ๆ หากสิ่งนี้เกิดขึ้น", + "by_clicking_accept_terms": "คลิก 'ยอมรับ' คุณยินยอมตามเงื่อนไขต่อไปนี้:", + "download_agreement_1": "ฉันรู้ว่าฉันกำลังละเมิดลิขสิทธิ์เพลง ฉันเลว", + "download_agreement_2": "ฉันจะสนับสนุนศิลปินทุกที่ที่ฉันทำได้และฉันทำสิ่งนี้เพียงเพราะฉันไม่มีเงินซื้อผลงานศิลปะของพวกเขา", + "download_agreement_3": "ฉันรับทราบอย่างสมบูรณ์ว่า IP ของฉันอาจถูกบล็อกบน YouTube และฉันจะไม่ถือ Spotube หรือเจ้าของ/ผู้มีส่วนร่วมใด ๆ รับผิดชอบต่ออุบัติเหตุใด ๆ ที่เกิดจากการกระทำปัจจุบันของฉัน", + "decline": "ปฏิเสธ", + "accept": "ยอมรับ", + "details": "รายละเอียด", + "youtube": "youtube", + "channel": "ช่อง", + "likes": "ถูกใจ", + "dislikes": "ไม่ชอบ", + "views": "วิว", + "streamUrl": "สตรีม URL", + "stop": "หยุด", + "sort_newest": "เรียงตามการเพิ่มใหม่ล่าสุด", + "sort_oldest": "เรียงตามการเพิ่มเก่าสุด", + "sleep_timer": "ตั้งเวลาปิด", + "mins": "{minutes} นาที", + "hours": "{hours} ชั่วโมง", + "hour": "{hours} ชั่วโมง", + "custom_hours": "ชั่วโมงที่กำหนดเอง", + "logs": "บันทึก", + "developers": "นักพัฒนา", + "not_logged_in": "คุณไม่ได้เข้าสู่ระบบ", + "search_mode": "โหมดการค้นหา", + "audio_source": "แหล่งที่มาของเสียง", + "ok": "ตกลง", + "failed_to_encrypt": "เข้ารหัสล้มเหลว", + "encryption_failed_warning": "Spotube ใช้การเข้ารหัสเพื่อเก็บข้อมูลของคุณอย่างปลอดภัย แต่ไม่สามารถทำได้ ดังนั้นจะเปลี่ยนเป็นการจัดเก็บที่ไม่ปลอดภัย\nหากคุณใช้ Linux โปรดตรวจสอบว่าคุณได้ติดตั้งบริการลับ (gnome-keyring, kde-wallet, keepassxc เป็นต้น)", + "querying_info": "กำลังดึงข้อมูล...", + "piped_api_down": "Piped API ไม่ทำงาน", + "piped_down_error_instructions": "Piped instance {pipedInstance} ไม่ทำงานขณะนี้\n\nเปลี่ยนอินสแตนซ์หรือเปลี่ยน 'ประเภท API' เป็น YouTube API อย่างเป็นทางการ\n\nอย่าลืมรีสตาร์ทแอปหลังจากเปลี่ยน", + "you_are_offline": "คุณออฟไลน์อยู่", + "connection_restored": "การเชื่อมต่ออินเทอร์เน็ตของคุณได้รับการกู้คืน", + "use_system_title_bar": "ใช้แถบชื่อระบบ", + "crunching_results": "กำลังประมวลผล...", + "search_to_get_results": "ค้นหาเพื่อดูผลลัพธ์", + "use_amoled_mode": "ธีมมืดสนิท", + "pitch_dark_theme": "โหมด AMOLED", + "normalize_audio": "ปรับระดับเสียง", + "change_cover": "เปลี่ยนปก", + "add_cover": "เพิ่มปก", + "restore_defaults": "คืนค่าเริ่มต้น", + "download_music_codec": "ดาวน์โหลดโคเดคเพลง", + "streaming_music_codec": "สตรีมมิ่งโคเดคเพลง", + "login_with_lastfm": "เข้าสู่ระบบด้วย Last.fm", + "connect": "เชื่อมต่อ", + "disconnect_lastfm": "ตัดการเชื่อมต่อ Last.fm", + "disconnect": "ตัดการเชื่อมต่อ", + "username": "ชื่อผู้ใช้", + "password": "รหัสผ่าน", + "login": "เข้าสู่ระบบ", + "login_with_your_lastfm": "เข้าสู่ระบบด้วย Last.fm", + "scrobble_to_lastfm": "Scrobble ไปเป็น Last.fm", + "go_to_album": "ไปที่อัลบั้ม", + "discord_rich_presence": "Discord Rich Presence", + "browse_all": "เรียกดูทั้งหมด", + "genres": "ประเภท", + "explore_genres": "สำรวจประเภท", + "friends": "เพื่อน", + "no_lyrics_available": "ขออภัย ไม่พบเนื้อเพลงสำหรับเพลงนี้", + "start_a_radio": "เปิดวิทยุ", + "how_to_start_radio": "หากต้องการเปิดวิทยุฟังยังไง?", + "replace_queue_question": "คุณต้องการแทนที่คิวปัจจุบันหรือเพิ่มเข้าไปหรือไม่", + "endless_playback": "เล่นซ้ำ", + "delete_playlist": "ลบเพลย์ลิสต์", + "delete_playlist_confirmation": "คุณแน่ใจที่จะลบเพลย์ลิสต์นี้หรือไม่", + "local_tracks": "เพลงในเครื่อง", + "song_link": "ลิงค์เพลง", + "skip_this_nonsense": "ข้ามสิ่งไร้สาระนี้", + "freedom_of_music": "“เสรีภาพแห่งเสียงเพลง”", + "freedom_of_music_palm": "“เสรีภาพแห่งเสียงเพลง ในมือของคุณ”", + "get_started": "เริ่มต้น", + "youtube_source_description": "แนะนำและใช้งานได้ดีที่สุด", + "piped_source_description": "รู้สึกอิสระ? เหมือน YouTube แต่ฟรีกว่าเยอะ", + "jiosaavn_source_description": "ดีที่สุดสำหรับภูมิภาคเอเชียใต้", + "highest_quality": "คุณภาพสูงสุด: {quality}", + "select_audio_source": "เลือกแหล่งเสียง", + "endless_playback_description": "เพิ่มเพลงใหม่ลงในคิวโดยอัตโนมัติ", + "choose_your_region": "เลือกภูมิภาคของคุณ", + "choose_your_region_description": "สิ่งนี้จะช่วยให้ Spotube แสดงเนื้อหาที่เหมาะสมสำหรับคุณ", + "choose_ your_language": "เลือกภาษาของคุณ", + "help_project_grow": "ช่วยให้โครงการนี้เติบโต", + "help_project_grow_description": "Spotube เป็นโครงการโอเพนซอร์ส คุณสามารถช่วยให้โครงการนี้เติบโตได้โดยการมีส่วนร่วมในโครงการ รายงานข้อบกพร่อง หรือเสนอคุณสมบัติใหม่", + "contribute_on_github": "มีส่วนร่วมบน GitHub", + "donate_on_open_collective": "บริจาคบน Open Collective", + "browse_anonymously": "เรียกดูแบบไม่ระบุตัวตน" +} \ No newline at end of file diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 4d9066fd..ee7562ef 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -1,63 +1,64 @@ { "guest": "Misafir", - "browse": "Gözat", + "browse": "Göz at", "search": "Ara", "library": "Kütüphane", - "lyrics": "Sözler", + "lyrics": "Şarkı Sözleri", "settings": "Ayarlar", - "genre_categories_filter": "Kategorileri veya türleri filtreleyin...", + "genre_categories_filter": "Kategorileri veya türleri filtrele...", "genre": "Tür", "personalized": "Kişiselleştirilmiş", "featured": "Öne Çıkanlar", "new_releases": "Yeni Çıkanlar", "songs": "Şarkılar", - "playing_track": "Oynatılıyor {track}", - "queue_clear_alert": "Bu, mevcut kuyruğu temizleyecektir. {track_length} parçaları kaldırılacaktır\nDevam etmek istiyor musunuz?", + "playing_track": "{track} oynatılıyor", + "queue_clear_alert": "Bu, mevcut kuyruğu temizleyecektir. {track_length} parça kaldırılacak\nDevam etmek istiyor musunuz?", "load_more": "Daha fazlasını yükle", - "playlists": "Çalma Listeleri", + "playlists": "Oynatma listeleri", "artists": "Sanatçılar", "albums": "Albümler", "tracks": "Parçalar", - "downloads": "İndirmeler", - "filter_playlists": "Çalma listelerinizi filtreleyin...", + "downloads": "İndirilenler", + "filter_playlists": "Oynatma listelerinizi filtreleyin...", "liked_tracks": "Beğenilen Parçalar", "liked_tracks_description": "Beğendiğiniz tüm parçalar", - "create_playlist": "Çalma Listesi Oluştur", - "create_a_playlist": "Bir çalma listesi oluştur", - "update_playlist": "Çalma listesini güncelle", + "create_playlist": "Oynatma Listesi Oluştur", + "create_a_playlist": "Bir oynatma listesi oluşturun", + "update_playlist": "Oynatma listesini güncelle", "create": "Oluştur", "cancel": "İptal", "update": "Güncelle", - "playlist_name": "Çalma Listesi Adı", - "name_of_playlist": "Çalma listesi adı", + "playlist_name": "Oynatma Listesi Adı", + "name_of_playlist": "Oynatma listesinin adı", "description": "Açıklama", "public": "Halka açık", "collaborative": "İşbirliği", - "search_local_tracks": "Yerel parçaları arayın...", + "search_local_tracks": "Yerel parçaları ara...", "play": "Oynat", "delete": "Sil", - "none": "Hiçbiri", - "sort_a_z": "A'dan Z'ye sırala", - "sort_z_a": "Z'dan A'ye sırala", + "none": "Yok", + "sort_a_z": "A - Z'ye göre sırala", + "sort_z_a": "Z - A'ya göre sırala", "sort_artist": "Sanatçıya Göre Sırala", "sort_album": "Albüme Göre Sırala", + "sort_duration": "Süreye Göre Sırala", "sort_tracks": "Parçaları Sırala", - "currently_downloading": "Şu Anda İndiriliyor ({tracks_length})", + "currently_downloading": "Şu An İndirilenler ({tracks_length})", "cancel_all": "Tümünü İptal Et", "filter_artist": "Sanatçıları filtrele...", "followers": "{followers} Takipçiler", "add_artist_to_blacklist": "Sanatçıyı kara listeye ekle", "top_tracks": "En İyi Parçalar", - "fans_also_like": "Hayranlar ayrıca şunları beğendi", + "fans_also_like": "Hayranlar ayrıca şunları da beğendi", "loading": "Yükleniyor...", "artist": "Sanatçı", - "blacklisted": "Kara Listede", - "following": "Takip Ediliyor", - "follow": "Takip Et", + "blacklisted": "Kara listeye alındı", + "following": "Takip ediliyor", + "follow": "Takip et", "artist_url_copied": "Sanatçı bağlantısı panoya kopyalandı", - "added_to_queue": "Kuyruğa {tracks} parçaları eklendi", + "added_to_queue": "Kuyruğa {tracks} parçası eklendi", "filter_albums": "Albümleri filtrele...", - "synced": "Eşitlendi", + "synced": "Senkronize edildi", "plain": "Sade", "shuffle": "Karıştır", "search_tracks": "Parça ara...", @@ -65,56 +66,56 @@ "error": "Hata {error}", "title": "Başlık", "time": "Zaman", - "more_actions": "Daha fazla işlem", + "more_actions": "Daha fazla eylem", "download_count": "İndir ({count})", - "add_count_to_playlist": "Çalma Listesine ({count}) Ekle", - "add_count_to_queue": "Sıraya ({count}) ekle", - "play_count_next": "Oynat ({count}) sonraki", + "add_count_to_playlist": "Oynatma Listesine ({count}) ekle", + "add_count_to_queue": "Kuyruğa ({count}) ekle", + "play_count_next": "({count}) sonrakini oynat", "album": "Albüm", - "copied_to_clipboard": "Panoya {data} kopyalandı", - "add_to_following_playlists": "Aşağıdaki Çalma Listelerine {track} ekle", + "copied_to_clipboard": "{data} panoya kopyalandı", + "add_to_following_playlists": "{track} parçasını aşağıdaki Oynatma Listelerine ekle", "add": "Ekle", - "added_track_to_queue": "Sıraya {track} eklendi", + "added_track_to_queue": "{track} kuyruğa eklendi", "add_to_queue": "Kuyruğa ekle", - "track_will_play_next": "{track} sonraki çalacak", - "play_next": "Sıradaki", - "removed_track_from_queue": "Sıradan {track} kaldırıldı", - "remove_from_queue": "Kuyruktan çıkar", + "track_will_play_next": "{track} bir sonraki çalacak", + "play_next": "Sonrakini oynat", + "removed_track_from_queue": "{track} sıradan kaldırıldı", + "remove_from_queue": "Sıradan kaldır", "remove_from_favorites": "Favorilerden kaldır", "save_as_favorite": "Favori olarak kaydet", - "add_to_playlist": "Çalma listesine ekle", - "remove_from_playlist": "Çalma listesinden kaldır", + "add_to_playlist": "Oynatma listesine ekle", + "remove_from_playlist": "Oynatma listesinden kaldır", "add_to_blacklist": "Kara listeye ekle", - "remove_from_blacklist": "Kara listeden çıkar", + "remove_from_blacklist": "Kara listeden kaldır", "share": "Paylaş", "mini_player": "Mini Oynatıcı", "slide_to_seek": "İleri veya geri arama yapmak için kaydırın", - "shuffle_playlist": "Çalma listesini karıştır", - "unshuffle_playlist": "Karışık çalma listesi", + "shuffle_playlist": "Oynatma listesini karıştır", + "unshuffle_playlist": "Oynatma listesinin karışıklığını kaldır", "previous_track": "Önceki parça", "next_track": "Sonraki parça", - "pause_playback": "Çalmayı Duraklat", - "resume_playback": "Çalmaya Devam Et", + "pause_playback": "Oynatmayı duraklat", + "resume_playback": "Oynatmayı sürdür", "loop_track": "Döngü parçası", - "repeat_playlist": "Çalma listesini tekrarla", + "repeat_playlist": "Oynatma listesini tekrarla", "queue": "Sıra", - "alternative_track_sources": "Alternatif parça kaynakları", + "alternative_track_sources": "Alternatif yol kaynakları", "download_track": "Parçayı indir", - "tracks_in_queue": "{tracks} sıradaki parçalar", + "tracks_in_queue": "{tracks} parça sırada", "clear_all": "Tümünü temizle", - "show_hide_ui_on_hover": "Üzerine gelindiğinde kullanıcı arayüzünü göster/gizle", - "always_on_top": "Her zaman en üstte", + "show_hide_ui_on_hover": "Fareyle üzerine gelindiğinde kullanıcı arayüzünü göster/gizle", + "always_on_top": "Her zaman üstte", "exit_mini_player": "Mini oynatıcıdan çık", "download_location": "İndirme konumu", "account": "Hesap", - "login_with_spotify": "Spotify hesabınız ile giriş yapın", - "connect_with_spotify": "Spotify ile bağlantı kurun", + "login_with_spotify": "Spotify hesabınızla giriş yapın", + "connect_with_spotify": "Spotify ile bağlan", "logout": "Çıkış Yap", "logout_of_this_account": "Bu hesaptan çıkış yap", - "language_region": "Dil & Bölge", + "language_region": "Dil ve Bölge", "language": "Dil", "system_default": "Sistem Varsayılanı", - "market_place_region": "Mevcut Bölge", + "market_place_region": "Pazaryeri Bölgesi", "recommendation_country": "Tavsiye Edilen Ülke", "appearance": "Görünüm", "layout_mode": "Düzen Modu", @@ -123,23 +124,23 @@ "compact": "Sıkıştırılmış", "extended": "Genişletilmiş", "theme": "Tema", - "dark": "Karanlık", - "light": "Aydınlık", + "dark": "Koyu", + "light": "Açık", "system": "Sistem", "accent_color": "Vurgu Rengi", - "sync_album_color": "Albüm rengini eşitle", - "sync_album_color_description": "Albüm resminin baskın rengini vurgu rengi olarak kullanır", - "playback": "Çalma", + "sync_album_color": "Albüm rengini senkronize et", + "sync_album_color_description": "Vurgu rengi olarak albüm resminin baskın rengini kullanır", + "playback": "Oynatma", "audio_quality": "Ses Kalitesi", "high": "Yüksek", "low": "Düşük", - "pre_download_play": "Önceden indir ve oynat", - "pre_download_play_description": "Ses akışı yerine, baytları indirin ve oynatın (Daha yüksek bant genişliği kullanıcıları için önerilir)", + "pre_download_play": "Ön yükleme ve oynatma", + "pre_download_play_description": "Ses akışı yerine baytları indirin ve oynatın (Daha yüksek bant genişliğine sahip kullanıcılar için önerilir)", "skip_non_music": "Müzik olmayan bölümleri atla (SponsorBlock)", "blacklist_description": "Kara listeye alınan parçalar ve sanatçılar", - "wait_for_download_to_finish": "Lütfen mevcut indirme işleminin bitmesini bekleyin", + "wait_for_download_to_finish": "Lütfen mevcut indirme işleminin tamamlanmasını bekleyin", "desktop": "Masaüstü", - "close_behavior": "Yakın Davranış", + "close_behavior": "Kapatma Davranışı", "close": "Kapat", "minimize_to_tray": "Tepsiye küçült", "show_tray_icon": "Sistem tepsisi simgesini göster", @@ -147,24 +148,24 @@ "u_love_spotube": "Spotube'u sevdiğinizi biliyoruz", "check_for_updates": "Güncellemeleri kontrol et", "about_spotube": "Spotube Hakkında", - "blacklist": "Kara Liste", - "please_sponsor": "Lütfen Sponsor Olun/Bağış Yapın", - "spotube_description": "Spotube, hafif, platformlar arası, herkesin kullanabileceği ücretsiz bir Spotify istemcisidir.", + "blacklist": "Kara liste", + "please_sponsor": "Sponsor Ol/Bağış Yap", + "spotube_description": "Spotube, hafif, platformlar arası, herkes için ücretsiz bir spotify istemcisidir", "version": "Sürüm", "build_number": "Derleme Numarası", "founder": "Kurucu", "repository": "Depo", - "bug_issues": "Hata + Sorunlar", - "made_with": "❤️ ile Bangladesh🇧🇩 adresinde yapılmıştır.", + "bug_issues": "Hata+Sorunlar", + "made_with": "❤️ ile Bangladeş'te yapıldı", "kingkor_roy_tirtho": "Kingkor Roy Tirtho", "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", "license": "Lisans", - "add_spotify_credentials": "Başlamak için spotify bilgilerinizi ekleyin", - "credentials_will_not_be_shared_disclaimer": "Endişelenmeyin, bilgileriniz toplanmayacak veya kimseyle paylaşılmayacak", - "know_how_to_login": "Nasıl yapılacağını bilmiyor musunuz?", + "add_spotify_credentials": "Başlamak için spotify kimlik bilgilerinizi ekleyin", + "credentials_will_not_be_shared_disclaimer": "Endişelenmeyin, kimlik bilgilerinizden hiçbiri toplanmayacak veya kimseyle paylaşılmayacak", + "know_how_to_login": "Bunu nasıl yapacağınızı bilmiyor musunuz?", "follow_step_by_step_guide": "Adım Adım kılavuzu takip edin", - "spotify_cookie": "Spotify {name} Çerez", - "cookie_name_cookie": "{name} Çerez", + "spotify_cookie": "Spotify {name} Çerezi", + "cookie_name_cookie": "{name} Çerezi", "fill_in_all_fields": "Lütfen tüm alanları doldurun", "submit": "Gönder", "exit": "Çık", @@ -172,38 +173,40 @@ "next": "Sonraki", "done": "Bitti", "step_1": "1. Adım", - "first_go_to": "İlk önce şu adrese gidin", - "login_if_not_logged_in": "ve oturum açmadıysanız Giriş Yapın/Kaydolun", + "first_go_to": "İlk olarak şuraya gidin:", + "login_if_not_logged_in": "ve oturum açmadıysanız Oturum Açın/Kaydolun", "step_2": "2. Adım", - "step_2_steps": "1. Giriş yaptıktan sonra, Tarayıcı devtools.\n2'yi açmak için F12'ye basın veya Fare Sağ Tıklaması > İncele'ye basın. Ardından \"Uygulama\" Sekmesine (Chrome, Edge, Brave vb.) veya \"Depolama\" Sekmesine (Firefox, Palemoon vb.) gidin\n3. \"Çerezler\" bölümüne ve ardından \"https://accounts.spotify.com\" alt bölümüne gidin", + "step_2_steps": "1. Giriş yaptıktan sonra, Tarayıcı geliştirme araçlarını açmak için F12 veya Fareye Sağ Tıklayın > İncele'ye basın.\n2. Ardından \"Uygulama\" Sekmesine (Chrome, Edge, Brave vb.) veya \"Depolama\" Sekmesine (Firefox, Palemoon vb.) gidin.\n3. \"Çerezler\" bölümüne ve ardından \"https://accounts.spotify.com\" alt bölümüne gidin", "step_3": "3. Adım", + "step_3_steps": "\"sp_dc\" Çerezinin değerini kopyalayın", "success_emoji": "Başarılı🥳", - "success_message": "Şimdi Spotify hesabınızla başarılı bir şekilde oturum açtınız. İyi iş, dostum!", + "success_message": "Artık Spotify hesabınızla başarıyla giriş yaptınız. Aferin, dostum!", "step_4": "4. Adım", - "something_went_wrong": "Bir şeyler ters gitti", + "step_4_steps": "Kopyalanan \"sp_dc\" değerini yapıştırın", + "something_went_wrong": "Bir hata oluştu", "piped_instance": "Piped Sunucu Örneği", "piped_description": "Parça eşleştirme için kullanılacak Piped sunucu örneği", - "piped_warning": "Bazıları iyi çalışmayabilir. Bu yüzden riski size ait olmak üzere kullanın", - "generate_playlist": "Çalma Listesi Oluştur", - "track_exists": "Track {track} zaten mevcut", + "piped_warning": "Bazıları iyi çalışmayabilir. Yani riski size ait olmak üzere kullanın", + "generate_playlist": "Oynatma Listesi Oluştur", + "track_exists": "{track} parçası zaten var", "replace_downloaded_tracks": "İndirilen tüm parçaları değiştir", "skip_download_tracks": "İndirilen tüm parçaları indirmeyi atla", - "do_you_want_to_replace": "Mevcut parçayı değiştirmek mi istiyorsunuz?", + "do_you_want_to_replace": "Mevcut parçayı değiştirmek istiyor musunuz?", "replace": "Değiştir", "skip": "Atla", "select_up_to_count_type": "En fazla {count} {type} seçin", - "select_genres": "Tür Seç", + "select_genres": "Türleri Seç", "add_genres": "Tür Ekle", "country": "Ülke", "number_of_tracks_generate": "Oluşturulacak parça sayısı", "acousticness": "Akustiklik", - "danceability": "Dansedilebilirlik", + "danceability": "Dans Edilebilirlik", "energy": "Enerji", - "instrumentalness": "Enstrümansallık", + "instrumentalness": "Araçsallık", "liveness": "Canlılık", - "loudness": "Yükseklik", + "loudness": "Ses yüksekliği", "speechiness": "Konuşkanlık", - "valence": "Değerlilik", + "valence": "Değerlik", "popularity": "Popülerlik", "key": "Anahtar", "duration": "Süre (sn)", @@ -220,30 +223,30 @@ "deselect_all": "Tüm Seçimleri Kaldır", "select_all": "Tümünü Seç", "are_you_sure": "Emin misiniz?", - "generating_playlist": "Özel çalma listenizi oluşturun...", - "selected_count_tracks": "Seçilen {count} parçalar", - "download_warning": "Tüm Parçaları toplu olarak indirirseniz, açıkça Müzik korsanlığı yapmış ve yaratıcı Müzik toplumuna zarar vermiş olursunuz. Umarım bunun farkındasınızdır. Her zaman, Sanatçıların sıkı çalışmalarına saygı duymayı ve desteklemeyi deneyin", - "download_ip_ban_warning": "Bu arada, normalden fazla indirme isteği nedeniyle IP adresiniz YouTube'da engellenebilir. IP engeli, o IP cihazından en az 2-3 ay boyunca YouTube'u (giriş yapmış olsanız bile) kullanamayacağınız anlamına gelir. Ve Spotube böyle bir durumda herhangi bir sorumluluk kabul etmez", - "by_clicking_accept_terms": "'Kabul et' seçeneğine tıklayarak aşağıdaki şartları kabul etmiş olursunuz:", - "download_agreement_1": "Müzik korsanlığı yaptığımı biliyorum. Ben malım.", - "download_agreement_2": "Sanatçıları elimden geldiğince destekleyeceğim ve bunu sadece sanatlarını satın alacak param olmadığı için yapıyorum", - "download_agreement_3": "IP adresimin YouTube'da engellenebileceğinin tamamen farkındayım ve mevcut eylemimin neden olduğu herhangi bir kazadan Spotube'u veya sahiplerini/dağıtıcılarını sorumlu tutmuyorum", + "generating_playlist": "Özel oynatma listeniz oluşturuluyor...", + "selected_count_tracks": "{count} parça seçildi", + "download_warning": "Tüm Parçaları toplu olarak indirirseniz, açıkça Müzik korsanlığı yapıyor ve Müziğin yaratıcı toplumuna zarar veriyorsunuz demektir. Umarım bunun farkındasınızdır. Her zaman, Sanatçının sıkı çalışmasına saygı duymaya ve desteklemeye çalışın", + "download_ip_ban_warning": "Bu arada, IP'niz normalden daha fazla indirme isteği nedeniyle YouTube'da engellenebilir. IP engelleme, YouTube'u (oturum açmış olsanız bile) o IP cihazından en az 2 -3 ay kullanamayacağınız anlamına gelir. Ve eğer böyle bir şey olursa Spotube'un hiçbir sorumluluğu yok", + "by_clicking_accept_terms": "\"Kabul et\" e tıklayarak aşağıdaki şartları kabul etmiş olursunuz:", + "download_agreement_1": "Müzik korsanlığı yaptığımı biliyorum. Ben kötüyüm", + "download_agreement_2": "Sanatçıyı elimden geldiğince destekleyeceğim ve bunu sadece sanatını satın alacak param olmadığı için yapıyorum", + "download_agreement_3": "IP adresimin YouTube'da engellenebileceğinin tamamen farkındayım ve mevcut işlemimden kaynaklanan herhangi bir kazadan Spotube'u veya sahiplerini/katkıda bulunanlarını sorumlu tutmuyorum", "decline": "Reddet", "accept": "Kabul et", "details": "Detaylar", "youtube": "YouTube", "channel": "Kanal", "likes": "Beğeniler", - "dislikes": "Beğenmemeler", + "dislikes": "Beğenmeyenler", "views": "İzlenmeler", - "streamUrl": "Yayın Bağlantısı", - "stop": "Dur", - "sort_newest": "En yeni eklenene göre sırala", - "sort_oldest": "En eski eklenene göre sırala", + "streamUrl": "Akış bağlantısı", + "stop": "Durdur", + "sort_newest": "En yeniye göre sırala", + "sort_oldest": "Eklenen en eskiye göre sırala", "sleep_timer": "Uyku Zamanlayıcısı", - "mins": "{minutes} Dakikalar", - "hours": "{hours} Saat", - "hour": "{hours} Saatler", + "mins": "{minutes} Dakika", + "hours": "{hours} Saatler", + "hour": "{hours} Saat", "custom_hours": "Özel Saatler", "logs": "Günlükler", "developers": "Geliştiriciler", @@ -252,39 +255,70 @@ "audio_source": "Ses Kaynağı", "ok": "Tamam", "failed_to_encrypt": "Şifreleme başarısız oldu", - "encryption_failed_warning": "Spotube, verilerinizi güvenli bir şekilde depolamak için şifreleme kullanır. Ancak bunu başaramadı. Bu nedenle güvensiz bir depolamaya geri dönecektir. Linux kullanıyorsanız, lütfen gnome-keyring, kde-wallet, keepassxc vb. gibi bir güvenlik hizmetinizin kurulu olduğundan emin olun.", + "encryption_failed_warning": "Spotube, verilerinizi güvenli bir şekilde saklamak için şifreleme kullanır. Ama başaramadı. Bu yüzden güvensiz depolamaya geri dönecek\nLinux kullanıyorsanız, lütfen herhangi bir gizli servisin (gnome - anahtarlık, kde - cüzdan, keepassxc vb.) yüklü olduğundan emin olun", "querying_info": "Bilgi sorgulanıyor...", "piped_api_down": "Piped API kapalı", - "piped_down_error_instructions": "Piped örneği {pipedInstance} şu anda kapalı\n\nYa örneği değiştirin ya da 'API türünü' resmi YouTube API'si olarak değiştirin\n\nDeğişiklikten sonra uygulamayı yeniden başlattığınızdan emin olun", + "piped_down_error_instructions": "Piped örneği {pipedInstance} şu anda kapalı\n\nÖrneği değiştirin veya 'API türünü' resmi YouTube API'si olarak değiştirin\n\nDeğişiklikten sonra uygulamayı yeniden başlattığınızdan emin olun", "you_are_offline": "Şu anda çevrimdışısınız", - "connection_restored": "İnternet bağlantınız yeniden kuruldu", + "connection_restored": "İnternet bağlantınız geri yüklendi", "use_system_title_bar": "Sistem başlık çubuğunu kullan", - "crunching_results": "Sonuçlar kırılıyor...", - "search_to_get_results": "Sonuç almak için arama yap", - "use_amoled_mode": "AMOLED modunu kullan", - "pitch_dark_theme": "Zifiri siyah dart teması", + "crunching_results": "Sonuçlar...", + "search_to_get_results": "Sonuç almak için ara", + "use_amoled_mode": "AMOLED Modunu Kullan", + "pitch_dark_theme": "Zifiri karanlık koyu tema", "normalize_audio": "Sesi normalleştir", "change_cover": "Kapağı değiştir", "add_cover": "Kapak ekle", "restore_defaults": "Varsayılanları geri yükle", - "download_music_codec": "Müzik codec bileşenini indirin", - "streaming_music_codec": "Müzik akışı codec bileşeni", + "download_music_codec": "Müzik codec bileşenini indir", + "streaming_music_codec": "Müzik codec'i akışı", "login_with_lastfm": "Last.fm ile giriş yap", "connect": "Bağlan", "disconnect_lastfm": "Last.fm bağlantısını kes", - "disconnect": "Bağlantıyı Kes", - "username": "Kullanıcı Adı", - "password": "Şifre", - "login": "Giriş Yap", - "login_with_your_lastfm": "Last.fm hesabınız ile giriş yapın", + "disconnect": "Bağlantıyı kes", + "username": "Kullanıcı adı", + "password": "Parola", + "login": "Giriş", + "login_with_your_lastfm": "Last.fm hesabınızla giriş yapın", "scrobble_to_lastfm": "Last.fm için Scrobble", "go_to_album": "Albüme Git", - "discord_rich_presence": "Discord Zengin Varlık", - "browse_all": "Tümünü Gözat", + "discord_rich_presence": "Discord Zengin Varlığı", + "browse_all": "Tümüne Göz At", "genres": "Müzik Türleri", "explore_genres": "Türleri Keşfet", - "step_3_steps": "\"sp_dc\" Çerezinin değerini kopyala", - "step_4_steps": "Kopyalanan \"sp_dc\" değerini yapıştır", "friends": "Arkadaşlar", - "no_lyrics_available": "Üzgünüz, bu parça için şarkı sözleri bulunamıyor" + "no_lyrics_available": "Üzgünüz, bu parçanın sözleri bulunamıyor", + "start_a_radio": "Radyo Başlat", + "how_to_start_radio": "Radyoyu nasıl başlatmak istersiniz?", + "replace_queue_question": "Mevcut kuyruğu değiştirmek mi yoksa eklemek mi istersiniz?", + "endless_playback": "Sonsuz Olarak Oynat", + "delete_playlist": "Oynatma Listesini Sil", + "delete_playlist_confirmation": "Bu oynatma listesini silmek istediğinizden emin misiniz?", + "local_tracks": "Yerel Parçalar", + "song_link": "Şarkı Bağlantısı", + "skip_this_nonsense": "Bu saçmalığı atla", + "freedom_of_music": "“Müzik Özgürlüğü”", + "freedom_of_music_palm": "“Müzik Özgürlüğü avucunuzun içinde”", + "get_started": "Haydi başlayalım", + "youtube_source_description": "Tavsiye edilir ve en iyi şekilde çalışır.", + "piped_source_description": "Özgür hissediyor musunuz? YouTube ile aynı ama çok daha fazla ücretsiz.", + "jiosaavn_source_description": "Güney Asya bölgesi için en iyisi.", + "highest_quality": "En Yüksek Kalite: {quality}", + "select_audio_source": "Ses Kaynağını Seç", + "endless_playback_description": "Yeni şarkıları otomatik olarak \nkuyruğun sonuna ekle", + "choose_your_region": "Bölgenizi seçin", + "choose_your_region_description": "Bu, Spotube'un size doğru içeriği göstermesine yardımcı olacaktır\nkonumunuz için.", + "choose_your_language": "Dilinizi seçin", + "help_project_grow": "Bu projenin büyümesine yardımcı ol", + "help_project_grow_description": "Spotube açık kaynaklı bir projedir. Projeye katkıda bulunarak, hataları bildirerek veya yeni özellikler önererek bu projenin büyümesine yardımcı olabilirsiniz.", + "contribute_on_github": "GitHub'a katkıda bulunun", + "donate_on_open_collective": "Open Collective'e bağış yap", + "browse_anonymously": "Anonim Olarak Göz at" + "enable_connect": "Bağlantıyı Etkinleştir", + "enable_connect_description": "Spotube'u diğer cihazlardan kontrol edin", + "devices": "Cihazlar", + "select": "Seç", + "connect_client_alert": "{client} tarafından kontrol ediliyorsun.", + "this_device": "Bu Cihaz", + "remote": "Yönet" } \ No newline at end of file diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index a4586a5e..fe57e617 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -286,5 +286,32 @@ "step_3_steps": "Скопіюйте значення cookie \"sp_dc\"", "step_4_steps": "Вставте скопійоване значення \"sp_dc\"", "friends": "Друзі", - "no_lyrics_available": "Вибачте, не вдалося знайти текст для цього треку" + "no_lyrics_available": "Вибачте, не вдалося знайти текст для цього треку", + "sort_duration": "Сортувати за тривалістю", + "start_a_radio": "Запустити радіо", + "how_to_start_radio": "Як ви хочете запустити радіо?", + "replace_queue_question": "Ви хочете замінити поточну чергу чи додати до неї?", + "endless_playback": "Безкінечне відтворення", + "delete_playlist": "Видалити плейлист", + "delete_playlist_confirmation": "Ви впевнені, що хочете видалити цей плейлист?", + "local_tracks": "Місцеві треки", + "song_link": "Посилання на пісню", + "skip_this_nonsense": "Пропустити цей бред", + "freedom_of_music": "“Свобода музики”", + "freedom_of_music_palm": "“Свобода музики у вашій долоні”", + "get_started": "Давайте почнемо", + "youtube_source_description": "Рекомендовано та працює краще за все.", + "piped_source_description": "Чи почуваєте себе вільно? Те саме, що і на YouTube, але набагато безкоштовно.", + "jiosaavn_source_description": "Найкраще для регіону Південної Азії.", + "highest_quality": "Найвища якість: {quality}", + "select_audio_source": "Виберіть джерело аудіо", + "endless_playback_description": "Автоматично додавати нові пісні\nв кінець черги", + "choose_your_region": "Виберіть ваш регіон", + "choose_your_region_description": "Це допоможе Spotube показати вам правильний контент\nдля вашого місцезнаходження.", + "choose_your_language": "Виберіть свою мову", + "help_project_grow": "Допоможіть цьому проекту рости", + "help_project_grow_description": "Spotube - це проект з відкритим кодом. Ви можете допомогти цьому проекту зростати, вносячи свій внесок у проект, повідомляючи про помилки або пропонуючи нові функції.", + "contribute_on_github": "Долучайтесь на GitHub", + "donate_on_open_collective": "Пожертвуйте на Open Collective", + "browse_anonymously": "Анонімно переглядати" } \ No newline at end of file diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index d8d337c2..0e9b0b7c 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -284,5 +284,32 @@ "discord_rich_presence": "Hiển thị trạng thái Discord", "browse_all": "Duyệt tất cả", "genres": "Thể loại", - "explore_genres": "Khám phá Thể loại" -} + "explore_genres": "Khám phá Thể loại", + "sort_duration": "Sắp xếp theo Thời lượng", + "start_a_radio": "Bắt đầu Một Đài phát thanh", + "how_to_start_radio": "Bạn muốn bắt đầu đài phát thanh như thế nào?", + "replace_queue_question": "Bạn muốn thay thế hàng đợi hiện tại hay thêm vào?", + "endless_playback": "Phát không giới hạn", + "delete_playlist": "Xóa Danh sách phát", + "delete_playlist_confirmation": "Bạn có chắc chắn muốn xóa danh sách phát này không?", + "local_tracks": "Bài hát Địa phương", + "song_link": "Liên kết Bài hát", + "skip_this_nonsense": "Bỏ qua bớt rối này", + "freedom_of_music": "“Sự Tự do của Âm nhạc”", + "freedom_of_music_palm": "“Sự Tự do của Âm nhạc trong lòng bàn tay của bạn”", + "get_started": "Bắt đầu thôi", + "youtube_source_description": "Được đề xuất và hoạt động tốt nhất.", + "piped_source_description": "Cảm thấy tự do? Giống như YouTube nhưng miễn phí hơn rất nhiều.", + "jiosaavn_source_description": "Tốt nhất cho khu vực Nam Á.", + "highest_quality": "Chất lượng Tốt nhất: {quality}", + "select_audio_source": "Chọn Nguồn Âm thanh", + "endless_playback_description": "Tự động thêm các bài hát mới\nvào cuối hàng đợi", + "choose_your_region": "Chọn khu vực của bạn", + "choose_your_region_description": "Điều này sẽ giúp Spotube hiển thị nội dung phù hợp cho vị trí của bạn.", + "choose_your_language": "Chọn ngôn ngữ của bạn", + "help_project_grow": "Hãy giúp dự án này phát triển", + "help_project_grow_description": "Spotube là một dự án mã nguồn mở. Bạn có thể giúp dự án này phát triển bằng cách đóng góp vào dự án, báo cáo lỗi hoặc đề xuất tính năng mới.", + "contribute_on_github": "Đóng góp trên GitHub", + "donate_on_open_collective": "Quyên góp trên Open Collective", + "browse_anonymously": "Duyệt Anonymously" +} \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 20fdb329..506661f0 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -286,5 +286,32 @@ "step_3_steps": "复制\"sp_dc\" Cookie的值", "step_4_steps": "粘贴复制的\"sp_dc\"值", "friends": "朋友", - "no_lyrics_available": "抱歉,无法找到此曲的歌词" + "no_lyrics_available": "抱歉,无法找到此曲的歌词", + "sort_duration": "按时长排序", + "start_a_radio": "开始收听电台", + "how_to_start_radio": "您想如何开始收听电台?", + "replace_queue_question": "您想要替换当前队列还是追加到队列?", + "endless_playback": "无尽播放", + "delete_playlist": "删除播放列表", + "delete_playlist_confirmation": "您确定要删除此播放列表吗?", + "local_tracks": "本地音轨", + "song_link": "歌曲链接", + "skip_this_nonsense": "跳过此无聊内容", + "freedom_of_music": "“音乐的自由”", + "freedom_of_music_palm": "“音乐的自由掌握在您手中”", + "get_started": "让我们开始吧", + "youtube_source_description": "推荐并且效果最佳。", + "piped_source_description": "感觉自由?与YouTube一样但更自由。", + "jiosaavn_source_description": "最适合南亚地区。", + "highest_quality": "最高音质:{quality}", + "select_audio_source": "选择音频源", + "endless_playback_description": "自动将新歌曲添加到队列的末尾", + "choose_your_region": "选择您的地区", + "choose_your_region_description": "这将帮助Spotube为您的位置显示正确的内容。", + "choose_your_language": "选择您的语言", + "help_project_grow": "帮助这个项目成长", + "help_project_grow_description": "Spotube是一个开源项目。您可以通过为项目做出贡献、报告错误或建议新功能来帮助该项目成长。", + "contribute_on_github": "在GitHub上做出贡献", + "donate_on_open_collective": "在Open Collective上捐款", + "browse_anonymously": "匿名浏览" } \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 364e542a..180d2ec6 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -7,9 +7,12 @@ /// TexturedPolak@github => Polish /// yuri-val@github => Ukrainian /// energywave@github, ncvescera@github, OpenCode@github => Italian -/// mdksec@github => Turkish +/// mdksec@github, mikropsoft@github => Turkish /// Stephan-P@github, SecularSteve@github => Dutch /// doannc2212@github => Vietnamese +/// sappho192@github => Korean +/// watchakorn-18k@github => Thai + import 'package:flutter/material.dart'; class L10n { @@ -20,19 +23,21 @@ class L10n { const Locale('ca', 'AD'), const Locale('de', 'GE'), const Locale('es', 'ES'), - const Locale("fa", "IR"), + const Locale('fa', 'IR'), const Locale('fr', 'FR'), const Locale('ne', 'NP'), const Locale('hi', 'IN'), const Locale('it', 'IT'), const Locale('ja', 'JP'), + const Locale('ko', 'KR'), const Locale('nl', 'NL'), const Locale('pl', 'PL'), const Locale('pt', 'PT'), const Locale('ru', 'RU'), const Locale('uk', 'UA'), + const Locale('th', 'TH'), const Locale('tr', 'TR'), const Locale('zh', 'CN'), const Locale('vi', 'VN'), ]; -} +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 31c1da57..8de524c7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,6 @@ import 'package:catcher_2/catcher_2.dart'; import 'package:dart_discord_rpc/dart_discord_rpc.dart'; import 'package:device_preview/device_preview.dart'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -24,11 +23,12 @@ import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/models/source_match.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/cli/cli.dart'; -import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/themes/theme.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; @@ -70,16 +70,11 @@ Future main(List rawArgs) async { } await KVStoreService.initialize(); - KVStoreService.doneGettingStarted = false; final hiveCacheDir = kIsWeb ? null : (await getApplicationSupportDirectory()).path; - await QueryClient.initialize( - cachePrefix: "oss.krtirtho.spotube", - cacheDir: hiveCacheDir, - connectivity: FlQueryInternetConnectionCheckerAdapter(), - ); + Hive.init(hiveCacheDir); Hive.registerAdapter(SkipSegmentAdapter()); @@ -136,21 +131,18 @@ Future main(List rawArgs) async { ), runAppFunction: () { runApp( - DevicePreview( - availableLocales: L10n.all, - enabled: false, - data: const DevicePreviewData( - isEnabled: false, - orientation: Orientation.portrait, + ProviderScope( + child: DevicePreview( + availableLocales: L10n.all, + enabled: false, + data: const DevicePreviewData( + isEnabled: false, + orientation: Orientation.portrait, + ), + builder: (context) { + return const Spotube(); + }, ), - builder: (context) { - return ProviderScope( - child: QueryClientProvider( - staleDuration: const Duration(minutes: 30), - child: const Spotube(), - ), - ); - }, ), ); }, @@ -158,7 +150,7 @@ Future main(List rawArgs) async { } class Spotube extends StatefulHookConsumerWidget { - const Spotube({Key? key}) : super(key: key); + const Spotube({super.key}); @override SpotubeState createState() => SpotubeState(); @@ -190,6 +182,9 @@ class SpotubeState extends ConsumerState { ref.watch(paletteProvider.select((s) => s?.dominantColor?.color)); final router = ref.watch(routerProvider); + ref.listen(connectServerProvider, (_, __) {}); + ref.listen(connectClientsProvider, (_, __) {}); + useDisableBatteryOptimizations(); useInitSysTray(ref); useDeepLinking(ref); diff --git a/lib/models/connect/connect.dart b/lib/models/connect/connect.dart new file mode 100644 index 00000000..efb37315 --- /dev/null +++ b/lib/models/connect/connect.dart @@ -0,0 +1,16 @@ +library connect; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; +import 'package:spotube/services/audio_player/loop_mode.dart'; + +part 'connect.freezed.dart'; +part 'connect.g.dart'; + +part 'ws_event.dart'; +part 'load.dart'; diff --git a/lib/models/connect/connect.freezed.dart b/lib/models/connect/connect.freezed.dart new file mode 100644 index 00000000..dcbd783d --- /dev/null +++ b/lib/models/connect/connect.freezed.dart @@ -0,0 +1,216 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'connect.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + +WebSocketLoadEventData _$WebSocketLoadEventDataFromJson( + Map json) { + return _WebSocketLoadEventData.fromJson(json); +} + +/// @nodoc +mixin _$WebSocketLoadEventData { + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks => throw _privateConstructorUsedError; + String? get collectionId => throw _privateConstructorUsedError; + int? get initialIndex => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $WebSocketLoadEventDataCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $WebSocketLoadEventDataCopyWith<$Res> { + factory $WebSocketLoadEventDataCopyWith(WebSocketLoadEventData value, + $Res Function(WebSocketLoadEventData) then) = + _$WebSocketLoadEventDataCopyWithImpl<$Res, WebSocketLoadEventData>; + @useResult + $Res call( + {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + String? collectionId, + int? initialIndex}); +} + +/// @nodoc +class _$WebSocketLoadEventDataCopyWithImpl<$Res, + $Val extends WebSocketLoadEventData> + implements $WebSocketLoadEventDataCopyWith<$Res> { + _$WebSocketLoadEventDataCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? tracks = null, + Object? collectionId = freezed, + Object? initialIndex = freezed, + }) { + return _then(_value.copyWith( + tracks: null == tracks + ? _value.tracks + : tracks // ignore: cast_nullable_to_non_nullable + as List, + collectionId: freezed == collectionId + ? _value.collectionId + : collectionId // ignore: cast_nullable_to_non_nullable + as String?, + initialIndex: freezed == initialIndex + ? _value.initialIndex + : initialIndex // ignore: cast_nullable_to_non_nullable + as int?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$WebSocketLoadEventDataImplCopyWith<$Res> + implements $WebSocketLoadEventDataCopyWith<$Res> { + factory _$$WebSocketLoadEventDataImplCopyWith( + _$WebSocketLoadEventDataImpl value, + $Res Function(_$WebSocketLoadEventDataImpl) then) = + __$$WebSocketLoadEventDataImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + String? collectionId, + int? initialIndex}); +} + +/// @nodoc +class __$$WebSocketLoadEventDataImplCopyWithImpl<$Res> + extends _$WebSocketLoadEventDataCopyWithImpl<$Res, + _$WebSocketLoadEventDataImpl> + implements _$$WebSocketLoadEventDataImplCopyWith<$Res> { + __$$WebSocketLoadEventDataImplCopyWithImpl( + _$WebSocketLoadEventDataImpl _value, + $Res Function(_$WebSocketLoadEventDataImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? tracks = null, + Object? collectionId = freezed, + Object? initialIndex = freezed, + }) { + return _then(_$WebSocketLoadEventDataImpl( + tracks: null == tracks + ? _value._tracks + : tracks // ignore: cast_nullable_to_non_nullable + as List, + collectionId: freezed == collectionId + ? _value.collectionId + : collectionId // ignore: cast_nullable_to_non_nullable + as String?, + initialIndex: freezed == initialIndex + ? _value.initialIndex + : initialIndex // ignore: cast_nullable_to_non_nullable + as int?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$WebSocketLoadEventDataImpl implements _WebSocketLoadEventData { + _$WebSocketLoadEventDataImpl( + {@JsonKey(name: 'tracks', toJson: _tracksJson) + required final List tracks, + this.collectionId, + this.initialIndex}) + : _tracks = tracks; + + factory _$WebSocketLoadEventDataImpl.fromJson(Map json) => + _$$WebSocketLoadEventDataImplFromJson(json); + + final List _tracks; + @override + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks { + if (_tracks is EqualUnmodifiableListView) return _tracks; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tracks); + } + + @override + final String? collectionId; + @override + final int? initialIndex; + + @override + String toString() { + return 'WebSocketLoadEventData(tracks: $tracks, collectionId: $collectionId, initialIndex: $initialIndex)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$WebSocketLoadEventDataImpl && + const DeepCollectionEquality().equals(other._tracks, _tracks) && + (identical(other.collectionId, collectionId) || + other.collectionId == collectionId) && + (identical(other.initialIndex, initialIndex) || + other.initialIndex == initialIndex)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, + const DeepCollectionEquality().hash(_tracks), collectionId, initialIndex); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl> + get copyWith => __$$WebSocketLoadEventDataImplCopyWithImpl< + _$WebSocketLoadEventDataImpl>(this, _$identity); + + @override + Map toJson() { + return _$$WebSocketLoadEventDataImplToJson( + this, + ); + } +} + +abstract class _WebSocketLoadEventData implements WebSocketLoadEventData { + factory _WebSocketLoadEventData( + {@JsonKey(name: 'tracks', toJson: _tracksJson) + required final List tracks, + final String? collectionId, + final int? initialIndex}) = _$WebSocketLoadEventDataImpl; + + factory _WebSocketLoadEventData.fromJson(Map json) = + _$WebSocketLoadEventDataImpl.fromJson; + + @override + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks; + @override + String? get collectionId; + @override + int? get initialIndex; + @override + @JsonKey(ignore: true) + _$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/models/connect/connect.g.dart b/lib/models/connect/connect.g.dart new file mode 100644 index 00000000..f636e035 --- /dev/null +++ b/lib/models/connect/connect.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'connect.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$WebSocketLoadEventDataImpl _$$WebSocketLoadEventDataImplFromJson( + Map json) => + _$WebSocketLoadEventDataImpl( + tracks: (json['tracks'] as List) + .map((e) => Track.fromJson(e as Map)) + .toList(), + collectionId: json['collectionId'] as String?, + initialIndex: json['initialIndex'] as int?, + ); + +Map _$$WebSocketLoadEventDataImplToJson( + _$WebSocketLoadEventDataImpl instance) => + { + 'tracks': _tracksJson(instance.tracks), + 'collectionId': instance.collectionId, + 'initialIndex': instance.initialIndex, + }; diff --git a/lib/models/connect/load.dart b/lib/models/connect/load.dart new file mode 100644 index 00000000..d750cddd --- /dev/null +++ b/lib/models/connect/load.dart @@ -0,0 +1,27 @@ +part of 'connect.dart'; + +List> _tracksJson(List tracks) { + return tracks.map((e) => e.toJson()).toList(); +} + +@freezed +class WebSocketLoadEventData with _$WebSocketLoadEventData { + factory WebSocketLoadEventData({ + @JsonKey(name: 'tracks', toJson: _tracksJson) required List tracks, + String? collectionId, + int? initialIndex, + }) = _WebSocketLoadEventData; + + factory WebSocketLoadEventData.fromJson(Map json) => + _$WebSocketLoadEventDataFromJson(json); +} + +class WebSocketLoadEvent extends WebSocketEvent { + WebSocketLoadEvent(WebSocketLoadEventData data) : super(WsEvent.load, data); + + factory WebSocketLoadEvent.fromJson(Map json) { + return WebSocketLoadEvent( + WebSocketLoadEventData.fromJson(json['data'] as Map), + ); + } +} diff --git a/lib/models/connect/ws_event.dart b/lib/models/connect/ws_event.dart new file mode 100644 index 00000000..2d7213b1 --- /dev/null +++ b/lib/models/connect/ws_event.dart @@ -0,0 +1,374 @@ +part of 'connect.dart'; + +enum WsEvent { + error, + volume, + removeTrack, + addTrack, + reorder, + shuffle, + loop, + seek, + duration, + queue, + position, + playing, + resume, + pause, + load, + next, + previous, + jump, + stop; + + static WsEvent fromString(String value) { + return WsEvent.values.firstWhere((e) => e.name == value); + } +} + +typedef EventCallback = FutureOr Function(T event); + +class WebSocketEvent { + final WsEvent type; + final T data; + + WebSocketEvent(this.type, this.data); + + factory WebSocketEvent.fromJson( + Map json, + T Function(dynamic) fromJson, + ) { + return WebSocketEvent( + WsEvent.fromString(json["type"]), + fromJson(json["data"]), + ); + } + + String toJson() { + return jsonEncode({ + "type": type.name, + "data": data, + }); + } + + Future onPosition( + EventCallback callback, + ) async { + if (type == WsEvent.position) { + await callback(WebSocketPositionEvent.fromJson({"data": data})); + } + } + + Future onPlaying( + EventCallback callback, + ) async { + if (type == WsEvent.playing) { + await callback(WebSocketPlayingEvent(data as bool)); + } + } + + Future onResume( + EventCallback callback, + ) async { + if (type == WsEvent.resume) { + await callback(WebSocketResumeEvent()); + } + } + + Future onPause( + EventCallback callback, + ) async { + if (type == WsEvent.pause) { + await callback(WebSocketPauseEvent()); + } + } + + Future onStop( + EventCallback callback, + ) async { + if (type == WsEvent.stop) { + await callback(WebSocketStopEvent()); + } + } + + Future onLoad( + EventCallback callback, + ) async { + if (type == WsEvent.load) { + await callback( + WebSocketLoadEvent( + WebSocketLoadEventData.fromJson(data as Map), + ), + ); + } + } + + Future onNext( + EventCallback callback, + ) async { + if (type == WsEvent.next) { + await callback(WebSocketNextEvent()); + } + } + + Future onPrevious( + EventCallback callback, + ) async { + if (type == WsEvent.previous) { + await callback(WebSocketPreviousEvent()); + } + } + + Future onJump( + EventCallback callback, + ) async { + if (type == WsEvent.jump) { + await callback(WebSocketJumpEvent(data as int)); + } + } + + Future onError( + EventCallback callback, + ) async { + if (type == WsEvent.error) { + await callback(WebSocketErrorEvent(data as String)); + } + } + + Future onQueue( + EventCallback callback, + ) async { + if (type == WsEvent.queue) { + await callback( + WebSocketQueueEvent.fromJson(data as Map), + ); + } + } + + Future onDuration( + EventCallback callback, + ) async { + if (type == WsEvent.duration) { + await callback( + WebSocketDurationEvent( + Duration(seconds: data as int), + ), + ); + } + } + + Future onSeek( + EventCallback callback, + ) async { + if (type == WsEvent.seek) { + await callback( + WebSocketSeekEvent( + Duration(seconds: data as int), + ), + ); + } + } + + Future onShuffle( + EventCallback callback, + ) async { + if (type == WsEvent.shuffle) { + await callback(WebSocketShuffleEvent(data as bool)); + } + } + + Future onLoop( + EventCallback callback, + ) async { + if (type == WsEvent.loop) { + await callback( + WebSocketLoopEvent( + PlaybackLoopMode.fromString(data as String), + ), + ); + } + } + + Future onRemoveTrack( + EventCallback callback, + ) async { + if (type == WsEvent.removeTrack) { + await callback(WebSocketRemoveTrackEvent(data as String)); + } + } + + Future onAddTrack( + EventCallback callback, + ) async { + if (type == WsEvent.addTrack) { + await callback( + WebSocketAddTrackEvent.fromJson(data as Map)); + } + } + + Future onReorder( + EventCallback callback, + ) async { + if (type == WsEvent.reorder) { + await callback( + WebSocketReorderEvent.fromJson(data as Map)); + } + } + + Future onVolume( + EventCallback callback, + ) async { + if (type == WsEvent.volume) { + await callback(WebSocketVolumeEvent(data as double)); + } + } +} + +class WebSocketLoopEvent extends WebSocketEvent { + WebSocketLoopEvent(PlaybackLoopMode data) : super(WsEvent.loop, data); + + WebSocketLoopEvent.fromJson(Map json) + : super( + WsEvent.loop, PlaybackLoopMode.fromString(json["data"] as String)); + + @override + String toJson() { + return jsonEncode({ + "type": type.name, + "data": data.name, + }); + } +} + +class WebSocketPositionEvent extends WebSocketEvent { + WebSocketPositionEvent(Duration data) : super(WsEvent.position, data); + + WebSocketPositionEvent.fromJson(Map json) + : super(WsEvent.position, Duration(seconds: json["data"] as int)); + + @override + String toJson() { + return jsonEncode({ + "type": type.name, + "data": data.inSeconds, + }); + } +} + +class WebSocketDurationEvent extends WebSocketEvent { + WebSocketDurationEvent(Duration data) : super(WsEvent.duration, data); + + WebSocketDurationEvent.fromJson(Map json) + : super(WsEvent.duration, Duration(seconds: json["data"] as int)); + + @override + String toJson() { + return jsonEncode({ + "type": type.name, + "data": data.inSeconds, + }); + } +} + +class WebSocketSeekEvent extends WebSocketEvent { + WebSocketSeekEvent(Duration data) : super(WsEvent.seek, data); + + WebSocketSeekEvent.fromJson(Map json) + : super(WsEvent.seek, Duration(seconds: json["data"] as int)); + + @override + String toJson() { + return jsonEncode({ + "type": type.name, + "data": data.inSeconds, + }); + } +} + +class WebSocketShuffleEvent extends WebSocketEvent { + WebSocketShuffleEvent(bool data) : super(WsEvent.shuffle, data); +} + +class WebSocketPlayingEvent extends WebSocketEvent { + WebSocketPlayingEvent(bool data) : super(WsEvent.playing, data); +} + +class WebSocketResumeEvent extends WebSocketEvent { + WebSocketResumeEvent() : super(WsEvent.resume, null); +} + +class WebSocketPauseEvent extends WebSocketEvent { + WebSocketPauseEvent() : super(WsEvent.pause, null); +} + +class WebSocketStopEvent extends WebSocketEvent { + WebSocketStopEvent() : super(WsEvent.stop, null); +} + +class WebSocketNextEvent extends WebSocketEvent { + WebSocketNextEvent() : super(WsEvent.next, null); +} + +class WebSocketPreviousEvent extends WebSocketEvent { + WebSocketPreviousEvent() : super(WsEvent.previous, null); +} + +class WebSocketJumpEvent extends WebSocketEvent { + WebSocketJumpEvent(int data) : super(WsEvent.jump, data); +} + +class WebSocketErrorEvent extends WebSocketEvent { + WebSocketErrorEvent(String data) : super(WsEvent.error, data); +} + +class WebSocketQueueEvent extends WebSocketEvent { + WebSocketQueueEvent(ProxyPlaylist data) : super(WsEvent.queue, data); + + factory WebSocketQueueEvent.fromJson(Map json) => + WebSocketQueueEvent( + ProxyPlaylist.fromJsonRaw(json), + ); +} + +class WebSocketRemoveTrackEvent extends WebSocketEvent { + WebSocketRemoveTrackEvent(String data) : super(WsEvent.removeTrack, data); +} + +class WebSocketAddTrackEvent extends WebSocketEvent { + WebSocketAddTrackEvent(Track data) : super(WsEvent.addTrack, data); + + WebSocketAddTrackEvent.fromJson(Map json) + : super( + WsEvent.addTrack, + Track.fromJson(json["data"] as Map), + ); +} + +typedef ReorderData = ({int oldIndex, int newIndex}); + +class WebSocketReorderEvent extends WebSocketEvent { + WebSocketReorderEvent(ReorderData data) : super(WsEvent.reorder, data); + + factory WebSocketReorderEvent.fromJson(Map json) => + WebSocketReorderEvent( + ( + oldIndex: json["oldIndex"] as int, + newIndex: json["newIndex"] as int, + ), + ); + + @override + String toJson() { + return jsonEncode({ + "type": type.name, + "data": { + "oldIndex": data.oldIndex, + "newIndex": data.newIndex, + }, + }); + } +} + +class WebSocketVolumeEvent extends WebSocketEvent { + WebSocketVolumeEvent(double data) : super(WsEvent.volume, data); +} diff --git a/lib/models/local_track.dart b/lib/models/local_track.dart index 134cd327..923f5f26 100644 --- a/lib/models/local_track.dart +++ b/lib/models/local_track.dart @@ -37,7 +37,7 @@ class LocalTrack extends Track { Map toJson() { return { - ...TrackJson.trackToJson(this), + ...TrackExtensions.trackToJson(this), 'path': path, }; } diff --git a/lib/models/lyrics.dart b/lib/models/lyrics.dart index c800b040..f6457287 100644 --- a/lib/models/lyrics.dart +++ b/lib/models/lyrics.dart @@ -1,13 +1,18 @@ +import 'package:lrc/lrc.dart'; + class SubtitleSimple { Uri uri; String name; List lyrics; int rating; + String provider; + SubtitleSimple({ required this.uri, required this.name, required this.lyrics, required this.rating, + required this.provider, }); factory SubtitleSimple.fromJson(Map json) { @@ -18,6 +23,7 @@ class SubtitleSimple { .map((e) => LyricSlice.fromJson(e as Map)) .toList(), rating: json["rating"] as int, + provider: json["provider"] as String? ?? "unknown", ); } @@ -27,6 +33,7 @@ class SubtitleSimple { "name": name, "lyrics": lyrics.map((e) => e.toJson()).toList(), "rating": rating, + "provider": provider, }; } } @@ -37,6 +44,13 @@ class LyricSlice { LyricSlice({required this.time, required this.text}); + factory LyricSlice.fromLrcLine(LrcLine line) { + return LyricSlice( + time: line.timestamp, + text: line.lyrics.trim(), + ); + } + factory LyricSlice.fromJson(Map json) { return LyricSlice( time: Duration(milliseconds: json["time"]), diff --git a/lib/models/spotify/recommendation_seeds.dart b/lib/models/spotify/recommendation_seeds.dart new file mode 100644 index 00000000..0d874ad6 --- /dev/null +++ b/lib/models/spotify/recommendation_seeds.dart @@ -0,0 +1,40 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'recommendation_seeds.freezed.dart'; +part 'recommendation_seeds.g.dart'; + +@freezed +class GeneratePlaylistProviderInput with _$GeneratePlaylistProviderInput { + factory GeneratePlaylistProviderInput({ + Iterable? seedArtists, + Iterable? seedGenres, + Iterable? seedTracks, + required int limit, + RecommendationSeeds? max, + RecommendationSeeds? min, + RecommendationSeeds? target, + }) = _GeneratePlaylistProviderInput; +} + +@freezed +class RecommendationSeeds with _$RecommendationSeeds { + factory RecommendationSeeds({ + num? acousticness, + num? danceability, + @JsonKey(name: "duration_ms") num? durationMs, + num? energy, + num? instrumentalness, + num? key, + num? liveness, + num? loudness, + num? mode, + num? popularity, + num? speechiness, + num? tempo, + @JsonKey(name: "time_signature") num? timeSignature, + num? valence, + }) = _RecommendationSeeds; + + factory RecommendationSeeds.fromJson(Map json) => + _$RecommendationSeedsFromJson(json); +} diff --git a/lib/models/spotify/recommendation_seeds.freezed.dart b/lib/models/spotify/recommendation_seeds.freezed.dart new file mode 100644 index 00000000..4cfcce12 --- /dev/null +++ b/lib/models/spotify/recommendation_seeds.freezed.dart @@ -0,0 +1,756 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'recommendation_seeds.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + +/// @nodoc +mixin _$GeneratePlaylistProviderInput { + Iterable? get seedArtists => throw _privateConstructorUsedError; + Iterable? get seedGenres => throw _privateConstructorUsedError; + Iterable? get seedTracks => throw _privateConstructorUsedError; + int get limit => throw _privateConstructorUsedError; + RecommendationSeeds? get max => throw _privateConstructorUsedError; + RecommendationSeeds? get min => throw _privateConstructorUsedError; + RecommendationSeeds? get target => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $GeneratePlaylistProviderInputCopyWith + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $GeneratePlaylistProviderInputCopyWith<$Res> { + factory $GeneratePlaylistProviderInputCopyWith( + GeneratePlaylistProviderInput value, + $Res Function(GeneratePlaylistProviderInput) then) = + _$GeneratePlaylistProviderInputCopyWithImpl<$Res, + GeneratePlaylistProviderInput>; + @useResult + $Res call( + {Iterable? seedArtists, + Iterable? seedGenres, + Iterable? seedTracks, + int limit, + RecommendationSeeds? max, + RecommendationSeeds? min, + RecommendationSeeds? target}); + + $RecommendationSeedsCopyWith<$Res>? get max; + $RecommendationSeedsCopyWith<$Res>? get min; + $RecommendationSeedsCopyWith<$Res>? get target; +} + +/// @nodoc +class _$GeneratePlaylistProviderInputCopyWithImpl<$Res, + $Val extends GeneratePlaylistProviderInput> + implements $GeneratePlaylistProviderInputCopyWith<$Res> { + _$GeneratePlaylistProviderInputCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? seedArtists = freezed, + Object? seedGenres = freezed, + Object? seedTracks = freezed, + Object? limit = null, + Object? max = freezed, + Object? min = freezed, + Object? target = freezed, + }) { + return _then(_value.copyWith( + seedArtists: freezed == seedArtists + ? _value.seedArtists + : seedArtists // ignore: cast_nullable_to_non_nullable + as Iterable?, + seedGenres: freezed == seedGenres + ? _value.seedGenres + : seedGenres // ignore: cast_nullable_to_non_nullable + as Iterable?, + seedTracks: freezed == seedTracks + ? _value.seedTracks + : seedTracks // ignore: cast_nullable_to_non_nullable + as Iterable?, + limit: null == limit + ? _value.limit + : limit // ignore: cast_nullable_to_non_nullable + as int, + max: freezed == max + ? _value.max + : max // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + min: freezed == min + ? _value.min + : min // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + target: freezed == target + ? _value.target + : target // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + ) as $Val); + } + + @override + @pragma('vm:prefer-inline') + $RecommendationSeedsCopyWith<$Res>? get max { + if (_value.max == null) { + return null; + } + + return $RecommendationSeedsCopyWith<$Res>(_value.max!, (value) { + return _then(_value.copyWith(max: value) as $Val); + }); + } + + @override + @pragma('vm:prefer-inline') + $RecommendationSeedsCopyWith<$Res>? get min { + if (_value.min == null) { + return null; + } + + return $RecommendationSeedsCopyWith<$Res>(_value.min!, (value) { + return _then(_value.copyWith(min: value) as $Val); + }); + } + + @override + @pragma('vm:prefer-inline') + $RecommendationSeedsCopyWith<$Res>? get target { + if (_value.target == null) { + return null; + } + + return $RecommendationSeedsCopyWith<$Res>(_value.target!, (value) { + return _then(_value.copyWith(target: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$GeneratePlaylistProviderInputImplCopyWith<$Res> + implements $GeneratePlaylistProviderInputCopyWith<$Res> { + factory _$$GeneratePlaylistProviderInputImplCopyWith( + _$GeneratePlaylistProviderInputImpl value, + $Res Function(_$GeneratePlaylistProviderInputImpl) then) = + __$$GeneratePlaylistProviderInputImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {Iterable? seedArtists, + Iterable? seedGenres, + Iterable? seedTracks, + int limit, + RecommendationSeeds? max, + RecommendationSeeds? min, + RecommendationSeeds? target}); + + @override + $RecommendationSeedsCopyWith<$Res>? get max; + @override + $RecommendationSeedsCopyWith<$Res>? get min; + @override + $RecommendationSeedsCopyWith<$Res>? get target; +} + +/// @nodoc +class __$$GeneratePlaylistProviderInputImplCopyWithImpl<$Res> + extends _$GeneratePlaylistProviderInputCopyWithImpl<$Res, + _$GeneratePlaylistProviderInputImpl> + implements _$$GeneratePlaylistProviderInputImplCopyWith<$Res> { + __$$GeneratePlaylistProviderInputImplCopyWithImpl( + _$GeneratePlaylistProviderInputImpl _value, + $Res Function(_$GeneratePlaylistProviderInputImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? seedArtists = freezed, + Object? seedGenres = freezed, + Object? seedTracks = freezed, + Object? limit = null, + Object? max = freezed, + Object? min = freezed, + Object? target = freezed, + }) { + return _then(_$GeneratePlaylistProviderInputImpl( + seedArtists: freezed == seedArtists + ? _value.seedArtists + : seedArtists // ignore: cast_nullable_to_non_nullable + as Iterable?, + seedGenres: freezed == seedGenres + ? _value.seedGenres + : seedGenres // ignore: cast_nullable_to_non_nullable + as Iterable?, + seedTracks: freezed == seedTracks + ? _value.seedTracks + : seedTracks // ignore: cast_nullable_to_non_nullable + as Iterable?, + limit: null == limit + ? _value.limit + : limit // ignore: cast_nullable_to_non_nullable + as int, + max: freezed == max + ? _value.max + : max // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + min: freezed == min + ? _value.min + : min // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + target: freezed == target + ? _value.target + : target // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + )); + } +} + +/// @nodoc + +class _$GeneratePlaylistProviderInputImpl + implements _GeneratePlaylistProviderInput { + _$GeneratePlaylistProviderInputImpl( + {this.seedArtists, + this.seedGenres, + this.seedTracks, + required this.limit, + this.max, + this.min, + this.target}); + + @override + final Iterable? seedArtists; + @override + final Iterable? seedGenres; + @override + final Iterable? seedTracks; + @override + final int limit; + @override + final RecommendationSeeds? max; + @override + final RecommendationSeeds? min; + @override + final RecommendationSeeds? target; + + @override + String toString() { + return 'GeneratePlaylistProviderInput(seedArtists: $seedArtists, seedGenres: $seedGenres, seedTracks: $seedTracks, limit: $limit, max: $max, min: $min, target: $target)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$GeneratePlaylistProviderInputImpl && + const DeepCollectionEquality() + .equals(other.seedArtists, seedArtists) && + const DeepCollectionEquality() + .equals(other.seedGenres, seedGenres) && + const DeepCollectionEquality() + .equals(other.seedTracks, seedTracks) && + (identical(other.limit, limit) || other.limit == limit) && + (identical(other.max, max) || other.max == max) && + (identical(other.min, min) || other.min == min) && + (identical(other.target, target) || other.target == target)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(seedArtists), + const DeepCollectionEquality().hash(seedGenres), + const DeepCollectionEquality().hash(seedTracks), + limit, + max, + min, + target); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$GeneratePlaylistProviderInputImplCopyWith< + _$GeneratePlaylistProviderInputImpl> + get copyWith => __$$GeneratePlaylistProviderInputImplCopyWithImpl< + _$GeneratePlaylistProviderInputImpl>(this, _$identity); +} + +abstract class _GeneratePlaylistProviderInput + implements GeneratePlaylistProviderInput { + factory _GeneratePlaylistProviderInput( + {final Iterable? seedArtists, + final Iterable? seedGenres, + final Iterable? seedTracks, + required final int limit, + final RecommendationSeeds? max, + final RecommendationSeeds? min, + final RecommendationSeeds? target}) = _$GeneratePlaylistProviderInputImpl; + + @override + Iterable? get seedArtists; + @override + Iterable? get seedGenres; + @override + Iterable? get seedTracks; + @override + int get limit; + @override + RecommendationSeeds? get max; + @override + RecommendationSeeds? get min; + @override + RecommendationSeeds? get target; + @override + @JsonKey(ignore: true) + _$$GeneratePlaylistProviderInputImplCopyWith< + _$GeneratePlaylistProviderInputImpl> + get copyWith => throw _privateConstructorUsedError; +} + +RecommendationSeeds _$RecommendationSeedsFromJson(Map json) { + return _RecommendationSeeds.fromJson(json); +} + +/// @nodoc +mixin _$RecommendationSeeds { + num? get acousticness => throw _privateConstructorUsedError; + num? get danceability => throw _privateConstructorUsedError; + @JsonKey(name: "duration_ms") + num? get durationMs => throw _privateConstructorUsedError; + num? get energy => throw _privateConstructorUsedError; + num? get instrumentalness => throw _privateConstructorUsedError; + num? get key => throw _privateConstructorUsedError; + num? get liveness => throw _privateConstructorUsedError; + num? get loudness => throw _privateConstructorUsedError; + num? get mode => throw _privateConstructorUsedError; + num? get popularity => throw _privateConstructorUsedError; + num? get speechiness => throw _privateConstructorUsedError; + num? get tempo => throw _privateConstructorUsedError; + @JsonKey(name: "time_signature") + num? get timeSignature => throw _privateConstructorUsedError; + num? get valence => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $RecommendationSeedsCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $RecommendationSeedsCopyWith<$Res> { + factory $RecommendationSeedsCopyWith( + RecommendationSeeds value, $Res Function(RecommendationSeeds) then) = + _$RecommendationSeedsCopyWithImpl<$Res, RecommendationSeeds>; + @useResult + $Res call( + {num? acousticness, + num? danceability, + @JsonKey(name: "duration_ms") num? durationMs, + num? energy, + num? instrumentalness, + num? key, + num? liveness, + num? loudness, + num? mode, + num? popularity, + num? speechiness, + num? tempo, + @JsonKey(name: "time_signature") num? timeSignature, + num? valence}); +} + +/// @nodoc +class _$RecommendationSeedsCopyWithImpl<$Res, $Val extends RecommendationSeeds> + implements $RecommendationSeedsCopyWith<$Res> { + _$RecommendationSeedsCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? acousticness = freezed, + Object? danceability = freezed, + Object? durationMs = freezed, + Object? energy = freezed, + Object? instrumentalness = freezed, + Object? key = freezed, + Object? liveness = freezed, + Object? loudness = freezed, + Object? mode = freezed, + Object? popularity = freezed, + Object? speechiness = freezed, + Object? tempo = freezed, + Object? timeSignature = freezed, + Object? valence = freezed, + }) { + return _then(_value.copyWith( + acousticness: freezed == acousticness + ? _value.acousticness + : acousticness // ignore: cast_nullable_to_non_nullable + as num?, + danceability: freezed == danceability + ? _value.danceability + : danceability // ignore: cast_nullable_to_non_nullable + as num?, + durationMs: freezed == durationMs + ? _value.durationMs + : durationMs // ignore: cast_nullable_to_non_nullable + as num?, + energy: freezed == energy + ? _value.energy + : energy // ignore: cast_nullable_to_non_nullable + as num?, + instrumentalness: freezed == instrumentalness + ? _value.instrumentalness + : instrumentalness // ignore: cast_nullable_to_non_nullable + as num?, + key: freezed == key + ? _value.key + : key // ignore: cast_nullable_to_non_nullable + as num?, + liveness: freezed == liveness + ? _value.liveness + : liveness // ignore: cast_nullable_to_non_nullable + as num?, + loudness: freezed == loudness + ? _value.loudness + : loudness // ignore: cast_nullable_to_non_nullable + as num?, + mode: freezed == mode + ? _value.mode + : mode // ignore: cast_nullable_to_non_nullable + as num?, + popularity: freezed == popularity + ? _value.popularity + : popularity // ignore: cast_nullable_to_non_nullable + as num?, + speechiness: freezed == speechiness + ? _value.speechiness + : speechiness // ignore: cast_nullable_to_non_nullable + as num?, + tempo: freezed == tempo + ? _value.tempo + : tempo // ignore: cast_nullable_to_non_nullable + as num?, + timeSignature: freezed == timeSignature + ? _value.timeSignature + : timeSignature // ignore: cast_nullable_to_non_nullable + as num?, + valence: freezed == valence + ? _value.valence + : valence // ignore: cast_nullable_to_non_nullable + as num?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$RecommendationSeedsImplCopyWith<$Res> + implements $RecommendationSeedsCopyWith<$Res> { + factory _$$RecommendationSeedsImplCopyWith(_$RecommendationSeedsImpl value, + $Res Function(_$RecommendationSeedsImpl) then) = + __$$RecommendationSeedsImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {num? acousticness, + num? danceability, + @JsonKey(name: "duration_ms") num? durationMs, + num? energy, + num? instrumentalness, + num? key, + num? liveness, + num? loudness, + num? mode, + num? popularity, + num? speechiness, + num? tempo, + @JsonKey(name: "time_signature") num? timeSignature, + num? valence}); +} + +/// @nodoc +class __$$RecommendationSeedsImplCopyWithImpl<$Res> + extends _$RecommendationSeedsCopyWithImpl<$Res, _$RecommendationSeedsImpl> + implements _$$RecommendationSeedsImplCopyWith<$Res> { + __$$RecommendationSeedsImplCopyWithImpl(_$RecommendationSeedsImpl _value, + $Res Function(_$RecommendationSeedsImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? acousticness = freezed, + Object? danceability = freezed, + Object? durationMs = freezed, + Object? energy = freezed, + Object? instrumentalness = freezed, + Object? key = freezed, + Object? liveness = freezed, + Object? loudness = freezed, + Object? mode = freezed, + Object? popularity = freezed, + Object? speechiness = freezed, + Object? tempo = freezed, + Object? timeSignature = freezed, + Object? valence = freezed, + }) { + return _then(_$RecommendationSeedsImpl( + acousticness: freezed == acousticness + ? _value.acousticness + : acousticness // ignore: cast_nullable_to_non_nullable + as num?, + danceability: freezed == danceability + ? _value.danceability + : danceability // ignore: cast_nullable_to_non_nullable + as num?, + durationMs: freezed == durationMs + ? _value.durationMs + : durationMs // ignore: cast_nullable_to_non_nullable + as num?, + energy: freezed == energy + ? _value.energy + : energy // ignore: cast_nullable_to_non_nullable + as num?, + instrumentalness: freezed == instrumentalness + ? _value.instrumentalness + : instrumentalness // ignore: cast_nullable_to_non_nullable + as num?, + key: freezed == key + ? _value.key + : key // ignore: cast_nullable_to_non_nullable + as num?, + liveness: freezed == liveness + ? _value.liveness + : liveness // ignore: cast_nullable_to_non_nullable + as num?, + loudness: freezed == loudness + ? _value.loudness + : loudness // ignore: cast_nullable_to_non_nullable + as num?, + mode: freezed == mode + ? _value.mode + : mode // ignore: cast_nullable_to_non_nullable + as num?, + popularity: freezed == popularity + ? _value.popularity + : popularity // ignore: cast_nullable_to_non_nullable + as num?, + speechiness: freezed == speechiness + ? _value.speechiness + : speechiness // ignore: cast_nullable_to_non_nullable + as num?, + tempo: freezed == tempo + ? _value.tempo + : tempo // ignore: cast_nullable_to_non_nullable + as num?, + timeSignature: freezed == timeSignature + ? _value.timeSignature + : timeSignature // ignore: cast_nullable_to_non_nullable + as num?, + valence: freezed == valence + ? _value.valence + : valence // ignore: cast_nullable_to_non_nullable + as num?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$RecommendationSeedsImpl implements _RecommendationSeeds { + _$RecommendationSeedsImpl( + {this.acousticness, + this.danceability, + @JsonKey(name: "duration_ms") this.durationMs, + this.energy, + this.instrumentalness, + this.key, + this.liveness, + this.loudness, + this.mode, + this.popularity, + this.speechiness, + this.tempo, + @JsonKey(name: "time_signature") this.timeSignature, + this.valence}); + + factory _$RecommendationSeedsImpl.fromJson(Map json) => + _$$RecommendationSeedsImplFromJson(json); + + @override + final num? acousticness; + @override + final num? danceability; + @override + @JsonKey(name: "duration_ms") + final num? durationMs; + @override + final num? energy; + @override + final num? instrumentalness; + @override + final num? key; + @override + final num? liveness; + @override + final num? loudness; + @override + final num? mode; + @override + final num? popularity; + @override + final num? speechiness; + @override + final num? tempo; + @override + @JsonKey(name: "time_signature") + final num? timeSignature; + @override + final num? valence; + + @override + String toString() { + return 'RecommendationSeeds(acousticness: $acousticness, danceability: $danceability, durationMs: $durationMs, energy: $energy, instrumentalness: $instrumentalness, key: $key, liveness: $liveness, loudness: $loudness, mode: $mode, popularity: $popularity, speechiness: $speechiness, tempo: $tempo, timeSignature: $timeSignature, valence: $valence)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$RecommendationSeedsImpl && + (identical(other.acousticness, acousticness) || + other.acousticness == acousticness) && + (identical(other.danceability, danceability) || + other.danceability == danceability) && + (identical(other.durationMs, durationMs) || + other.durationMs == durationMs) && + (identical(other.energy, energy) || other.energy == energy) && + (identical(other.instrumentalness, instrumentalness) || + other.instrumentalness == instrumentalness) && + (identical(other.key, key) || other.key == key) && + (identical(other.liveness, liveness) || + other.liveness == liveness) && + (identical(other.loudness, loudness) || + other.loudness == loudness) && + (identical(other.mode, mode) || other.mode == mode) && + (identical(other.popularity, popularity) || + other.popularity == popularity) && + (identical(other.speechiness, speechiness) || + other.speechiness == speechiness) && + (identical(other.tempo, tempo) || other.tempo == tempo) && + (identical(other.timeSignature, timeSignature) || + other.timeSignature == timeSignature) && + (identical(other.valence, valence) || other.valence == valence)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash( + runtimeType, + acousticness, + danceability, + durationMs, + energy, + instrumentalness, + key, + liveness, + loudness, + mode, + popularity, + speechiness, + tempo, + timeSignature, + valence); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$RecommendationSeedsImplCopyWith<_$RecommendationSeedsImpl> get copyWith => + __$$RecommendationSeedsImplCopyWithImpl<_$RecommendationSeedsImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$RecommendationSeedsImplToJson( + this, + ); + } +} + +abstract class _RecommendationSeeds implements RecommendationSeeds { + factory _RecommendationSeeds( + {final num? acousticness, + final num? danceability, + @JsonKey(name: "duration_ms") final num? durationMs, + final num? energy, + final num? instrumentalness, + final num? key, + final num? liveness, + final num? loudness, + final num? mode, + final num? popularity, + final num? speechiness, + final num? tempo, + @JsonKey(name: "time_signature") final num? timeSignature, + final num? valence}) = _$RecommendationSeedsImpl; + + factory _RecommendationSeeds.fromJson(Map json) = + _$RecommendationSeedsImpl.fromJson; + + @override + num? get acousticness; + @override + num? get danceability; + @override + @JsonKey(name: "duration_ms") + num? get durationMs; + @override + num? get energy; + @override + num? get instrumentalness; + @override + num? get key; + @override + num? get liveness; + @override + num? get loudness; + @override + num? get mode; + @override + num? get popularity; + @override + num? get speechiness; + @override + num? get tempo; + @override + @JsonKey(name: "time_signature") + num? get timeSignature; + @override + num? get valence; + @override + @JsonKey(ignore: true) + _$$RecommendationSeedsImplCopyWith<_$RecommendationSeedsImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/models/spotify/recommendation_seeds.g.dart b/lib/models/spotify/recommendation_seeds.g.dart new file mode 100644 index 00000000..bdfa3a07 --- /dev/null +++ b/lib/models/spotify/recommendation_seeds.g.dart @@ -0,0 +1,45 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recommendation_seeds.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$RecommendationSeedsImpl _$$RecommendationSeedsImplFromJson( + Map json) => + _$RecommendationSeedsImpl( + acousticness: json['acousticness'] as num?, + danceability: json['danceability'] as num?, + durationMs: json['duration_ms'] as num?, + energy: json['energy'] as num?, + instrumentalness: json['instrumentalness'] as num?, + key: json['key'] as num?, + liveness: json['liveness'] as num?, + loudness: json['loudness'] as num?, + mode: json['mode'] as num?, + popularity: json['popularity'] as num?, + speechiness: json['speechiness'] as num?, + tempo: json['tempo'] as num?, + timeSignature: json['time_signature'] as num?, + valence: json['valence'] as num?, + ); + +Map _$$RecommendationSeedsImplToJson( + _$RecommendationSeedsImpl instance) => + { + 'acousticness': instance.acousticness, + 'danceability': instance.danceability, + 'duration_ms': instance.durationMs, + 'energy': instance.energy, + 'instrumentalness': instance.instrumentalness, + 'key': instance.key, + 'liveness': instance.liveness, + 'loudness': instance.loudness, + 'mode': instance.mode, + 'popularity': instance.popularity, + 'speechiness': instance.speechiness, + 'tempo': instance.tempo, + 'time_signature': instance.timeSignature, + 'valence': instance.valence, + }; diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index 72f9a9af..b24b69f4 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -1,78 +1,61 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/tracks_view/track_view.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/infinite_query.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class AlbumPage extends HookConsumerWidget { final AlbumSimple album; const AlbumPage({ - Key? key, + super.key, required this.album, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { - final spotify = ref.watch(spotifyProvider); - final tracksQuery = useQueries.album.tracksOf(ref, album); - - final tracks = useMemoized(() { - return tracksQuery.pages.expand((element) => element).toList(); - }, [tracksQuery.pages]); - - final client = useQueryClient(); - - final albumIsSaved = useQueries.album.isSavedForMe(ref, album.id!); - final isLiked = albumIsSaved.data ?? false; - - final toggleAlbumLike = useMutations.album.toggleFavorite( - ref, - album.id!, - refreshQueries: [albumIsSaved.key], - onData: (_, __) async { - await client.refreshInfiniteQueryAllPages("current-user-albums"); - }, - ); + final tracks = ref.watch(albumTracksProvider(album)); + final tracksNotifier = ref.watch(albumTracksProvider(album).notifier); + final favoriteAlbumsNotifier = ref.watch(favoriteAlbumsProvider.notifier); + final isSavedAlbum = ref.watch(albumsIsSavedProvider(album.id!)); return InheritedTrackView( collectionId: album.id!, - image: TypeConversionUtils.image_X_UrlString( - album.images, + image: album.images.asUrlString( placeholder: ImagePlaceholder.albumArt, ), title: album.name!, description: "${context.l10n.released} • ${album.releaseDate} • ${album.artists!.first.name}", - tracks: tracks, - pagination: PaginationProps.fromQuery( - tracksQuery, - onFetchAll: () { - return tracksQuery.fetchAllTracks(getAllTracks: () async { - final res = await spotify.albums.tracks(album.id!).all(); - - return res - .map((track) => - TypeConversionUtils.simpleTrack_X_Track(track, album)) - .toList(); - }); + tracks: tracks.asData?.value.items ?? [], + pagination: PaginationProps( + hasNextPage: tracks.asData?.value.hasMore ?? false, + isLoading: tracks.isLoadingNextPage, + onFetchMore: () async { + await tracksNotifier.fetchMore(); + }, + onFetchAll: () async { + return tracksNotifier.fetchAll(); + }, + onRefresh: () async { + ref.invalidate(albumTracksProvider(album)); }, ), routePath: "/album/${album.id}", shareUrl: album.externalUrls!.spotify!, - isLiked: isLiked, - onHeart: albumIsSaved.hasData - ? () { - toggleAlbumLike.mutate(isLiked); - } - : null, + isLiked: isSavedAlbum.asData?.value ?? false, + onHeart: isSavedAlbum.asData?.value == null + ? null + : () async { + if (isSavedAlbum.asData!.value) { + await favoriteAlbumsNotifier.removeFavorites([album.id!]); + } else { + await favoriteAlbumsNotifier.addFavorites([album.id!]); + } + return null; + }, child: const TrackView(), ); } diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index d511cb97..c3b04691 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -12,19 +12,19 @@ import 'package:spotube/pages/artist/section/footer.dart'; import 'package:spotube/pages/artist/section/header.dart'; import 'package:spotube/pages/artist/section/related_artists.dart'; import 'package:spotube/pages/artist/section/top_tracks.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class ArtistPage extends HookConsumerWidget { final String artistId; final logger = getLogger(ArtistPage); - ArtistPage(this.artistId, {Key? key}) : super(key: key); + ArtistPage(this.artistId, {super.key}); @override Widget build(BuildContext context, ref) { final scrollController = useScrollController(); final theme = Theme.of(context); - final artistQuery = useQueries.artist.get(ref, artistId); + final artistQuery = ref.watch(artistProvider(artistId)); return SafeArea( bottom: false, @@ -35,7 +35,7 @@ class ArtistPage extends HookConsumerWidget { ), extendBodyBehindAppBar: true, body: Builder(builder: (context) { - if (artistQuery.hasError && artistQuery.data == null) { + if (artistQuery.hasError && artistQuery.asData?.value == null) { return Center(child: Text(artistQuery.error.toString())); } return Skeletonizer( @@ -66,11 +66,12 @@ class ArtistPage extends HookConsumerWidget { SliverSafeArea( sliver: ArtistPageRelatedArtists(artistId: artistId), ), - if (artistQuery.data != null) + if (artistQuery.asData?.value != null) SliverSafeArea( top: false, sliver: SliverToBoxAdapter( - child: ArtistPageFooter(artist: artistQuery.data!), + child: + ArtistPageFooter(artist: artistQuery.asData!.value), ), ), ], diff --git a/lib/pages/artist/section/footer.dart b/lib/pages/artist/section/footer.dart index b01ef705..4707b939 100644 --- a/lib/pages/artist/section/footer.dart +++ b/lib/pages/artist/section/footer.dart @@ -5,25 +5,26 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + import 'package:url_launcher/url_launcher_string.dart'; -class ArtistPageFooter extends HookConsumerWidget { +class ArtistPageFooter extends ConsumerWidget { final Artist artist; - const ArtistPageFooter({Key? key, required this.artist}) : super(key: key); + const ArtistPageFooter({super.key, required this.artist}); @override Widget build(BuildContext context, ref) { final ThemeData(:textTheme) = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final artistImage = TypeConversionUtils.image_X_UrlString( - artist.images, + final artistImage = artist.images.asUrlString( placeholder: ImagePlaceholder.artist, ); - final summary = useQueries.artist.wikipediaSummary(artist); - if (summary.hasError || !summary.hasData) return const SizedBox.shrink(); + final summary = ref.watch(artistWikipediaSummaryProvider(artist)); + if (summary.asData?.value == null) return const SizedBox.shrink(); + return Container( margin: const EdgeInsets.all(16), padding: mediaQuery.smAndDown @@ -38,9 +39,9 @@ class ArtistPageFooter extends HookConsumerWidget { BlendMode.darken, ), image: UniversalImage.imageProvider( - summary.data!.thumbnail?.source_ ?? artistImage, - height: summary.data!.thumbnail?.height.toDouble(), - width: summary.data!.thumbnail?.width.toDouble(), + summary.asData?.value!.thumbnail?.source_ ?? artistImage, + height: summary.asData?.value!.thumbnail?.height.toDouble(), + width: summary.asData?.value!.thumbnail?.width.toDouble(), ), fit: BoxFit.cover, alignment: Alignment.center, @@ -69,7 +70,7 @@ class ArtistPageFooter extends HookConsumerWidget { ), const TextSpan(text: '\n\n'), TextSpan( - text: summary.data!.extract, + text: summary.asData?.value!.extract, ), TextSpan( text: '\n...read more at wikipedia', @@ -81,7 +82,7 @@ class ArtistPageFooter extends HookConsumerWidget { recognizer: TapGestureRecognizer() ..onTap = () async { await launchUrlString( - "http://en.wikipedia.org/wiki?curid=${summary.data?.pageid}", + "http://en.wikipedia.org/wiki?curid=${summary.asData?.value?.pageid}", ); }, ), diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart index 7cee7a01..e5cb8900 100644 --- a/lib/pages/artist/section/header.dart +++ b/lib/pages/artist/section/header.dart @@ -1,33 +1,28 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/primitive_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class ArtistPageHeader extends HookConsumerWidget { final String artistId; - const ArtistPageHeader({Key? key, required this.artistId}) : super(key: key); + const ArtistPageHeader({super.key, required this.artistId}); @override Widget build(BuildContext context, ref) { - final queryClient = useQueryClient(); - final artistQuery = useQueries.artist.get(ref, artistId); - final artist = artistQuery.data ?? FakeData.artist; + final artistQuery = ref.watch(artistProvider(artistId)); + final artist = artistQuery.asData?.value ?? FakeData.artist; final scaffoldMessenger = ScaffoldMessenger.of(context); final mediaQuery = MediaQuery.of(context); @@ -43,15 +38,13 @@ class ArtistPageHeader extends HookConsumerWidget { xxl: textTheme.titleMedium, ); - final spotify = ref.read(spotifyProvider); final auth = ref.watch(AuthenticationNotifier.provider); final blacklist = ref.watch(BlackListNotifier.provider); final isBlackListed = blacklist.contains( BlacklistedElement.artist(artistId, artist.name!), ); - final image = TypeConversionUtils.image_X_UrlString( - artist.images, + final image = artist.images.asUrlString( placeholder: ImagePlaceholder.artist, ); @@ -143,53 +136,41 @@ class ArtistPageHeader extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ if (auth != null) - HookBuilder( - builder: (context) { - final isFollowingQuery = - useQueries.artist.doIFollow(ref, artistId); + Consumer( + builder: (context, ref, _) { + final isFollowingQuery = ref + .watch(artistIsFollowingProvider(artist.id!)); + final followingArtistNotifier = + ref.watch(followedArtistsProvider.notifier); - final followUnfollow = useCallback(() async { - try { - isFollowingQuery.data! - ? await spotify.me.unfollow( - FollowingType.artist, - [artistId], - ) - : await spotify.me.follow( - FollowingType.artist, - [artistId], + return switch (isFollowingQuery) { + AsyncData(value: final following) => Builder( + builder: (context) { + if (following) { + return OutlinedButton( + onPressed: () async { + await followingArtistNotifier + .removeArtists([artist.id!]); + }, + child: Text(context.l10n.following), ); - await isFollowingQuery.refresh(); + } - queryClient.refreshInfiniteQueryAllPages( - "user-following-artists"); - } finally { - queryClient.refreshQuery( - "user-follows-artists-query/$artistId", - ); - } - }, [isFollowingQuery]); - - if (isFollowingQuery.isLoading || - !isFollowingQuery.hasData) { - return const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(), - ); - } - - if (isFollowingQuery.data!) { - return OutlinedButton( - onPressed: followUnfollow, - child: Text(context.l10n.following), - ); - } - - return FilledButton( - onPressed: followUnfollow, - child: Text(context.l10n.follow), - ); + return FilledButton( + onPressed: () async { + await followingArtistNotifier + .saveArtists([artist.id!]); + }, + child: Text(context.l10n.follow), + ); + }, + ), + AsyncError() => const SizedBox(), + _ => const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(), + ) + }; }, ), const SizedBox(width: 5), diff --git a/lib/pages/artist/section/related_artists.dart b/lib/pages/artist/section/related_artists.dart index 2938c084..7fc48ded 100644 --- a/lib/pages/artist/section/related_artists.dart +++ b/lib/pages/artist/section/related_artists.dart @@ -1,49 +1,45 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/components/artist/artist_card.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; -class ArtistPageRelatedArtists extends HookConsumerWidget { +class ArtistPageRelatedArtists extends ConsumerWidget { final String artistId; const ArtistPageRelatedArtists({ - Key? key, + super.key, required this.artistId, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { - final relatedArtists = useQueries.artist.relatedArtistsOf( - ref, - artistId, - ); + final relatedArtists = ref.watch(relatedArtistsProvider(artistId)); - if (relatedArtists.isLoading || !relatedArtists.hasData) { - return const SliverToBoxAdapter( - child: Center(child: CircularProgressIndicator())); - } else if (relatedArtists.hasError) { - return SliverToBoxAdapter( - child: Center( - child: Text(relatedArtists.error.toString()), + return switch (relatedArtists) { + AsyncData(value: final artists) => SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + sliver: SliverGrid.builder( + itemCount: artists.length, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: 250, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 0.8, + ), + itemBuilder: (context, index) { + final artist = artists.elementAt(index); + return ArtistCard(artist); + }, + ), ), - ); - } - - return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - sliver: SliverGrid.builder( - itemCount: relatedArtists.data!.length, - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - mainAxisExtent: 250, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - childAspectRatio: 0.8, + AsyncError(:final error) => SliverToBoxAdapter( + child: Center( + child: Text(error.toString()), + ), ), - itemBuilder: (context, index) { - final artist = relatedArtists.data!.elementAt(index); - return ArtistCard(artist); - }, - ), - ); + _ => const SliverToBoxAdapter( + child: Center(child: CircularProgressIndicator()), + ), + }; } } diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart index 771757b9..9dec5f7c 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -4,15 +4,17 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class ArtistPageTopTracks extends HookConsumerWidget { final String artistId; - const ArtistPageTopTracks({Key? key, required this.artistId}) - : super(key: key); + const ArtistPageTopTracks({super.key, required this.artistId}); @override Widget build(BuildContext context, ref) { @@ -21,13 +23,10 @@ class ArtistPageTopTracks extends HookConsumerWidget { final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final topTracksQuery = useQueries.artist.topTracksOf( - ref, - artistId, - ); + final topTracksQuery = ref.watch(artistTopTracksProvider(artistId)); final isPlaylistPlaying = playlist.containsTracks( - topTracksQuery.data ?? [], + topTracksQuery.asData?.value ?? [], ); if (topTracksQuery.hasError) { @@ -38,21 +37,46 @@ class ArtistPageTopTracks extends HookConsumerWidget { ); } - final topTracks = - topTracksQuery.data ?? List.generate(10, (index) => FakeData.track); + final topTracks = topTracksQuery.asData?.value ?? + List.generate(10, (index) => FakeData.track); void playPlaylist(List tracks, {Track? currentTrack}) async { currentTrack ??= tracks.first; - if (!isPlaylistPlaying) { - playlistNotifier.load( - tracks, - initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), - autoPlay: true, - ); - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != playlist.activeTrack?.id) { - await playlistNotifier.jumpToTrack(currentTrack); + + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final remotePlaylist = ref.read(queueProvider); + + final isPlaylistPlaying = remotePlaylist.containsTracks(tracks); + + if (!isPlaylistPlaying) { + await remotePlayback.load( + WebSocketLoadEventData( + tracks: tracks, + initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), + ), + ); + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != remotePlaylist.activeTrack?.id) { + final index = playlist.tracks + .toList() + .indexWhere((s) => s.id == currentTrack!.id); + await remotePlayback.jumpTo(index); + } + } else { + if (!isPlaylistPlaying) { + playlistNotifier.load( + tracks, + initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), + autoPlay: true, + ); + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != playlist.activeTrack?.id) { + await playlistNotifier.jumpToTrack(currentTrack); + } } } @@ -111,6 +135,7 @@ class ArtistPageTopTracks extends HookConsumerWidget { final track = topTracks.elementAt(index); return TrackTile( index: index, + playlist: playlist, track: track, onTap: () async { playPlaylist( diff --git a/lib/pages/connect/connect.dart b/lib/pages/connect/connect.dart new file mode 100644 index 00000000..170a0c72 --- /dev/null +++ b/lib/pages/connect/connect.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/connect/local_devices.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class ConnectPage extends HookConsumerWidget { + const ConnectPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:colorScheme, :textTheme) = Theme.of(context); + + final connectClients = ref.watch(connectClientsProvider); + final connectClientsNotifier = ref.read(connectClientsProvider.notifier); + final discoveredDevices = connectClients.asData?.value.services; + + return Scaffold( + appBar: PageWindowTitleBar( + automaticallyImplyLeading: true, + title: Text(context.l10n.devices), + ), + body: ListTileTheme( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + selectedTileColor: colorScheme.secondary.withOpacity(0.1), + child: Padding( + padding: const EdgeInsets.all(10.0), + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + sliver: SliverToBoxAdapter( + child: Text( + context.l10n.remote, + style: textTheme.titleMedium, + ), + ), + ), + const SliverGap(10), + SliverList.separated( + itemCount: discoveredDevices?.length ?? 0, + separatorBuilder: (context, index) => const Gap(10), + itemBuilder: (context, index) { + final device = discoveredDevices![index]; + final selected = + connectClients.asData?.value.resolvedService?.name == + device.name; + return Card( + child: ListTile( + leading: const Icon(SpotubeIcons.monitor), + title: Text(device.name), + subtitle: selected + ? Text( + "${connectClients.asData?.value.resolvedService?.host}" + ":${connectClients.asData?.value.resolvedService?.port}", + ) + : null, + selected: selected, + onTap: () { + if (selected) { + ServiceUtils.push( + context, + "/connect/control", + ); + } else { + connectClientsNotifier.resolveService(device); + } + }, + trailing: selected + ? IconButton( + icon: const Icon(SpotubeIcons.power), + onPressed: () => + connectClientsNotifier.clearResolvedService(), + ) + : null, + ), + ); + }, + ), + const ConnectPageLocalDevices(), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart new file mode 100644 index 00000000..16256568 --- /dev/null +++ b/lib/pages/connect/control/control.dart @@ -0,0 +1,317 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/player/player_queue.dart'; +import 'package:spotube/components/player/volume_slider.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/anchor_button.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/duration.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/services/audio_player/loop_mode.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class ConnectControlPage extends HookConsumerWidget { + const ConnectControlPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:textTheme, :colorScheme) = Theme.of(context); + + final resolvedService = + ref.watch(connectClientsProvider).asData?.value.resolvedService; + final connectNotifier = ref.read(connectProvider.notifier); + final playlist = ref.watch(queueProvider); + final playing = ref.watch(playingProvider); + final shuffled = ref.watch(shuffleProvider); + final loopMode = ref.watch(loopModeProvider); + + final resumePauseStyle = IconButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + padding: const EdgeInsets.all(12), + iconSize: 24, + ); + final buttonStyle = IconButton.styleFrom( + backgroundColor: colorScheme.surface.withOpacity(0.4), + minimumSize: const Size(28, 28), + ); + + final activeButtonStyle = IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer, + foregroundColor: colorScheme.onPrimaryContainer, + minimumSize: const Size(28, 28), + ); + + final playerQueue = Consumer(builder: (context, ref, _) { + final playlist = ref.watch(queueProvider); + return PlayerQueue( + playlist: playlist, + floating: true, + onJump: (track) async { + final index = playlist.tracks.toList().indexOf(track); + connectNotifier.jumpTo(index); + }, + onRemove: (track) async { + await connectNotifier.removeTrack(track); + }, + onStop: () async => connectNotifier.stop(), + onReorder: (oldIndex, newIndex) async { + await connectNotifier.reorder( + (oldIndex: oldIndex, newIndex: newIndex), + ); + }, + ); + }); + + ref.listen(connectClientsProvider, (prev, next) { + if (next.asData?.value.resolvedService == null) { + context.pop(); + } + }); + + return SafeArea( + child: Scaffold( + appBar: PageWindowTitleBar( + title: Text(resolvedService!.name), + automaticallyImplyLeading: true, + ), + body: LayoutBuilder(builder: (context, constrains) { + return Row( + children: [ + Expanded( + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ).copyWith(top: 0), + constraints: + const BoxConstraints(maxHeight: 400, maxWidth: 400), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: UniversalImage( + path: (playlist.activeTrack?.album?.images) + .asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + fit: BoxFit.cover, + ), + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20), + sliver: SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: AnchorButton( + playlist.activeTrack?.name ?? "", + style: textTheme.titleLarge!, + onTap: () { + ServiceUtils.push( + context, + "/track/${playlist.activeTrack?.id}", + ); + }, + ), + ), + SliverToBoxAdapter( + child: ArtistLink( + artists: playlist.activeTrack?.artists ?? [], + textStyle: textTheme.bodyMedium!, + mainAxisAlignment: WrapAlignment.start, + ), + ), + ], + ), + ), + const SliverGap(30), + SliverToBoxAdapter( + child: Consumer( + builder: (context, ref, _) { + final position = ref.watch(positionProvider); + final duration = ref.watch(durationProvider); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + children: [ + Slider( + value: position > duration + ? 0 + : position.inSeconds.toDouble(), + min: 0, + max: duration.inSeconds.toDouble(), + onChanged: (value) { + connectNotifier + .seek(Duration(seconds: value.toInt())); + }, + ), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text(position.toHumanReadableString()), + Text(duration.toHumanReadableString()), + ], + ), + ], + ), + ); + }, + ), + ), + SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + tooltip: shuffled + ? context.l10n.unshuffle_playlist + : context.l10n.shuffle_playlist, + icon: const Icon(SpotubeIcons.shuffle), + style: shuffled ? activeButtonStyle : buttonStyle, + onPressed: playlist.activeTrack == null + ? null + : () { + connectNotifier.setShuffle(!shuffled); + }, + ), + IconButton( + tooltip: context.l10n.previous_track, + icon: const Icon(SpotubeIcons.skipBack), + onPressed: playlist.activeTrack == null + ? null + : connectNotifier.previous, + ), + IconButton( + tooltip: playing + ? context.l10n.pause_playback + : context.l10n.resume_playback, + icon: playlist.activeTrack == null + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: colorScheme.onPrimary, + ), + ) + : Icon( + playing + ? SpotubeIcons.pause + : SpotubeIcons.play, + ), + style: resumePauseStyle, + onPressed: playlist.activeTrack == null + ? null + : () { + if (playing) { + connectNotifier.pause(); + } else { + connectNotifier.resume(); + } + }, + ), + IconButton( + tooltip: context.l10n.next_track, + icon: const Icon(SpotubeIcons.skipForward), + onPressed: playlist.activeTrack == null + ? null + : connectNotifier.next, + ), + IconButton( + tooltip: loopMode == PlaybackLoopMode.one + ? context.l10n.loop_track + : loopMode == PlaybackLoopMode.all + ? context.l10n.repeat_playlist + : null, + icon: Icon( + loopMode == PlaybackLoopMode.one + ? SpotubeIcons.repeatOne + : SpotubeIcons.repeat, + ), + style: loopMode == PlaybackLoopMode.one || + loopMode == PlaybackLoopMode.all + ? activeButtonStyle + : buttonStyle, + onPressed: playlist.activeTrack == null + ? null + : () async { + connectNotifier.setLoopMode( + switch (loopMode) { + PlaybackLoopMode.all => + PlaybackLoopMode.one, + PlaybackLoopMode.one => + PlaybackLoopMode.none, + PlaybackLoopMode.none => + PlaybackLoopMode.all, + }, + ); + }, + ) + ], + ), + ), + const SliverGap(30), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20), + sliver: SliverToBoxAdapter( + child: Consumer(builder: (context, ref, _) { + final volume = ref.watch(volumeProvider); + return VolumeSlider( + fullWidth: true, + value: volume, + onChanged: (value) { + ref.read(volumeProvider.notifier).state = value; + connectNotifier.setVolume(value); + }, + ); + }), + ), + ), + const SliverGap(30), + if (constrains.mdAndDown) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20), + sliver: SliverToBoxAdapter( + child: OutlinedButton.icon( + icon: const Icon(SpotubeIcons.queue), + label: Text(context.l10n.queue), + onPressed: () { + showModalBottomSheet( + context: context, + builder: (context) { + return playerQueue; + }, + ); + }, + ), + ), + ) + ], + ), + ), + if (constrains.lgAndUp) ...[ + const VerticalDivider(thickness: 1), + Expanded( + child: playerQueue, + ), + ] + ], + ); + }), + ), + ); + } +} diff --git a/lib/pages/desktop_login/desktop_login.dart b/lib/pages/desktop_login/desktop_login.dart index c2cc3695..9c061091 100644 --- a/lib/pages/desktop_login/desktop_login.dart +++ b/lib/pages/desktop_login/desktop_login.dart @@ -9,7 +9,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; class DesktopLoginPage extends HookConsumerWidget { - const DesktopLoginPage({Key? key}) : super(key: key); + const DesktopLoginPage({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/desktop_login/login_tutorial.dart b/lib/pages/desktop_login/login_tutorial.dart index 24373e75..e6a4cf9a 100644 --- a/lib/pages/desktop_login/login_tutorial.dart +++ b/lib/pages/desktop_login/login_tutorial.dart @@ -12,7 +12,7 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/service_utils.dart'; class LoginTutorial extends ConsumerWidget { - const LoginTutorial({Key? key}) : super(key: key); + const LoginTutorial({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/getting_started/getting_started.dart b/lib/pages/getting_started/getting_started.dart index 724fb346..cbab03b9 100644 --- a/lib/pages/getting_started/getting_started.dart +++ b/lib/pages/getting_started/getting_started.dart @@ -8,13 +8,20 @@ import 'package:spotube/pages/getting_started/sections/greeting.dart'; import 'package:spotube/pages/getting_started/sections/playback.dart'; import 'package:spotube/pages/getting_started/sections/region.dart'; import 'package:spotube/pages/getting_started/sections/support.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/themes/theme.dart'; class GettingStarting extends HookConsumerWidget { const GettingStarting({super.key}); @override Widget build(BuildContext context, ref) { - final ThemeData(:colorScheme) = Theme.of(context); + final preferences = ref.watch(userPreferencesProvider); + final themeData = theme( + preferences.accentColorScheme, + Brightness.dark, + preferences.amoledDarkTheme, + ); final pageController = usePageController(); final onNext = useCallback(() { @@ -31,63 +38,66 @@ class GettingStarting extends HookConsumerWidget { ); }, [pageController]); - return Scaffold( - appBar: PageWindowTitleBar( - backgroundColor: Colors.transparent, - actions: [ - ListenableBuilder( - listenable: pageController, - builder: (context, _) { - return AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: pageController.hasClients && - (pageController.page == 0 || pageController.page == 3) - ? const SizedBox() - : TextButton( - onPressed: () { - pageController.animateToPage( - 3, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - child: Text( - context.l10n.skip_this_nonsense, - style: TextStyle( - decoration: TextDecoration.underline, - decorationColor: colorScheme.primary, + return Theme( + data: themeData, + child: Scaffold( + appBar: PageWindowTitleBar( + backgroundColor: Colors.transparent, + actions: [ + ListenableBuilder( + listenable: pageController, + builder: (context, _) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: pageController.hasClients && + (pageController.page == 0 || pageController.page == 3) + ? const SizedBox() + : TextButton( + onPressed: () { + pageController.animateToPage( + 3, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }, + child: Text( + context.l10n.skip_this_nonsense, + style: TextStyle( + decoration: TextDecoration.underline, + decorationColor: themeData.colorScheme.primary, + ), ), ), - ), - ); - }, - ), - ], - ), - extendBodyBehindAppBar: true, - body: DecoratedBox( - decoration: BoxDecoration( - image: DecorationImage( - image: Assets.bengaliPatternsBg.provider(), - fit: BoxFit.cover, - colorFilter: ColorFilter.mode( - colorScheme.background.withOpacity(0.2), - BlendMode.srcOver, + ); + }, ), - ), - ), - child: PageView( - controller: pageController, - children: [ - GettingStartedPageGreetingSection(onNext: onNext), - GettingStartedPageLanguageRegionSection(onNext: onNext), - GettingStartedPagePlaybackSection( - onNext: onNext, - onPrevious: onPrevious, - ), - const GettingStartedScreenSupportSection(), ], ), + extendBodyBehindAppBar: true, + body: DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: Assets.bengaliPatternsBg.provider(), + fit: BoxFit.cover, + colorFilter: const ColorFilter.mode( + Colors.black38, + BlendMode.srcOver, + ), + ), + ), + child: PageView( + controller: pageController, + children: [ + GettingStartedPageGreetingSection(onNext: onNext), + GettingStartedPageLanguageRegionSection(onNext: onNext), + GettingStartedPagePlaybackSection( + onNext: onNext, + onPrevious: onPrevious, + ), + const GettingStartedScreenSupportSection(), + ], + ), + ), ), ); } diff --git a/lib/pages/getting_started/sections/playback.dart b/lib/pages/getting_started/sections/playback.dart index e94a06cc..298cf839 100644 --- a/lib/pages/getting_started/sections/playback.dart +++ b/lib/pages/getting_started/sections/playback.dart @@ -6,6 +6,7 @@ import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/string.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; @@ -87,7 +88,7 @@ class GettingStartedPagePlaybackSection extends HookConsumerWidget { audioSourceToIconMap[source]!, const Gap(8), Text( - source.name, + source.name.capitalize(), style: textTheme.bodySmall!.copyWith( color: preferences.audioSource == source ? colorScheme.primary diff --git a/lib/pages/getting_started/sections/support.dart b/lib/pages/getting_started/sections/support.dart index 1be7ca34..46823425 100644 --- a/lib/pages/getting_started/sections/support.dart +++ b/lib/pages/getting_started/sections/support.dart @@ -101,9 +101,11 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { style: TextButton.styleFrom( foregroundColor: Colors.white, ), - onPressed: () { - KVStoreService.doneGettingStarted = true; - context.go("/"); + onPressed: () async { + await KVStoreService.setDoneGettingStarted(true); + if (context.mounted) { + context.go("/"); + } }, ), ), @@ -115,9 +117,11 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { backgroundColor: const Color(0xff1db954), foregroundColor: Colors.white, ), - onPressed: () { - KVStoreService.doneGettingStarted = true; - context.push("/login"); + onPressed: () async { + await KVStoreService.setDoneGettingStarted(true); + if (context.mounted) { + context.push("/login"); + } }, ), ], diff --git a/lib/pages/home/genres/genre_playlists.dart b/lib/pages/home/genres/genre_playlists.dart index 78f32245..d80b4513 100644 --- a/lib/pages/home/genres/genre_playlists.dart +++ b/lib/pages/home/genres/genre_playlists.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; @@ -12,34 +10,20 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:collection/collection.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; class GenrePlaylistsPage extends HookConsumerWidget { final Category category; - const GenrePlaylistsPage({Key? key, required this.category}) - : super(key: key); + const GenrePlaylistsPage({super.key, required this.category}); @override Widget build(BuildContext context, ref) { - final playlistsQuery = useQueries.category.playlistsOf( - ref, - category.id!, - ); - - final playlists = useMemoized( - () => playlistsQuery.pages.expand( - (page) { - return page.items?.whereNotNull() ?? - const Iterable.empty(); - }, - ).toList(), - [playlistsQuery.pages], - ); - final mediaQuery = MediaQuery.of(context); - + final playlists = ref.watch(categoryPlaylistsProvider(category.id!)); + final playlistsNotifier = + ref.read(categoryPlaylistsProvider(category.id!).notifier); final scrollController = useScrollController(); return Scaffold( @@ -51,123 +35,116 @@ class GenrePlaylistsPage extends HookConsumerWidget { ) : null, extendBodyBehindAppBar: true, - body: CustomScrollView( - controller: scrollController, - slivers: [ - SliverAppBar( - automaticallyImplyLeading: DesktopTools.platform.isMobile, - expandedHeight: mediaQuery.mdAndDown ? 200 : 150, - pinned: true, - floating: false, - title: const Text(""), - backgroundColor: Colors.brown.withOpacity(0.7), - flexibleSpace: FlexibleSpaceBar( - stretchModes: const [ - StretchMode.zoomBackground, - StretchMode.blurBackground, - ], - background: DecoratedBox( - decoration: BoxDecoration( - image: DecorationImage( - image: UniversalImage.imageProvider( - category.icons!.first.url!, - ), - fit: BoxFit.cover, + body: DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: UniversalImage.imageProvider(category.icons!.first.url!), + alignment: Alignment.topCenter, + fit: BoxFit.cover, + colorFilter: ColorFilter.mode( + Colors.black.withOpacity(0.5), + BlendMode.darken, + ), + repeat: ImageRepeat.noRepeat, + matchTextDirection: true, + ), + ), + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverAppBar( + automaticallyImplyLeading: DesktopTools.platform.isMobile, + expandedHeight: mediaQuery.mdAndDown ? 200 : 150, + title: const Text(""), + backgroundColor: Colors.transparent, + flexibleSpace: FlexibleSpaceBar( + centerTitle: DesktopTools.platform.isDesktop, + title: Text( + category.name!, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: Colors.white, + letterSpacing: 3, + shadows: [ + const Shadow( + offset: Offset(-1.5, -1.5), + color: Colors.black54, + ), + const Shadow( + offset: Offset(1.5, -1.5), + color: Colors.black54, + ), + const Shadow( + offset: Offset(1.5, 1.5), + color: Colors.black54, + ), + const Shadow( + offset: Offset(-1.5, 1.5), + color: Colors.black54, + ), + ], ), ), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: const ColoredBox(color: Colors.transparent), - ), + collapseMode: CollapseMode.parallax, ), - centerTitle: DesktopTools.platform.isDesktop, - title: Text( - category.name!, - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: Colors.white, - letterSpacing: 3, - shadows: [ - const Shadow( - offset: Offset(-1.5, -1.5), - color: Colors.black54, - ), - const Shadow( - offset: Offset(1.5, -1.5), - color: Colors.black54, - ), - const Shadow( - offset: Offset(1.5, 1.5), - color: Colors.black54, - ), - const Shadow( - offset: Offset(-1.5, 1.5), - color: Colors.black54, - ), - ], - ), - ), - collapseMode: CollapseMode.parallax, ), - ), - const SliverGap(20), - SliverSafeArea( - top: false, - sliver: SliverPadding( - padding: EdgeInsets.symmetric( - horizontal: mediaQuery.mdAndDown ? 12 : 24, - ), - sliver: playlists.isEmpty - ? Skeletonizer.sliver( - child: SliverToBoxAdapter( - child: Wrap( - spacing: 12, - runSpacing: 12, - children: List.generate( - 6, - (index) => PlaylistCard(FakeData.playlist), + const SliverGap(20), + SliverSafeArea( + top: false, + sliver: SliverPadding( + padding: EdgeInsets.symmetric( + horizontal: mediaQuery.mdAndDown ? 12 : 24, + ), + sliver: playlists.asData?.value.items.isNotEmpty != true + ? Skeletonizer.sliver( + child: SliverToBoxAdapter( + child: Wrap( + spacing: 12, + runSpacing: 12, + children: List.generate( + 6, + (index) => PlaylistCard(FakeData.playlist), + ), ), ), - ), - ) - : SliverGrid.builder( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 190, - mainAxisExtent: mediaQuery.mdAndDown ? 225 : 250, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - ), - itemCount: playlists.length + 1, - itemBuilder: (context, index) { - final playlist = playlists.elementAtOrNull(index); + ) + : SliverGrid.builder( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 190, + mainAxisExtent: mediaQuery.mdAndDown ? 225 : 250, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: + (playlists.asData?.value.items.length ?? 0) + 1, + itemBuilder: (context, index) { + final playlist = playlists.asData?.value.items + .elementAtOrNull(index); - if (playlist == null) { - if (!playlistsQuery.hasNextPage) { - return const SizedBox.shrink(); + if (playlist == null) { + if (playlists.asData?.value.hasMore == false) { + return const SizedBox.shrink(); + } + return Skeletonizer( + enabled: true, + child: Waypoint( + controller: scrollController, + isGrid: true, + onTouchEdge: playlistsNotifier.fetchMore, + child: PlaylistCard(FakeData.playlist), + ), + ); } - return Skeletonizer( - enabled: true, - child: Waypoint( - controller: scrollController, - isGrid: true, - onTouchEdge: () async { - if (playlistsQuery.hasNextPage) { - await playlistsQuery.fetchNext(); - } - }, - child: PlaylistCard(FakeData.playlist), - ), - ); - } - return Skeleton.keep( - child: PlaylistCard(playlist), - ); - }, - ), + return Skeleton.keep( + child: PlaylistCard(playlist), + ); + }, + ), + ), ), - ), - const SliverGap(20), - ], + const SliverGap(20), + ], + ), ), ); } diff --git a/lib/pages/home/genres/genres.dart b/lib/pages/home/genres/genres.dart index dc165fe4..a981cbe7 100644 --- a/lib/pages/home/genres/genres.dart +++ b/lib/pages/home/genres/genres.dart @@ -5,29 +5,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart' hide Offset; import 'package:spotube/collections/gradients.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; - -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class GenrePage extends HookConsumerWidget { - const GenrePage({Key? key}) : super(key: key); + const GenrePage({super.key}); @override Widget build(BuildContext context, ref) { final ThemeData(:textTheme) = Theme.of(context); final scrollController = useScrollController(); - final recommendationMarket = ref.watch( - userPreferencesProvider.select((s) => s.recommendationMarket), - ); - final categoriesQuery = - useQueries.category.listAll(ref, recommendationMarket); - - final categories = categoriesQuery.data ?? []; + final categories = ref.watch(categoriesProvider); final mediaQuery = MediaQuery.of(context); @@ -48,9 +39,9 @@ class GenrePage extends HookConsumerWidget { crossAxisSpacing: 12, mainAxisSpacing: 12, ), - itemCount: categories.length, + itemCount: categories.asData!.value.length, itemBuilder: (context, index) { - final category = categories[index]; + final category = categories.asData!.value[index]; final gradient = gradients[Random().nextInt(gradients.length)]; return InkWell( borderRadius: BorderRadius.circular(8), diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 312ca7f9..487ceb4c 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -3,6 +3,8 @@ import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/connect/connect_device.dart'; import 'package:spotube/components/home/sections/featured.dart'; import 'package:spotube/components/home/sections/friends.dart'; import 'package:spotube/components/home/sections/genres.dart'; @@ -11,7 +13,7 @@ import 'package:spotube/components/home/sections/new_releases.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; class HomePage extends HookConsumerWidget { - const HomePage({Key? key}) : super(key: key); + const HomePage({super.key}); @override Widget build(BuildContext context, ref) { @@ -20,15 +22,21 @@ class HomePage extends HookConsumerWidget { return SafeArea( bottom: false, child: Scaffold( - appBar: - DesktopTools.platform.isLinux || DesktopTools.platform.isWindows - ? const PageWindowTitleBar() - : null, body: CustomScrollView( controller: controller, slivers: [ - if (DesktopTools.platform.isMacOS || DesktopTools.platform.isWeb) - const SliverGap(20), + PageWindowTitleBar.sliver( + pinned: DesktopTools.platform.isDesktop, + actions: [ + const ConnectDeviceButton(), + const Gap(10), + IconButton.filledTonal( + icon: const Icon(SpotubeIcons.user), + onPressed: () {}, + ), + const Gap(10), + ], + ), const HomeGenresSection(), const SliverToBoxAdapter(child: HomeFeaturedSection()), const HomePageFriendsSection(), diff --git a/lib/pages/lastfm_login/lastfm_login.dart b/lib/pages/lastfm_login/lastfm_login.dart index 4280328f..b6aeef2e 100644 --- a/lib/pages/lastfm_login/lastfm_login.dart +++ b/lib/pages/lastfm_login/lastfm_login.dart @@ -10,7 +10,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; class LastFMLoginPage extends HookConsumerWidget { - const LastFMLoginPage({Key? key}) : super(key: key); + const LastFMLoginPage({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/library/library.dart b/lib/pages/library/library.dart index b6b88656..ccdb6a35 100644 --- a/lib/pages/library/library.dart +++ b/lib/pages/library/library.dart @@ -12,7 +12,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; class LibraryPage extends HookConsumerWidget { - const LibraryPage({Key? key}) : super(key: key); + const LibraryPage({super.key}); @override Widget build(BuildContext context, ref) { final downloadingCount = ref.watch(downloadManagerProvider).$downloadCount; diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 802b28d3..5044090d 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -15,16 +15,16 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/models/spotify/recommendation_seeds.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; const RecommendationAttribute zeroValues = (min: 0, target: 0, max: 0); class PlaylistGeneratorPage extends HookConsumerWidget { - const PlaylistGeneratorPage({Key? key}) : super(key: key); + const PlaylistGeneratorPage({super.key}); @override Widget build(BuildContext context, ref) { @@ -34,7 +34,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { final textTheme = theme.textTheme; final preferences = ref.watch(userPreferencesProvider); - final genresCollection = useQueries.category.genreSeeds(ref); + final genresCollection = ref.watch(categoryGenresProvider); final limit = useValueNotifier(10); final market = useValueNotifier(preferences.recommendationMarket); @@ -50,22 +50,9 @@ class PlaylistGeneratorPage extends HookConsumerWidget { 5 - genres.value.length - artists.value.length - tracks.value.length; // Dial (int 0-1) attributes - final acousticness = useState(zeroValues); - final danceability = useState(zeroValues); - final energy = useState(zeroValues); - final instrumentalness = useState(zeroValues); - final key = useState(zeroValues); - final liveness = useState(zeroValues); - final loudness = useState(zeroValues); - final popularity = useState(zeroValues); - final speechiness = useState(zeroValues); - final valence = useState(zeroValues); - - // Field editable attributes - final tempo = useState(zeroValues); - final durationMs = useState(zeroValues); - final mode = useState(zeroValues); - final timeSignature = useState(zeroValues); + final min = useState(RecommendationSeeds()); + final max = useState(RecommendationSeeds()); + final target = useState(RecommendationSeeds()); final artistAutoComplete = SeedsMultiAutocomplete( seeds: artists, @@ -97,8 +84,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { autocompleteOptionBuilder: (option, onSelected) => ListTile( leading: CircleAvatar( backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - option.images, + option.images.asUrlString( placeholder: ImagePlaceholder.artist, ), ), @@ -130,8 +116,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { selectedSeedBuilder: (artist) => Chip( avatar: CircleAvatar( backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - artist.images, + artist.images.asUrlString( placeholder: ImagePlaceholder.artist, ), ), @@ -176,8 +161,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { autocompleteOptionBuilder: (option, onSelected) => ListTile( leading: CircleAvatar( backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - option.album?.images, + (option.album?.images).asUrlString( placeholder: ImagePlaceholder.artist, ), ), @@ -203,7 +187,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { ); final genreSelector = MultiSelectField( - options: genresCollection.data ?? [], + options: genresCollection.asData?.value ?? [], selectedOptions: genres.value, getValueForOption: (option) => option, onSelected: (value) { @@ -355,88 +339,213 @@ class PlaylistGeneratorPage extends HookConsumerWidget { const SizedBox(height: 16), RecommendationAttributeDials( title: Text(context.l10n.acousticness), - values: acousticness.value, + values: ( + target: target.value.acousticness?.toDouble() ?? 0, + min: min.value.acousticness?.toDouble() ?? 0, + max: max.value.acousticness?.toDouble() ?? 0, + ), onChanged: (value) { - acousticness.value = value; + target.value = target.value.copyWith( + acousticness: value.target, + ); + min.value = min.value.copyWith( + acousticness: value.min, + ); + max.value = max.value.copyWith( + acousticness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.danceability), - values: danceability.value, + values: ( + target: target.value.danceability?.toDouble() ?? 0, + min: min.value.danceability?.toDouble() ?? 0, + max: max.value.danceability?.toDouble() ?? 0, + ), onChanged: (value) { - danceability.value = value; + target.value = target.value.copyWith( + danceability: value.target, + ); + min.value = min.value.copyWith( + danceability: value.min, + ); + max.value = max.value.copyWith( + danceability: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.energy), - values: energy.value, + values: ( + target: target.value.energy?.toDouble() ?? 0, + min: min.value.energy?.toDouble() ?? 0, + max: max.value.energy?.toDouble() ?? 0, + ), onChanged: (value) { - energy.value = value; + target.value = target.value.copyWith( + energy: value.target, + ); + min.value = min.value.copyWith( + energy: value.min, + ); + max.value = max.value.copyWith( + energy: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.instrumentalness), - values: instrumentalness.value, + values: ( + target: + target.value.instrumentalness?.toDouble() ?? 0, + min: min.value.instrumentalness?.toDouble() ?? 0, + max: max.value.instrumentalness?.toDouble() ?? 0, + ), onChanged: (value) { - instrumentalness.value = value; + target.value = target.value.copyWith( + instrumentalness: value.target, + ); + min.value = min.value.copyWith( + instrumentalness: value.min, + ); + max.value = max.value.copyWith( + instrumentalness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.liveness), - values: liveness.value, + values: ( + target: target.value.liveness?.toDouble() ?? 0, + min: min.value.liveness?.toDouble() ?? 0, + max: max.value.liveness?.toDouble() ?? 0, + ), onChanged: (value) { - liveness.value = value; + target.value = target.value.copyWith( + liveness: value.target, + ); + min.value = min.value.copyWith( + liveness: value.min, + ); + max.value = max.value.copyWith( + liveness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.loudness), - values: loudness.value, + values: ( + target: target.value.loudness?.toDouble() ?? 0, + min: min.value.loudness?.toDouble() ?? 0, + max: max.value.loudness?.toDouble() ?? 0, + ), onChanged: (value) { - loudness.value = value; + target.value = target.value.copyWith( + loudness: value.target, + ); + min.value = min.value.copyWith( + loudness: value.min, + ); + max.value = max.value.copyWith( + loudness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.speechiness), - values: speechiness.value, + values: ( + target: target.value.speechiness?.toDouble() ?? 0, + min: min.value.speechiness?.toDouble() ?? 0, + max: max.value.speechiness?.toDouble() ?? 0, + ), onChanged: (value) { - speechiness.value = value; + target.value = target.value.copyWith( + speechiness: value.target, + ); + min.value = min.value.copyWith( + speechiness: value.min, + ); + max.value = max.value.copyWith( + speechiness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.valence), - values: valence.value, + values: ( + target: target.value.valence?.toDouble() ?? 0, + min: min.value.valence?.toDouble() ?? 0, + max: max.value.valence?.toDouble() ?? 0, + ), onChanged: (value) { - valence.value = value; + target.value = target.value.copyWith( + valence: value.target, + ); + min.value = min.value.copyWith( + valence: value.min, + ); + max.value = max.value.copyWith( + valence: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.popularity), - values: popularity.value, base: 100, + values: ( + target: target.value.popularity?.toDouble() ?? 0, + min: min.value.popularity?.toDouble() ?? 0, + max: max.value.popularity?.toDouble() ?? 0, + ), onChanged: (value) { - popularity.value = value; + target.value = target.value.copyWith( + popularity: value.target, + ); + min.value = min.value.copyWith( + popularity: value.min, + ); + max.value = max.value.copyWith( + popularity: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.key), - values: key.value, base: 11, + values: ( + target: target.value.key?.toDouble() ?? 0, + min: min.value.key?.toDouble() ?? 0, + max: max.value.key?.toDouble() ?? 0, + ), onChanged: (value) { - key.value = value; + target.value = target.value.copyWith( + key: value.target, + ); + min.value = min.value.copyWith( + key: value.min, + ); + max.value = max.value.copyWith( + key: value.max, + ); }, ), RecommendationAttributeFields( title: Text(context.l10n.duration), values: ( - max: durationMs.value.max / 1000, - target: durationMs.value.target / 1000, - min: durationMs.value.min / 1000, + max: (max.value.durationMs ?? 0) / 1000, + target: (target.value.durationMs ?? 0) / 1000, + min: (min.value.durationMs ?? 0) / 1000, ), onChanged: (value) { - durationMs.value = ( - max: value.max * 1000, - target: value.target * 1000, - min: value.min * 1000, + target.value = target.value.copyWith( + durationMs: (value.target * 1000).toInt(), + ); + min.value = min.value.copyWith( + durationMs: (value.min * 1000).toInt(), + ); + max.value = max.value.copyWith( + durationMs: (value.max * 1000).toInt(), ); }, presets: { @@ -451,23 +560,59 @@ class PlaylistGeneratorPage extends HookConsumerWidget { ), RecommendationAttributeFields( title: Text(context.l10n.tempo), - values: tempo.value, + values: ( + max: max.value.tempo?.toDouble() ?? 0, + target: target.value.tempo?.toDouble() ?? 0, + min: min.value.tempo?.toDouble() ?? 0, + ), onChanged: (value) { - tempo.value = value; + target.value = target.value.copyWith( + tempo: value.target, + ); + min.value = min.value.copyWith( + tempo: value.min, + ); + max.value = max.value.copyWith( + tempo: value.max, + ); }, ), RecommendationAttributeFields( title: Text(context.l10n.mode), - values: mode.value, + values: ( + max: max.value.mode?.toDouble() ?? 0, + target: target.value.mode?.toDouble() ?? 0, + min: min.value.mode?.toDouble() ?? 0, + ), onChanged: (value) { - mode.value = value; + target.value = target.value.copyWith( + mode: value.target, + ); + min.value = min.value.copyWith( + mode: value.min, + ); + max.value = max.value.copyWith( + mode: value.max, + ); }, ), RecommendationAttributeFields( title: Text(context.l10n.time_signature), - values: timeSignature.value, + values: ( + max: max.value.timeSignature?.toDouble() ?? 0, + target: target.value.timeSignature?.toDouble() ?? 0, + min: min.value.timeSignature?.toDouble() ?? 0, + ), onChanged: (value) { - timeSignature.value = value; + target.value = target.value.copyWith( + timeSignature: value.target, + ); + min.value = min.value.copyWith( + timeSignature: value.min, + ); + max.value = max.value.copyWith( + timeSignature: value.max, + ); }, ), const SizedBox(height: 20), @@ -479,35 +624,18 @@ class PlaylistGeneratorPage extends HookConsumerWidget { genres.value.isEmpty ? null : () { - final PlaylistGenerateResultRouteState - routeState = ( - seeds: ( - artists: artists.value - .map((a) => a.id!) - .toList(), - tracks: tracks.value - .map((t) => t.id!) - .toList(), - genres: genres.value - ), - market: market.value, + final routeState = + GeneratePlaylistProviderInput( + seedArtists: artists.value + .map((a) => a.id!) + .toList(), + seedTracks: + tracks.value.map((t) => t.id!).toList(), + seedGenres: genres.value, limit: limit.value, - parameters: ( - acousticness: acousticness.value, - danceability: danceability.value, - energy: energy.value, - instrumentalness: instrumentalness.value, - liveness: liveness.value, - loudness: loudness.value, - speechiness: speechiness.value, - valence: valence.value, - popularity: popularity.value, - key: key.value, - duration_ms: durationMs.value, - tempo: tempo.value, - mode: mode.value, - time_signature: timeSignature.value, - ) + max: max.value, + min: min.value, + target: target.value, ); GoRouter.of(context).push( "/library/generate/result", diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index f751b65b..5390c337 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -1,4 +1,3 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; @@ -10,249 +9,226 @@ import 'package:spotube/components/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/services/queries/playlist.dart'; -import 'package:spotube/services/queries/queries.dart'; - -typedef PlaylistGenerateResultRouteState = ({ - ({List tracks, List artists, List genres})? seeds, - RecommendationParameters? parameters, - int limit, - Market? market, -}); +import 'package:spotube/provider/spotify/spotify.dart'; class PlaylistGenerateResultPage extends HookConsumerWidget { - final PlaylistGenerateResultRouteState state; + final GeneratePlaylistProviderInput state; const PlaylistGenerateResultPage({ - Key? key, + super.key, required this.state, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { final router = GoRouter.of(context); final scaffoldMessenger = ScaffoldMessenger.of(context); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final (:seeds, :parameters, :limit, :market) = state; - final queryClient = useQueryClient(); - final generatedPlaylist = useQueries.playlist.generate( - ref, - seeds: seeds, - parameters: parameters, - limit: limit, - market: market, - ); + final generatedPlaylist = ref.watch(generatePlaylistProvider(state)); final selectedTracks = useState>( - generatedPlaylist.data?.map((e) => e.id!).toList() ?? [], + generatedPlaylist.asData?.value.map((e) => e.id!).toList() ?? [], ); useEffect(() { - if (generatedPlaylist.data != null) { + if (generatedPlaylist.asData?.value != null) { selectedTracks.value = - generatedPlaylist.data!.map((e) => e.id!).toList(); + generatedPlaylist.asData!.value.map((e) => e.id!).toList(); } return null; - }, [generatedPlaylist.data]); + }, [generatedPlaylist.asData?.value]); - final isAllTrackSelected = - selectedTracks.value.length == (generatedPlaylist.data?.length ?? 0); + final isAllTrackSelected = selectedTracks.value.length == + (generatedPlaylist.asData?.value.length ?? 0); - return WillPopScope( - onWillPop: () async { - queryClient.cache.removeQuery(generatedPlaylist); - return true; - }, - child: Scaffold( - appBar: const PageWindowTitleBar(leading: BackButton()), - body: generatedPlaylist.isLoading - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - Text(context.l10n.generating_playlist), - ], - ), - ) - : Padding( - padding: const EdgeInsets.all(8.0), - child: ListView( - children: [ - GridView( - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - mainAxisExtent: 32, + return Scaffold( + appBar: const PageWindowTitleBar(leading: BackButton()), + body: generatedPlaylist.isLoading + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + Text(context.l10n.generating_playlist), + ], + ), + ) + : Padding( + padding: const EdgeInsets.all(8.0), + child: ListView( + children: [ + GridView( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + mainAxisExtent: 32, + ), + shrinkWrap: true, + children: [ + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.play), + label: Text(context.l10n.play), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + await playlistNotifier.load( + generatedPlaylist.asData!.value.where( + (e) => selectedTracks.value.contains(e.id!), + ), + autoPlay: true, + ); + }, ), - shrinkWrap: true, + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.queueAdd), + label: Text(context.l10n.add_to_queue), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + await playlistNotifier.addTracks( + generatedPlaylist.asData!.value.where( + (e) => selectedTracks.value.contains(e.id!), + ), + ); + if (context.mounted) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.l10n.add_count_to_queue( + selectedTracks.value.length, + ), + ), + ), + ); + } + }, + ), + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.addFilled), + label: Text(context.l10n.create_a_playlist), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + final playlist = await showDialog( + context: context, + builder: (context) => PlaylistCreateDialog( + trackIds: selectedTracks.value, + ), + ); + + if (playlist != null) { + router.go( + '/playlist/${playlist.id}', + extra: playlist, + ); + } + }, + ), + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.playlistAdd), + label: Text(context.l10n.add_to_playlist), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + final hasAdded = await showDialog( + context: context, + builder: (context) => PlaylistAddTrackDialog( + openFromPlaylist: null, + tracks: selectedTracks.value + .map( + (e) => generatedPlaylist.asData!.value + .firstWhere( + (element) => element.id == e, + ), + ) + .toList(), + ), + ); + + if (context.mounted && hasAdded == true) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.l10n.add_count_to_playlist( + selectedTracks.value.length, + ), + ), + ), + ); + } + }, + ) + ], + ), + const SizedBox(height: 16), + if (generatedPlaylist.asData?.value != null) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.play), - label: Text(context.l10n.play), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - await playlistNotifier.load( - generatedPlaylist.data!.where( - (e) => - selectedTracks.value.contains(e.id!), - ), - autoPlay: true, - ); - }, + Text( + context.l10n.selected_count_tracks( + selectedTracks.value.length, + ), ), - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.queueAdd), - label: Text(context.l10n.add_to_queue), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - await playlistNotifier.addTracks( - generatedPlaylist.data!.where( - (e) => - selectedTracks.value.contains(e.id!), - ), - ); - if (context.mounted) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.add_count_to_queue( - selectedTracks.value.length, - ), - ), - ), - ); - } - }, + ElevatedButton.icon( + onPressed: () { + if (isAllTrackSelected) { + selectedTracks.value = []; + } else { + selectedTracks.value = generatedPlaylist + .asData?.value + .map((e) => e.id!) + .toList() ?? + []; + } + }, + icon: const Icon(SpotubeIcons.selectionCheck), + label: Text( + isAllTrackSelected + ? context.l10n.deselect_all + : context.l10n.select_all, + ), ), - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.addFilled), - label: Text(context.l10n.create_a_playlist), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - final playlist = await showDialog( - context: context, - builder: (context) => PlaylistCreateDialog( - trackIds: selectedTracks.value, - ), - ); - - if (playlist != null) { - router.go( - '/playlist/${playlist.id}', - extra: playlist, - ); - } - }, - ), - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.playlistAdd), - label: Text(context.l10n.add_to_playlist), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - final hasAdded = await showDialog( - context: context, - builder: (context) => - PlaylistAddTrackDialog( - openFromPlaylist: null, - tracks: selectedTracks.value - .map( - (e) => generatedPlaylist.data! - .firstWhere( - (element) => element.id == e, - ), - ) - .toList(), - ), - ); - - if (context.mounted && hasAdded == true) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.add_count_to_playlist( - selectedTracks.value.length, - ), - ), - ), - ); - } - }, - ) ], ), - const SizedBox(height: 16), - if (generatedPlaylist.data != null) - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + const SizedBox(height: 8), + Card( + margin: const EdgeInsets.all(0), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Text( - context.l10n.selected_count_tracks( - selectedTracks.value.length, - ), - ), - ElevatedButton.icon( - onPressed: () { - if (isAllTrackSelected) { - selectedTracks.value = []; - } else { - selectedTracks.value = generatedPlaylist.data - ?.map((e) => e.id!) - .toList() ?? - []; - } - }, - icon: const Icon(SpotubeIcons.selectionCheck), - label: Text( - isAllTrackSelected - ? context.l10n.deselect_all - : context.l10n.select_all, - ), - ), + for (final track + in generatedPlaylist.asData?.value ?? []) + CheckboxListTile( + value: selectedTracks.value.contains(track.id), + onChanged: (value) { + if (value == true) { + selectedTracks.value.add(track.id!); + } else { + selectedTracks.value.remove(track.id); + } + selectedTracks.value = + selectedTracks.value.toList(); + }, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + dense: true, + title: SimpleTrackTile(track: track), + ) ], ), - const SizedBox(height: 8), - Card( - margin: const EdgeInsets.all(0), - child: SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final track in generatedPlaylist.data ?? []) - CheckboxListTile( - value: selectedTracks.value.contains(track.id), - onChanged: (value) { - if (value == true) { - selectedTracks.value.add(track.id!); - } else { - selectedTracks.value.remove(track.id); - } - selectedTracks.value = - selectedTracks.value.toList(); - }, - controlAffinity: - ListTileControlAffinity.leading, - contentPadding: EdgeInsets.zero, - dense: true, - title: SimpleTrackTile(track: track), - ) - ], - ), - ), ), - ], - ), + ), + ], ), - ), + ), ); } } diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index ac4b61e7..a0db7178 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -11,6 +12,7 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/themed_button_tab_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/pages/lyrics/plain_lyrics.dart'; @@ -18,18 +20,17 @@ import 'package:spotube/pages/lyrics/synced_lyrics.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class LyricsPage extends HookConsumerWidget { final bool isModal; - const LyricsPage({Key? key, this.isModal = false}) : super(key: key); + const LyricsPage({super.key, this.isModal = false}); @override Widget build(BuildContext context, ref) { final playlist = ref.watch(ProxyPlaylistNotifier.provider); String albumArt = useMemoized( - () => TypeConversionUtils.image_X_UrlString( - playlist.activeTrack?.album?.images, + () => (playlist.activeTrack?.album?.images).asUrlString( index: (playlist.activeTrack?.album?.images?.length ?? 1) - 1, placeholder: ImagePlaceholder.albumArt, ), @@ -44,13 +45,41 @@ class LyricsPage extends HookConsumerWidget { noSetBGColor: true, ); - final tabbar = ThemedButtonsTabBar( + PreferredSizeWidget tabbar = ThemedButtonsTabBar( tabs: [ Tab(text: " ${context.l10n.synced} "), Tab(text: " ${context.l10n.plain} "), ], ); + tabbar = PreferredSize( + preferredSize: tabbar.preferredSize, + child: Row( + children: [ + tabbar, + const Spacer(), + Consumer( + builder: (context, ref, child) { + final playback = ref.watch(ProxyPlaylistNotifier.provider); + final lyric = + ref.watch(syncedLyricsProvider(playback.activeTrack)); + final providerName = lyric.asData?.value.provider; + + if (providerName == null) { + return const SizedBox.shrink(); + } + + return Align( + alignment: Alignment.bottomRight, + child: Text("Powered by $providerName"), + ); + }, + ), + const Gap(5), + ], + ), + ); + final auth = ref.watch(AuthenticationNotifier.provider); if (auth == null) { diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index 2cf73728..310df75c 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -21,7 +21,7 @@ import 'package:spotube/utils/platform.dart'; class MiniLyricsPage extends HookConsumerWidget { final Size prevSize; - const MiniLyricsPage({Key? key, required this.prevSize}) : super(key: key); + const MiniLyricsPage({super.key, required this.prevSize}); @override Widget build(BuildContext context, ref) { @@ -221,7 +221,18 @@ class MiniLyricsPage extends HookConsumerWidget { MediaQuery.of(context).size.height * .7, ), builder: (context) { - return const PlayerQueue(floating: true); + return Consumer(builder: (context, ref, _) { + final playlist = ref + .watch(ProxyPlaylistNotifier.provider); + + return PlayerQueue + .fromProxyPlaylistNotifier( + floating: true, + playlist: playlist, + notifier: ref + .read(ProxyPlaylistNotifier.notifier), + ); + }); }, ); } diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index bee5114d..2c0df0aa 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -4,17 +4,15 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/lyrics/zoom_controls.dart'; import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; - -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class PlainLyrics extends HookConsumerWidget { final PaletteColor palette; @@ -24,14 +22,13 @@ class PlainLyrics extends HookConsumerWidget { required this.palette, this.isModal, this.defaultTextZoom = 100, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final lyricsQuery = - useQueries.lyrics.spotifySynced(ref, playlist.activeTrack); + final lyricsQuery = ref.watch(syncedLyricsProvider(playlist.activeTrack)); final mediaQuery = MediaQuery.of(context); final textTheme = Theme.of(context).textTheme; @@ -56,8 +53,7 @@ class PlainLyrics extends HookConsumerWidget { ), Center( child: Text( - TypeConversionUtils.artists_X_String( - playlist.activeTrack?.artists ?? []), + playlist.activeTrack?.artists?.asString() ?? "", style: (mediaQuery.mdAndUp ? textTheme.headlineSmall : textTheme.titleLarge) @@ -96,9 +92,9 @@ class PlainLyrics extends HookConsumerWidget { } final lyrics = - lyricsQuery.data?.lyrics.mapIndexed((i, e) { - final next = - lyricsQuery.data?.lyrics.elementAtOrNull(i + 1); + lyricsQuery.asData?.value.lyrics.mapIndexed((i, e) { + final next = lyricsQuery.asData?.value.lyrics + .elementAtOrNull(i + 1); if (next != null && e.time - next.time > const Duration(milliseconds: 700)) { @@ -123,6 +119,7 @@ class PlainLyrics extends HookConsumerWidget { lyrics == null && playlist.activeTrack == null ? "No Track being played currently" : lyrics ?? "", + textAlign: TextAlign.center, ), ); }, diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index ddef1c65..52824f5e 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -1,26 +1,27 @@ +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; -import 'package:spotify/spotify.dart' hide Offset; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/lyrics/zoom_controls.dart'; import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; import 'package:spotube/components/lyrics/use_synced_lyrics.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:stroke_text/stroke_text.dart'; -final _delay = StateProvider((ref) => 0); - class SyncedLyrics extends HookConsumerWidget { final PaletteColor palette; final bool? isModal; @@ -30,8 +31,8 @@ class SyncedLyrics extends HookConsumerWidget { required this.palette, this.isModal, this.defaultTextZoom = 100, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { @@ -40,28 +41,18 @@ class SyncedLyrics extends HookConsumerWidget { final mediaQuery = MediaQuery.of(context); final controller = useAutoScrollController(); - final delay = ref.watch(_delay); + final delay = ref.watch(syncedLyricsDelayProvider); final timedLyricsQuery = - useQueries.lyrics.spotifySynced(ref, playlist.activeTrack); + ref.watch(syncedLyricsProvider(playlist.activeTrack)); - final lyricValue = timedLyricsQuery.data; + final lyricValue = timedLyricsQuery.asData?.value; - final isUnSyncLyric = useMemoized( - () => lyricValue?.lyrics.every((l) => l.time == Duration.zero), - [lyricValue], + final lyricsState = ref.watch( + syncedLyricsMapProvider(playlist.activeTrack), ); - - final lyricsMap = useMemoized( - () => - lyricValue?.lyrics - .map((lyric) => {lyric.time.inSeconds: lyric.text}) - .reduce((accumulator, lyricSlice) => - {...accumulator, ...lyricSlice}) ?? - {}, - [lyricValue], - ); - final currentTime = useSyncedLyrics(ref, lyricsMap, delay); + final currentTime = + useSyncedLyrics(ref, lyricsState.asData?.value.lyricsMap ?? {}, delay); final textZoomLevel = useState(defaultTextZoom); final textTheme = Theme.of(context).textTheme; @@ -70,7 +61,7 @@ class SyncedLyrics extends HookConsumerWidget { ProxyPlaylistNotifier.provider.select((s) => s.activeTrack), (previous, next) { controller.scrollToIndex(0); - ref.read(_delay.notifier).state = 0; + ref.read(syncedLyricsDelayProvider.notifier).state = 0; }, ); @@ -84,126 +75,128 @@ class SyncedLyrics extends HookConsumerWidget { ); return Stack( children: [ - Column( - children: [ + CustomScrollView( + controller: controller, + slivers: [ if (isModal != true) - Center( - child: Text( + SliverAppBar( + automaticallyImplyLeading: false, + backgroundColor: Colors.transparent, + centerTitle: true, + title: Text( playlist.activeTrack?.name ?? "Not Playing", style: headlineTextStyle, ), - ), - if (isModal != true) - Center( - child: Text( - TypeConversionUtils.artists_X_String( - playlist.activeTrack?.artists ?? []), - style: mediaQuery.mdAndUp - ? textTheme.headlineSmall - : textTheme.titleLarge, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(40), + child: Text( + playlist.activeTrack?.artists?.asString() ?? "", + style: mediaQuery.mdAndUp + ? textTheme.headlineSmall + : textTheme.titleLarge, + ), ), ), if (lyricValue != null && lyricValue.lyrics.isNotEmpty && - isUnSyncLyric == false) - Expanded( - child: ListView.builder( - controller: controller, - itemCount: lyricValue.lyrics.length, - itemBuilder: (context, index) { - final lyricSlice = lyricValue.lyrics[index]; - final isActive = lyricSlice.time.inSeconds == currentTime; + lyricsState.asData?.value.static != true) + SliverList.builder( + itemCount: lyricValue.lyrics.length, + itemBuilder: (context, index) { + final lyricSlice = lyricValue.lyrics[index]; + final isActive = lyricSlice.time.inSeconds == currentTime; - if (isActive) { - controller.scrollToIndex( - index, - preferPosition: AutoScrollPosition.middle, - ); - } - return AutoScrollTag( - key: ValueKey(index), - index: index, - controller: controller, - child: lyricSlice.text.isEmpty - ? Container( + if (isActive) { + controller.scrollToIndex( + index, + preferPosition: AutoScrollPosition.middle, + ); + } + return AutoScrollTag( + key: ValueKey(index), + index: index, + controller: controller, + child: lyricSlice.text.isEmpty + ? Container( + padding: index == lyricValue.lyrics.length - 1 + ? EdgeInsets.only( + bottom: mediaQuery.size.height / 2, + ) + : null, + ) + : Center( + child: Padding( padding: index == lyricValue.lyrics.length - 1 - ? EdgeInsets.only( - bottom: mediaQuery.size.height / 2, + ? const EdgeInsets.all(8.0).copyWith( + bottom: 100, ) - : null, - ) - : Center( - child: Padding( - padding: index == lyricValue.lyrics.length - 1 - ? const EdgeInsets.all(8.0).copyWith( - bottom: 100, - ) - : const EdgeInsets.all(8.0), - child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 250), - style: TextStyle( - fontWeight: isActive - ? FontWeight.w500 - : FontWeight.normal, - fontSize: (isActive ? 28 : 26) * - (textZoomLevel.value / 100), - ), - textAlign: TextAlign.center, - child: InkWell( - onTap: () async { - final duration = - await audioPlayer.duration ?? - Duration.zero; - final time = Duration( - seconds: - lyricSlice.time.inSeconds - delay, - ); - if (time > duration || time.isNegative) { - return; - } - audioPlayer.seek(time); - }, - child: Builder(builder: (context) { - return StrokeText( - text: lyricSlice.text, - textStyle: - DefaultTextStyle.of(context).style, - textColor: isActive - ? Colors.white - : palette.bodyTextColor, - strokeColor: isActive - ? Colors.black - : Colors.transparent, - ); - }), - ), + : const EdgeInsets.all(8.0), + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 250), + style: TextStyle( + fontWeight: isActive + ? FontWeight.w500 + : FontWeight.normal, + fontSize: (isActive ? 28 : 26) * + (textZoomLevel.value / 100), + ), + textAlign: TextAlign.center, + child: InkWell( + onTap: () async { + final duration = + await audioPlayer.duration ?? + Duration.zero; + final time = Duration( + seconds: + lyricSlice.time.inSeconds - delay, + ); + if (time > duration || time.isNegative) { + return; + } + audioPlayer.seek(time); + }, + child: Builder(builder: (context) { + return StrokeText( + text: lyricSlice.text, + textStyle: + DefaultTextStyle.of(context).style, + textColor: isActive + ? Colors.white + : palette.bodyTextColor, + strokeColor: isActive + ? Colors.black + : Colors.transparent, + ); + }), ), ), ), - ); - }, - ), + ), + ); + }, ), if (playlist.activeTrack != null && (timedLyricsQuery.isLoading || timedLyricsQuery.isRefreshing)) - const Expanded( - child: ShimmerLyrics(), - ) + const SliverToBoxAdapter(child: ShimmerLyrics()) else if (playlist.activeTrack != null && (timedLyricsQuery.hasError)) ...[ - Container( - alignment: Alignment.center, - padding: const EdgeInsets.all(16), - child: Text( - context.l10n.no_lyrics_available, - style: bodyTextTheme, - textAlign: TextAlign.center, + SliverToBoxAdapter( + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.all(16), + child: Text( + context.l10n.no_lyrics_available, + style: bodyTextTheme, + textAlign: TextAlign.center, + ), ), ), - const Gap(26), - const Icon(SpotubeIcons.noLyrics, size: 60), - ] else if (isUnSyncLyric == true) - Expanded( + const SliverGap(26), + const SliverToBoxAdapter( + child: Icon(SpotubeIcons.noLyrics, size: 60), + ), + ] else if (lyricsState.asData?.value.static == true) + SliverFillRemaining( child: Center( child: RichText( textAlign: TextAlign.center, @@ -235,7 +228,8 @@ class SyncedLyrics extends HookConsumerWidget { final actions = [ ZoomControls( value: delay, - onChanged: (value) => ref.read(_delay.notifier).state = value, + onChanged: (value) => + ref.read(syncedLyricsDelayProvider.notifier).state = value, interval: 1, unit: "s", increaseIcon: const Icon(SpotubeIcons.add), diff --git a/lib/pages/mobile_login/mobile_login.dart b/lib/pages/mobile_login/mobile_login.dart index 8b9bce4c..6260e284 100644 --- a/lib/pages/mobile_login/mobile_login.dart +++ b/lib/pages/mobile_login/mobile_login.dart @@ -8,7 +8,7 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/platform.dart'; class WebViewLogin extends HookConsumerWidget { - const WebViewLogin({Key? key}) : super(key: key); + const WebViewLogin({super.key}); @override Widget build(BuildContext context, ref) { @@ -27,19 +27,17 @@ class WebViewLogin extends HookConsumerWidget { return Scaffold( body: SafeArea( child: InAppWebView( - initialOptions: InAppWebViewGroupOptions( - crossPlatform: InAppWebViewOptions( - userAgent: - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 afari/537.36", - ), + initialSettings: InAppWebViewSettings( + userAgent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 afari/537.36", ), initialUrlRequest: URLRequest( - url: Uri.parse("https://accounts.spotify.com/"), + url: WebUri("https://accounts.spotify.com/"), ), - androidOnPermissionRequest: (controller, origin, resources) async { - return PermissionRequestResponse( - resources: resources, - action: PermissionRequestResponseAction.GRANT, + onPermissionRequest: (controller, permissionRequest) async { + return PermissionResponse( + resources: permissionRequest.resources, + action: PermissionResponseAction.GRANT, ); }, onLoadStop: (controller, action) async { diff --git a/lib/pages/playlist/liked_playlist.dart b/lib/pages/playlist/liked_playlist.dart index 1fb2e1dc..72983518 100644 --- a/lib/pages/playlist/liked_playlist.dart +++ b/lib/pages/playlist/liked_playlist.dart @@ -3,19 +3,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/tracks_view/track_view.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class LikedPlaylistPage extends HookConsumerWidget { final PlaylistSimple playlist; const LikedPlaylistPage({ - Key? key, + super.key, required this.playlist, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { - final likedTracks = useQueries.playlist.likedTracksQuery(ref); - final tracks = likedTracks.data ?? []; + final likedTracks = ref.watch(likedTracksProvider); + final tracks = likedTracks.asData?.value ?? []; return InheritedTrackView( collectionId: playlist.id!, @@ -28,7 +28,7 @@ class LikedPlaylistPage extends HookConsumerWidget { return tracks.toList(); }, onRefresh: () async { - await likedTracks.refresh(); + ref.invalidate(likedTracksProvider); }, ), title: playlist.name!, diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index 89a279ab..d9d224e0 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; @@ -7,91 +6,69 @@ import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_ import 'package:spotube/components/shared/tracks_view/track_view.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/infinite_query.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class PlaylistPage extends HookConsumerWidget { final PlaylistSimple playlist; const PlaylistPage({ - Key? key, + super.key, required this.playlist, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { - final spotify = ref.watch(spotifyProvider); - final tracksQuery = useQueries.playlist.tracksOfQuery(ref, playlist.id!); - - final tracks = useMemoized( - () { - return tracksQuery.pages.expand((page) => page).toList(); - }, - [tracksQuery.pages], - ); - - final me = useQueries.user.me(ref); - - final isLikedQuery = useQueries.playlist.doesUserFollow( - ref, - playlist.id!, - me.data?.id ?? '', - ); - - final togglePlaylistLike = useMutations.playlist.toggleFavorite( - ref, - playlist.id!, - refreshQueries: [ - isLikedQuery.key, - ], - ); + final tracks = ref.watch(playlistTracksProvider(playlist.id!)); + final tracksNotifier = + ref.watch(playlistTracksProvider(playlist.id!).notifier); + final isFavoritePlaylist = + ref.watch(isFavoritePlaylistProvider(playlist.id!)); + final favoritePlaylistsNotifier = + ref.watch(favoritePlaylistsProvider.notifier); final isUserPlaylist = useIsUserPlaylist(ref, playlist.id!); return InheritedTrackView( collectionId: playlist.id!, - image: TypeConversionUtils.image_X_UrlString( - playlist.images, + image: playlist.images.asUrlString( placeholder: ImagePlaceholder.collection, ), - pagination: PaginationProps.fromQuery( - tracksQuery, - onFetchAll: () { - return tracksQuery.fetchAllTracks( - getAllTracks: () async { - final res = await spotify.playlists - .getTracksByPlaylistId(playlist.id!) - .all(); - return res.toList(); - }, - ); + pagination: PaginationProps( + hasNextPage: tracks.asData?.value.hasMore ?? false, + isLoading: tracks.isLoadingNextPage, + onFetchMore: tracksNotifier.fetchMore, + onRefresh: () async { + ref.invalidate(playlistTracksProvider(playlist.id!)); + }, + onFetchAll: () async { + return await tracksNotifier.fetchAll(); }, ), title: playlist.name!, description: playlist.description, - tracks: tracks, + tracks: tracks.asData?.value.items ?? [], routePath: '/playlist/${playlist.id}', - isLiked: isLikedQuery.data ?? false, + isLiked: isFavoritePlaylist.asData?.value ?? false, shareUrl: playlist.externalUrls?.spotify ?? "", - onHeart: () async { - if (!isLikedQuery.hasData || togglePlaylistLike.isMutating) { - return false; - } - final confirmed = isUserPlaylist - ? await showPromptDialog( - context: context, - title: context.l10n.delete_playlist, - message: context.l10n.delete_playlist_confirmation, - ) - : true; - if (confirmed) { - await togglePlaylistLike.mutate(isLikedQuery.data!); - return isUserPlaylist; - } - return null; - }, + onHeart: isFavoritePlaylist.asData?.value == null + ? null + : () async { + final confirmed = isUserPlaylist + ? await showPromptDialog( + context: context, + title: context.l10n.delete_playlist, + message: context.l10n.delete_playlist_confirmation, + ) + : true; + if (!confirmed) return null; + + if (isFavoritePlaylist.asData!.value) { + await favoritePlaylistsNotifier.removeFavorite(playlist); + } else { + await favoritePlaylistsNotifier.addFavorite(playlist); + } + return isUserPlaylist; + }, child: const TrackView(), ); } diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index aaf3e30a..2e079200 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; @@ -17,7 +16,10 @@ import 'package:spotube/components/root/spotube_navigation_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart'; import 'package:spotube/hooks/configurators/use_update_checker.dart'; +import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; const rootPaths = { @@ -31,8 +33,8 @@ class RootApp extends HookConsumerWidget { final Widget child; const RootApp({ required this.child, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { @@ -53,49 +55,75 @@ class RootApp extends HookConsumerWidget { } }); - final subscription = - QueryClient.connectivity.onConnectivityChanged.listen((status) { - if (status) { + final subscriptions = [ + ConnectionCheckerService.instance.onConnectivityChanged + .listen((status) { + if (status) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Row( + children: [ + Icon( + SpotubeIcons.wifi, + color: theme.colorScheme.onPrimary, + ), + const SizedBox(width: 10), + Text(context.l10n.connection_restored), + ], + ), + backgroundColor: theme.colorScheme.primary, + showCloseIcon: true, + width: 350, + ), + ); + } else { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Row( + children: [ + Icon( + SpotubeIcons.noWifi, + color: theme.colorScheme.onError, + ), + const SizedBox(width: 10), + Text(context.l10n.you_are_offline), + ], + ), + backgroundColor: theme.colorScheme.error, + showCloseIcon: true, + width: 300, + ), + ); + } + }), + connectClientStream.listen((clientOrigin) { scaffoldMessenger.showSnackBar( SnackBar( + backgroundColor: Colors.yellow[600], + behavior: SnackBarBehavior.floating, content: Row( + mainAxisSize: MainAxisSize.min, children: [ - Icon( - SpotubeIcons.wifi, - color: theme.colorScheme.onPrimary, + const Icon( + SpotubeIcons.error, + color: Colors.black, ), const SizedBox(width: 10), - Text(context.l10n.connection_restored), - ], - ), - backgroundColor: theme.colorScheme.primary, - showCloseIcon: true, - width: 350, - ), - ); - } else { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Row( - children: [ - Icon( - SpotubeIcons.noWifi, - color: theme.colorScheme.onError, + Text( + context.l10n.connect_client_alert(clientOrigin), + style: const TextStyle(color: Colors.black), ), - const SizedBox(width: 10), - Text(context.l10n.you_are_offline), ], ), - backgroundColor: theme.colorScheme.error, - showCloseIcon: true, - width: 300, ), ); - } - }); + }) + ]; return () { - subscription.cancel(); + for (final subscription in subscriptions) { + subscription.cancel(); + } }; }, []); @@ -190,7 +218,19 @@ class RootApp extends HookConsumerWidget { top: 40, bottom: 100, ), - child: const PlayerQueue(floating: true), + child: Consumer( + builder: (context, ref, _) { + final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlistNotifier = + ref.read(ProxyPlaylistNotifier.notifier); + + return PlayerQueue.fromProxyPlaylistNotifier( + floating: true, + playlist: playlist, + notifier: playlistNotifier, + ); + }, + ), ) : null, bottomNavigationBar: Column( diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index f4a78d4f..c58b8df3 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -1,7 +1,10 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; @@ -11,64 +14,45 @@ import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/hooks/utils/use_force_update.dart'; import 'package:spotube/pages/search/sections/albums.dart'; import 'package:spotube/pages/search/sections/artists.dart'; import 'package:spotube/pages/search/sections/playlists.dart'; import 'package:spotube/pages/search/sections/tracks.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:collection/collection.dart'; - -final searchTermStateProvider = StateProvider((ref) => ""); class SearchPage extends HookConsumerWidget { - const SearchPage({Key? key}) : super(key: key); + const SearchPage({super.key}); @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); + final searchTerm = ref.watch(searchTermStateProvider); + final controller = useSearchController(); + ref.watch(AuthenticationNotifier.provider); final authenticationNotifier = ref.watch(AuthenticationNotifier.provider.notifier); final mediaQuery = MediaQuery.of(context); - final searchTerm = ref.watch(searchTermStateProvider); - - final searchTrack = - useQueries.search.query(ref, searchTerm, SearchType.track); - final searchAlbum = - useQueries.search.query(ref, searchTerm, SearchType.album); - final searchPlaylist = - useQueries.search.query(ref, searchTerm, SearchType.playlist); - final searchArtist = - useQueries.search.query(ref, searchTerm, SearchType.artist); - - Future onSearch() async { - await Future.wait([ - searchTrack.reset(), - searchAlbum.reset(), - searchPlaylist.reset(), - searchArtist.reset(), - ]).then((_) { - return Future.wait([ - searchTrack.refreshAll(), - searchAlbum.refreshAll(), - searchPlaylist.refreshAll(), - searchArtist.refreshAll(), - ]); - }); - } + final searchTrack = ref.watch(searchProvider(SearchType.track)); + final searchAlbum = ref.watch(searchProvider(SearchType.album)); + final searchPlaylist = ref.watch(searchProvider(SearchType.playlist)); + final searchArtist = ref.watch(searchProvider(SearchType.artist)); final queries = [searchTrack, searchAlbum, searchPlaylist, searchArtist]; - final isFetching = queries.every( - (s) => - (!s.hasPageData && !s.hasPageError) || - s.isRefreshingPage || - !s.hasPageData, - ) && - searchTerm.isNotEmpty; + + final isFetching = queries.every((s) => s.isLoading); + + useEffect(() { + controller.text = searchTerm; + + return null; + }, []); final resultWidget = HookBuilder( builder: (context) { @@ -78,18 +62,18 @@ class SearchPage extends HookConsumerWidget { controller: controller, child: SingleChildScrollView( controller: controller, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 8), child: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SearchTracksSection(query: searchTrack), - SearchPlaylistsSection(query: searchPlaylist), - const SizedBox(height: 20), - SearchArtistsSection(query: searchArtist), - const SizedBox(height: 20), - SearchAlbumsSection(query: searchAlbum), + SearchTracksSection(), + SearchPlaylistsSection(), + Gap(20), + SearchArtistsSection(), + Gap(20), + SearchAlbumsSection(), ], ), ), @@ -113,22 +97,86 @@ class SearchPage extends HookConsumerWidget { vertical: 10, ), color: theme.scaffoldBackgroundColor, - child: TextField( - autofocus: queries - .none((s) => s.hasPageData && !s.hasPageError) && - !kIsMobile, - decoration: InputDecoration( - prefixIcon: const Icon(SpotubeIcons.search), - hintText: "${context.l10n.search}...", - ), - onSubmitted: (value) async { - ref.read(searchTermStateProvider.notifier).state = - value; - // Fl-Query is too fast, so we need to delay the search - // to prevent spamming the API :) - Timer(const Duration(milliseconds: 50), () { - onSearch(); - }); + child: SearchAnchor( + searchController: controller, + viewBuilder: (_) => HookBuilder(builder: (context) { + final searchController = useListenable(controller); + final update = useForceUpdate(); + final suggestions = searchController.text.isEmpty + ? KVStoreService.recentSearches + : KVStoreService.recentSearches + .where( + (s) => + weightedRatio( + s.toLowerCase(), + searchController.text.toLowerCase(), + ) > + 50, + ) + .toList(); + + return ListView.builder( + itemCount: suggestions.length, + itemBuilder: (context, index) { + final suggestion = suggestions[index]; + + return ListTile( + leading: const Icon(SpotubeIcons.history), + title: Text(suggestion), + trailing: IconButton( + icon: const Icon(SpotubeIcons.trash), + onPressed: () { + KVStoreService.setRecentSearches( + KVStoreService.recentSearches + .where((s) => s != suggestion) + .toList(), + ); + update(); + }, + ), + onTap: () { + controller.closeView(suggestion); + ref + .read(searchTermStateProvider.notifier) + .state = suggestion; + }, + ); + }, + ); + }), + suggestionsBuilder: (context, controller) { + return []; + }, + viewOnSubmitted: (value) async { + controller.closeView(value); + Timer( + const Duration(milliseconds: 50), + () { + ref.read(searchTermStateProvider.notifier).state = + value; + if (value.trim().isEmpty) { + return; + } + KVStoreService.setRecentSearches( + { + value, + ...KVStoreService.recentSearches, + }.toList(), + ); + }, + ); + }, + builder: (context, controller) { + return SearchBar( + autoFocus: queries.none((s) => + s.asData?.value != null && !s.hasError) && + !kIsMobile, + controller: controller, + leading: const Icon(SpotubeIcons.search), + hintText: "${context.l10n.search}...", + onTap: controller.openView, + onChanged: (_) => controller.openView(), + ); }, ), ), diff --git a/lib/pages/search/sections/albums.dart b/lib/pages/search/sections/albums.dart index 8aa33feb..d15c34ff 100644 --- a/lib/pages/search/sections/albums.dart +++ b/lib/pages/search/sections/albums.dart @@ -1,39 +1,37 @@ -import 'package:fl_query/fl_query.dart'; - import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class SearchAlbumsSection extends HookConsumerWidget { - final InfiniteQuery>, dynamic, int> query; const SearchAlbumsSection({ - required this.query, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { + final query = ref.watch(searchProvider(SearchType.album)); + final notifier = ref.watch(searchProvider(SearchType.album).notifier); final albums = useMemoized( - () => query.pages - .expand( - (page) => page.map((p) => p.items!).expand((element) => element), - ) - .whereType() - .map((e) => TypeConversionUtils.simpleAlbum_X_Album(e)) - .toList(), - [query.pages], + () => + query.asData?.value.items + .cast() + .map((e) => e.toAlbum()) + .toList() ?? + [], + [query.asData?.value], ); return HorizontalPlaybuttonCardView( isLoadingNextPage: query.isLoadingNextPage, - hasNextPage: query.hasNextPage, + hasNextPage: query.asData?.value.hasMore == true, items: albums, - onFetchMore: query.fetchNext, + onFetchMore: notifier.fetchMore, title: Text(context.l10n.albums), ); } diff --git a/lib/pages/search/sections/artists.dart b/lib/pages/search/sections/artists.dart index fe4459d6..bb8063dc 100644 --- a/lib/pages/search/sections/artists.dart +++ b/lib/pages/search/sections/artists.dart @@ -1,37 +1,28 @@ -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class SearchArtistsSection extends HookConsumerWidget { - final InfiniteQuery>, dynamic, int> query; - const SearchArtistsSection({ - Key? key, - required this.query, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { - final artists = useMemoized( - () => query.pages - .expand( - (page) => page.map((p) => p.items!).expand((element) => element), - ) - .whereType() - .toList(), - [query.pages], - ); + final query = ref.watch(searchProvider(SearchType.artist)); + final notifier = ref.watch(searchProvider(SearchType.artist).notifier); + + final artists = query.asData?.value.items.cast() ?? []; return HorizontalPlaybuttonCardView( isLoadingNextPage: query.isLoadingNextPage, - hasNextPage: query.hasNextPage, + hasNextPage: query.asData?.value.hasMore == true, items: artists, - onFetchMore: query.fetchNext, + onFetchMore: notifier.fetchMore, title: Text(context.l10n.artists), ); } diff --git a/lib/pages/search/sections/playlists.dart b/lib/pages/search/sections/playlists.dart index 47614a70..13ff483d 100644 --- a/lib/pages/search/sections/playlists.dart +++ b/lib/pages/search/sections/playlists.dart @@ -1,35 +1,28 @@ -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class SearchPlaylistsSection extends HookConsumerWidget { - final InfiniteQuery>, dynamic, int> query; const SearchPlaylistsSection({ - required this.query, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { - final playlists = useMemoized( - () => query.pages - .expand( - (page) => page.map((p) => p.items!).expand((element) => element), - ) - .whereType() - .toList(), - [query.pages], - ); + final playlistsQuery = ref.watch(searchProvider(SearchType.playlist)); + final playlistsQueryNotifier = + ref.watch(searchProvider(SearchType.playlist).notifier); + final playlists = + playlistsQuery.asData?.value.items.cast() ?? []; return HorizontalPlaybuttonCardView( - isLoadingNextPage: query.isLoadingNextPage, - hasNextPage: query.hasNextPage, + isLoadingNextPage: playlistsQuery.isLoadingNextPage, + hasNextPage: playlistsQuery.asData?.value.hasMore == true, items: playlists, - onFetchMore: query.fetchNext, + onFetchMore: playlistsQueryNotifier.fetchMore, title: Text(context.l10n.playlists), ); } diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart index e77cd8f2..2152cc45 100644 --- a/lib/pages/search/sections/tracks.dart +++ b/lib/pages/search/sections/tracks.dart @@ -1,32 +1,29 @@ import 'package:collection/collection.dart'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class SearchTracksSection extends HookConsumerWidget { - final InfiniteQuery>, dynamic, int> query; const SearchTracksSection({ - Key? key, - required this.query, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { - final searchTrack = query; - final tracks = useMemoized( - () => searchTrack.pages - .expand( - (page) => page.map((p) => p.items!).expand((element) => element), - ) - .whereType(), - [searchTrack.pages], - ); + final searchTrack = ref.watch(searchProvider(SearchType.track)); + + final searchTrackNotifier = + ref.watch(searchProvider(SearchType.track).notifier); + + final tracks = searchTrack.asData?.value.items.cast() ?? []; final playlistNotifier = ref.watch(ProxyPlaylistNotifier.provider.notifier); final playlist = ref.watch(ProxyPlaylistNotifier.provider); final theme = Theme.of(context); @@ -43,50 +40,80 @@ class SearchTracksSection extends HookConsumerWidget { style: theme.textTheme.titleLarge!, ), ), - if (!searchTrack.hasPageData && - !searchTrack.hasPageError && - !searchTrack.isLoadingNextPage) + if (searchTrack.isLoading) const CircularProgressIndicator() - else if (searchTrack.hasPageError) - Text( - searchTrack.errors.lastOrNull?.toString() ?? "", - ) + else if (searchTrack.hasError) + Text(searchTrack.error.toString()) else ...tracks.mapIndexed((i, track) { return TrackTile( index: i, track: track, + playlist: playlist, onTap: () async { - final isTrackPlaying = playlist.activeTrack?.id == track.id; - if (!isTrackPlaying && context.mounted) { - final shouldPlay = (playlist.tracks.length) > 20 - ? await showPromptDialog( - context: context, - title: context.l10n.playing_track( - track.name!, - ), - message: context.l10n.queue_clear_alert( - playlist.tracks.length, - ), - ) - : true; + final isRemoteDevice = + await showSelectDeviceDialog(context, ref); - if (shouldPlay) { - await playlistNotifier.load( - [track], - autoPlay: true, - ); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final remotePlaylist = ref.read(queueProvider); + + final isTrackPlaying = + remotePlaylist.activeTrack?.id == track.id; + + if (!isTrackPlaying && context.mounted) { + final shouldPlay = (playlist.tracks.length) > 20 + ? await showPromptDialog( + context: context, + title: context.l10n.playing_track( + track.name!, + ), + message: context.l10n.queue_clear_alert( + playlist.tracks.length, + ), + ) + : true; + + if (shouldPlay) { + await remotePlayback.load( + WebSocketLoadEventData( + tracks: [track], + ), + ); + } + } + } else { + final isTrackPlaying = playlist.activeTrack?.id == track.id; + if (!isTrackPlaying && context.mounted) { + final shouldPlay = (playlist.tracks.length) > 20 + ? await showPromptDialog( + context: context, + title: context.l10n.playing_track( + track.name!, + ), + message: context.l10n.queue_clear_alert( + playlist.tracks.length, + ), + ) + : true; + + if (shouldPlay) { + await playlistNotifier.load( + [track], + autoPlay: true, + ); + } } } }, ); }), - if (searchTrack.hasNextPage && tracks.isNotEmpty) + if (searchTrack.asData?.value.hasMore == true && tracks.isNotEmpty) Center( child: TextButton( onPressed: searchTrack.isLoadingNextPage ? null - : () => searchTrack.fetchNext(), + : () => searchTrackNotifier.fetchMore, child: searchTrack.isLoadingNextPage ? const CircularProgressIndicator() : Text(context.l10n.load_more), diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart index 00263680..21b8117b 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -16,7 +16,7 @@ final _licenseProvider = FutureProvider((ref) async { }); class AboutSpotube extends HookConsumerWidget { - const AboutSpotube({Key? key}) : super(key: key); + const AboutSpotube({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index b4ce5044..45ce76d9 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -11,7 +11,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/blacklist_provider.dart'; class BlackListPage extends HookConsumerWidget { - const BlackListPage({Key? key}) : super(key: key); + const BlackListPage({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart index cfb28d18..b07ebbb1 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -11,7 +11,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/logger.dart'; class LogsPage extends HookWidget { - const LogsPage({Key? key}) : super(key: key); + const LogsPage({super.key}); List<({DateTime? date, String body})> parseLogs(String raw) { return raw diff --git a/lib/pages/settings/sections/about.dart b/lib/pages/settings/sections/about.dart index 9fe59662..a8d72cc0 100644 --- a/lib/pages/settings/sections/about.dart +++ b/lib/pages/settings/sections/about.dart @@ -11,7 +11,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:url_launcher/url_launcher_string.dart'; class SettingsAboutSection extends HookConsumerWidget { - const SettingsAboutSection({Key? key}) : super(key: key); + const SettingsAboutSection({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index 83740866..bded71b3 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -10,7 +10,7 @@ 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); + const SettingsAccountSection({super.key}); @override Widget build(context, ref) { diff --git a/lib/pages/settings/sections/appearance.dart b/lib/pages/settings/sections/appearance.dart index 3d941212..25bd4005 100644 --- a/lib/pages/settings/sections/appearance.dart +++ b/lib/pages/settings/sections/appearance.dart @@ -13,9 +13,9 @@ import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; class SettingsAppearanceSection extends HookConsumerWidget { final bool isGettingStarted; const SettingsAppearanceSection({ - Key? key, + super.key, this.isGettingStarted = false, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/sections/desktop.dart b/lib/pages/settings/sections/desktop.dart index ae721fc4..4e4408d9 100644 --- a/lib/pages/settings/sections/desktop.dart +++ b/lib/pages/settings/sections/desktop.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart'; @@ -9,7 +10,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; class SettingsDesktopSection extends HookConsumerWidget { - const SettingsDesktopSection({Key? key}) : super(key: key); + const SettingsDesktopSection({super.key}); @override Widget build(BuildContext context, ref) { @@ -19,6 +20,7 @@ class SettingsDesktopSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.desktop, children: [ + const Gap(10), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.close), title: Text(context.l10n.close_behavior), diff --git a/lib/pages/settings/sections/developers.dart b/lib/pages/settings/sections/developers.dart index 4b5f58a6..a22cf9f1 100644 --- a/lib/pages/settings/sections/developers.dart +++ b/lib/pages/settings/sections/developers.dart @@ -6,7 +6,7 @@ import 'package:spotube/components/settings/section_card_with_heading.dart'; import 'package:spotube/extensions/context.dart'; class SettingsDevelopersSection extends HookWidget { - const SettingsDevelopersSection({Key? key}) : super(key: key); + const SettingsDevelopersSection({super.key}); @override Widget build(BuildContext context) { diff --git a/lib/pages/settings/sections/downloads.dart b/lib/pages/settings/sections/downloads.dart index b1e360d0..1f25028e 100644 --- a/lib/pages/settings/sections/downloads.dart +++ b/lib/pages/settings/sections/downloads.dart @@ -10,7 +10,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; class SettingsDownloadsSection extends HookConsumerWidget { - const SettingsDownloadsSection({Key? key}) : super(key: key); + const SettingsDownloadsSection({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/sections/language_region.dart b/lib/pages/settings/sections/language_region.dart index fbfe1030..76670c77 100644 --- a/lib/pages/settings/sections/language_region.dart +++ b/lib/pages/settings/sections/language_region.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/language_codes.dart'; @@ -23,6 +24,7 @@ class SettingsLanguageRegionSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.language_region, children: [ + const Gap(10), AdaptiveSelectTile( value: preferences.locale, onChanged: (locale) { diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart index bd2e33b9..eeae98cb 100644 --- a/lib/pages/settings/sections/playback.dart +++ b/lib/pages/settings/sections/playback.dart @@ -1,5 +1,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -14,7 +15,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/services/sourced_track/enums.dart'; class SettingsPlaybackSection extends HookConsumerWidget { - const SettingsPlaybackSection({Key? key}) : super(key: key); + const SettingsPlaybackSection({super.key}); @override Widget build(BuildContext context, ref) { @@ -25,6 +26,7 @@ class SettingsPlaybackSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.playback, children: [ + const Gap(10), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.audioQuality), title: Text(context.l10n.audio_quality), @@ -49,6 +51,7 @@ class SettingsPlaybackSection extends HookConsumerWidget { } }, ), + const Gap(5), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.api), title: Text(context.l10n.audio_source), @@ -181,7 +184,8 @@ class SettingsPlaybackSection extends HookConsumerWidget { value: preferences.normalizeAudio, onChanged: preferencesNotifier.setNormalizeAudio, ), - if (preferences.audioSource != AudioSource.jiosaavn) + if (preferences.audioSource != AudioSource.jiosaavn) ...[ + const Gap(5), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.stream), title: Text(context.l10n.streaming_music_codec), @@ -201,7 +205,7 @@ class SettingsPlaybackSection extends HookConsumerWidget { preferencesNotifier.setStreamMusicCodec(value); }, ), - if (preferences.audioSource != AudioSource.jiosaavn) + const Gap(5), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.file), title: Text(context.l10n.download_music_codec), @@ -220,13 +224,21 @@ class SettingsPlaybackSection extends HookConsumerWidget { if (value == null) return; preferencesNotifier.setDownloadMusicCodec(value); }, - ), + ) + ], SwitchListTile( secondary: const Icon(SpotubeIcons.repeat), title: Text(context.l10n.endless_playback), value: preferences.endlessPlayback, onChanged: preferencesNotifier.setEndlessPlayback, ), + SwitchListTile( + title: Text(context.l10n.enable_connect), + subtitle: Text(context.l10n.enable_connect_description), + secondary: const Icon(SpotubeIcons.connect), + value: preferences.enableConnect, + onChanged: preferencesNotifier.setEnableConnect, + ), ], ); } diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index f773b809..d2a75057 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -16,7 +16,7 @@ import 'package:spotube/pages/settings/sections/playback.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; class SettingsPage extends HookConsumerWidget { - const SettingsPage({Key? key}) : super(key: key); + const SettingsPage({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index 14052c10..829256d4 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -8,22 +8,24 @@ import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/track_tile/track_options.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; + import 'package:spotube/extensions/constrains.dart'; class TrackPage extends HookConsumerWidget { final String trackId; const TrackPage({ - Key? key, + super.key, required this.trackId, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { @@ -35,9 +37,9 @@ class TrackPage extends HookConsumerWidget { final isActive = playlist.activeTrack?.id == trackId; - final trackQuery = useQueries.tracks.track(ref, trackId); + final trackQuery = ref.watch(trackProvider(trackId)); - final track = trackQuery.data ?? FakeData.track; + final track = trackQuery.asData?.value ?? FakeData.track; void onPlay() async { if (isActive) { @@ -60,8 +62,7 @@ class TrackPage extends HookConsumerWidget { decoration: BoxDecoration( image: DecorationImage( image: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - track.album!.images, + track.album!.images.asUrlString( placeholder: ImagePlaceholder.albumArt, ), ), @@ -104,8 +105,7 @@ class TrackPage extends HookConsumerWidget { ClipRRect( borderRadius: BorderRadius.circular(10), child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString( - track.album!.images, + path: track.album!.images.asUrlString( placeholder: ImagePlaceholder.albumArt, ), height: 200, @@ -146,10 +146,7 @@ class TrackPage extends HookConsumerWidget { children: [ const Icon(SpotubeIcons.artist), const Gap(5), - TypeConversionUtils - .artists_X_ClickableArtists( - track.artists!, - ), + ArtistLink(artists: track.artists!), ], ), const Gap(10), diff --git a/lib/provider/authentication_provider.dart b/lib/provider/authentication_provider.dart index cd77e7bb..f1cf58ec 100644 --- a/lib/provider/authentication_provider.dart +++ b/lib/provider/authentication_provider.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:convert'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart'; @@ -52,8 +51,7 @@ class AuthenticationCredentials { ), ); } catch (e) { - if (rootNavigatorKey?.currentContext != null && - await QueryClient.connectivity.isConnected) { + if (rootNavigatorKey?.currentContext != null) { showPromptDialog( context: rootNavigatorKey!.currentContext!, title: rootNavigatorKey!.currentContext!.l10n diff --git a/lib/provider/blacklist_provider.dart b/lib/provider/blacklist_provider.dart index 363d4b4c..1d4edebf 100644 --- a/lib/provider/blacklist_provider.dart +++ b/lib/provider/blacklist_provider.dart @@ -62,7 +62,7 @@ class BlackListNotifier final containsTrackArtists = track.artists?.any( (artist) => state.contains( - BlacklistedElement.artist(artist.id!, artist.name!), + BlacklistedElement.artist(artist.id!, artist.name ?? "Spotify"), ), ) ?? false; diff --git a/lib/provider/connect/clients.dart b/lib/provider/connect/clients.dart new file mode 100644 index 00000000..282c96aa --- /dev/null +++ b/lib/provider/connect/clients.dart @@ -0,0 +1,111 @@ +import 'package:bonsoir/bonsoir.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotube/services/device_info/device_info.dart'; + +class ConnectClientsState { + final List services; + final ResolvedBonsoirService? resolvedService; + final BonsoirDiscovery discovery; + + ConnectClientsState({ + required this.services, + required this.discovery, + this.resolvedService, + }); + + ConnectClientsState copyWith({ + List? services, + BonsoirDiscovery? discovery, + ResolvedBonsoirService? resolvedService, + }) { + return ConnectClientsState( + services: services ?? this.services, + discovery: discovery ?? this.discovery, + resolvedService: resolvedService ?? this.resolvedService, + ); + } +} + +class ConnectClientsNotifier extends AsyncNotifier { + ConnectClientsNotifier(); + + @override + build() async { + final discovery = BonsoirDiscovery(type: '_spotube._tcp'); + final deviceId = await DeviceInfoService.instance.deviceId(); + await discovery.ready; + + final subscription = discovery.eventStream?.listen((event) { + // ignore device itself + if (event.service?.attributes["deviceId"] == deviceId) { + return; + } + + switch (event.type) { + case BonsoirDiscoveryEventType.discoveryServiceFound: + state = AsyncData(state.value!.copyWith( + services: [ + ...?state.value?.services, + event.service!, + ], + )); + break; + case BonsoirDiscoveryEventType.discoveryServiceResolved: + state = AsyncData( + state.value!.copyWith( + resolvedService: event.service as ResolvedBonsoirService, + ), + ); + break; + case BonsoirDiscoveryEventType.discoveryServiceLost: + state = AsyncData( + ConnectClientsState( + services: state.value!.services + .where((s) => s.name != event.service!.name) + .toList(), + discovery: state.value!.discovery, + resolvedService: + event.service?.name == state.value!.resolvedService!.name + ? null + : state.value!.resolvedService, + ), + ); + break; + default: + break; + } + }); + + ref.onDispose(() { + subscription?.cancel(); + discovery.stop(); + }); + + await discovery.start(); + + return ConnectClientsState( + services: [], + discovery: discovery, + ); + } + + Future resolveService(BonsoirService service) async { + if (state.value == null) return; + await service.resolve(state.value!.discovery.serviceResolver); + } + + Future clearResolvedService() async { + if (state.value == null) return; + state = AsyncData( + ConnectClientsState( + services: state.value!.services, + discovery: state.value!.discovery, + ), + ); + } +} + +final connectClientsProvider = + AsyncNotifierProvider( + () => ConnectClientsNotifier(), +); diff --git a/lib/provider/connect/connect.dart b/lib/provider/connect/connect.dart new file mode 100644 index 00000000..65daaf55 --- /dev/null +++ b/lib/provider/connect/connect.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; +import 'package:spotube/services/audio_player/loop_mode.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:web_socket_channel/status.dart' as status; + +final playingProvider = StateProvider( + (ref) => false, +); + +final positionProvider = StateProvider( + (ref) => Duration.zero, +); + +final durationProvider = StateProvider( + (ref) => Duration.zero, +); + +final shuffleProvider = StateProvider( + (ref) => false, +); + +final loopModeProvider = StateProvider( + (ref) => PlaybackLoopMode.none, +); + +final queueProvider = StateProvider( + (ref) => ProxyPlaylist({}), +); + +final volumeProvider = StateProvider( + (ref) => 1.0, +); + +class ConnectNotifier extends AsyncNotifier { + @override + build() async { + try { + final connectClients = ref.watch(connectClientsProvider); + print('Building ConnectNotifier'); + + if (connectClients.asData?.value.resolvedService == null) return null; + + final service = connectClients.asData!.value.resolvedService!; + + print( + 'Connecting to ${service.name}: ws://${service.host}:${service.port}/ws'); + + final channel = WebSocketChannel.connect( + Uri.parse('ws://${service.host}:${service.port}/ws'), + ); + + await channel.ready; + + print( + 'Connected to ${service.name}: ws://${service.host}:${service.port}/ws'); + + final subscription = channel.stream.listen( + (message) { + final event = + WebSocketEvent.fromJson(jsonDecode(message), (data) => data); + + event.onQueue((event) { + ref.read(queueProvider.notifier).state = event.data; + }); + + event.onPlaying((event) { + ref.read(playingProvider.notifier).state = event.data; + }); + + event.onPosition((event) { + ref.read(positionProvider.notifier).state = event.data; + }); + + event.onDuration((event) { + ref.read(durationProvider.notifier).state = event.data; + }); + + event.onShuffle((event) { + ref.read(shuffleProvider.notifier).state = event.data; + }); + + event.onLoop((event) { + ref.read(loopModeProvider.notifier).state = event.data; + }); + + event.onVolume((event) { + ref.read(volumeProvider.notifier).state = event.data; + }); + }, + onError: (error) { + Catcher2.reportCheckedError( + error, + StackTrace.current, + ); + }, + ); + + ref.onDispose(() { + subscription.cancel(); + channel.sink.close(status.goingAway); + }); + + return channel; + } catch (e, stack) { + Catcher2.reportCheckedError(e, stack); + rethrow; + } + } + + Future emit(Object message) async { + if (state.value == null) return; + state.value?.sink.add( + message is String ? message : (message as dynamic).toJson(), + ); + } + + Future resume() async { + emit(WebSocketResumeEvent()); + } + + Future pause() async { + emit(WebSocketPauseEvent()); + } + + Future stop() async { + emit(WebSocketStopEvent()); + } + + Future jumpTo(int position) async { + emit(WebSocketJumpEvent(position)); + } + + Future load(WebSocketLoadEventData data) async { + emit(WebSocketLoadEvent(data)); + } + + Future next() async { + emit(WebSocketNextEvent()); + } + + Future previous() async { + emit(WebSocketPreviousEvent()); + } + + Future seek(Duration position) async { + emit(WebSocketSeekEvent(position)); + } + + Future setShuffle(bool value) async { + emit(WebSocketShuffleEvent(value)); + } + + Future setLoopMode(PlaybackLoopMode value) async { + emit(WebSocketLoopEvent(value)); + } + + Future addTrack(Track data) async { + emit(WebSocketAddTrackEvent(data)); + } + + Future removeTrack(String data) async { + emit(WebSocketRemoveTrackEvent(data)); + } + + Future reorder(ReorderData data) async { + emit(WebSocketReorderEvent(data)); + } + + Future setVolume(double value) async { + emit(WebSocketVolumeEvent(value)); + } +} + +final connectProvider = + AsyncNotifierProvider( + () => ConnectNotifier(), +); diff --git a/lib/provider/connect/server.dart b/lib/provider/connect/server.dart new file mode 100644 index 00000000..0469e3f5 --- /dev/null +++ b/lib/provider/connect/server.dart @@ -0,0 +1,261 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shelf_router/shelf_router.dart'; +import 'package:shelf_web_socket/shelf_web_socket.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/models/logger.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:bonsoir/bonsoir.dart'; +import 'package:spotube/services/device_info/device_info.dart'; +import 'package:spotube/utils/primitive_utils.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:spotube/provider/volume_provider.dart'; + +final logger = getLogger('ConnectServer'); +final _connectClientStreamController = StreamController.broadcast(); + +Stream get connectClientStream => _connectClientStreamController.stream; + +final connectServerProvider = FutureProvider((ref) async { + final enabled = + ref.watch(userPreferencesProvider.select((s) => s.enableConnect)); + final resolvedService = await ref + .watch(connectClientsProvider.selectAsync((s) => s.resolvedService)); + final playbackNotifier = ref.read(ProxyPlaylistNotifier.notifier); + + if (!enabled || resolvedService != null) { + return null; + } + + final app = Router(); + + app.get( + "/ping", + (Request req) { + return Response.ok("pong"); + }, + ); + + final subscriptions = []; + + FutureOr websocket(Request req) => webSocketHandler( + (WebSocketChannel channel, String? protocol) async { + final context = + (req.context["shelf.io.connection_info"] as HttpConnectionInfo?); + final origin = + "${context?.remoteAddress.host}:${context?.remotePort}"; + _connectClientStreamController.add(origin); + + ref.listen( + ProxyPlaylistNotifier.provider, + (previous, next) { + channel.sink.add( + WebSocketQueueEvent(next).toJson(), + ); + }, + fireImmediately: true, + ); + + // because audioPlayer events doesn't fireImmediately + channel.sink.add( + WebSocketPlayingEvent(audioPlayer.isPlaying).toJson(), + ); + channel.sink.add( + WebSocketPositionEvent(await audioPlayer.position ?? Duration.zero) + .toJson(), + ); + channel.sink.add( + WebSocketDurationEvent(await audioPlayer.duration ?? Duration.zero) + .toJson(), + ); + channel.sink.add( + WebSocketShuffleEvent(await audioPlayer.isShuffled).toJson(), + ); + channel.sink.add( + WebSocketLoopEvent(audioPlayer.loopMode).toJson(), + ); + channel.sink.add( + WebSocketVolumeEvent(audioPlayer.volume).toJson(), + ); + + subscriptions.addAll([ + audioPlayer.positionStream.listen( + (position) { + channel.sink.add( + WebSocketPositionEvent(position).toJson(), + ); + }, + ), + audioPlayer.playingStream.listen( + (playing) { + channel.sink.add( + WebSocketPlayingEvent(playing).toJson(), + ); + }, + ), + audioPlayer.durationStream.listen( + (duration) { + channel.sink.add( + WebSocketDurationEvent(duration).toJson(), + ); + }, + ), + audioPlayer.shuffledStream.listen( + (shuffled) { + channel.sink.add( + WebSocketShuffleEvent(shuffled).toJson(), + ); + }, + ), + audioPlayer.loopModeStream.listen( + (loopMode) { + channel.sink.add( + WebSocketLoopEvent(loopMode).toJson(), + ); + }, + ), + audioPlayer.volumeStream.listen( + (volume) { + channel.sink.add( + WebSocketVolumeEvent(volume).toJson(), + ); + }, + ), + channel.stream.listen( + (message) { + try { + final event = WebSocketEvent.fromJson( + jsonDecode(message), + (data) => data, + ); + + event.onLoad((event) async { + await playbackNotifier.load( + event.data.tracks, + autoPlay: true, + initialIndex: event.data.initialIndex ?? 0, + ); + + if (event.data.collectionId != null) { + playbackNotifier.addCollection(event.data.collectionId!); + } + }); + + event.onPause((event) async { + await audioPlayer.pause(); + }); + + event.onResume((event) async { + await audioPlayer.resume(); + }); + + event.onStop((event) async { + await audioPlayer.stop(); + }); + + event.onNext((event) async { + await playbackNotifier.next(); + }); + + event.onPrevious((event) async { + await playbackNotifier.previous(); + }); + + event.onJump((event) async { + await playbackNotifier.jumpTo(event.data); + }); + + event.onSeek((event) async { + await audioPlayer.seek(event.data); + }); + + event.onShuffle((event) async { + await audioPlayer.setShuffle(event.data); + }); + + event.onLoop((event) async { + await audioPlayer.setLoopMode(event.data); + }); + + event.onAddTrack((event) async { + await playbackNotifier.addTrack(event.data); + }); + + event.onRemoveTrack((event) async { + await playbackNotifier.removeTrack(event.data); + }); + + event.onReorder((event) async { + await playbackNotifier.moveTrack( + event.data.oldIndex, + event.data.newIndex, + ); + }); + + event.onVolume((event) async { + ref.read(volumeProvider.notifier).setVolume(event.data); + }); + } catch (e, stackTrace) { + Catcher2.reportCheckedError(e, stackTrace); + channel.sink.add(WebSocketErrorEvent(e.toString()).toJson()); + } + }, + onDone: () { + logger.i('Connection closed'); + }, + ), + ]); + }, + )(req); + + final port = Random().nextInt(17000) + 3000; + + final server = await serve( + (request) { + if (request.url.path.startsWith('ws')) { + return websocket(request); + } + return app(request); + }, + InternetAddress.anyIPv4, + port, + ); + + logger.i('Server running on http://${server.address.host}:${server.port}'); + + final service = BonsoirService( + name: await DeviceInfoService.instance.computerName(), + type: '_spotube._tcp', + port: port, + attributes: { + "id": PrimitiveUtils.uuid.v4(), + "deviceId": await DeviceInfoService.instance.deviceId(), + }, + ); + + final broadcast = BonsoirBroadcast(service: service); + + await broadcast.ready; + await broadcast.start(); + + ref.onDispose(() async { + logger.i('Stopping server'); + for (final subscription in subscriptions) { + await subscription.cancel(); + } + await broadcast.stop(); + await server.close(); + }); + + return app; +}); diff --git a/lib/provider/custom_spotify_endpoint_provider.dart b/lib/provider/custom_spotify_endpoint_provider.dart index 4857a358..7a4c5533 100644 --- a/lib/provider/custom_spotify_endpoint_provider.dart +++ b/lib/provider/custom_spotify_endpoint_provider.dart @@ -1,8 +1,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/services/custom_spotify_endpoints/spotify_endpoints.dart'; final customSpotifyEndpointProvider = Provider((ref) { + ref.watch(spotifyProvider); final auth = ref.watch(AuthenticationNotifier.provider); return CustomSpotifyEndpoints(auth?.accessToken ?? ""); }); diff --git a/lib/provider/discord_provider.dart b/lib/provider/discord_provider.dart index 3aa547a9..e07e2d3b 100644 --- a/lib/provider/discord_provider.dart +++ b/lib/provider/discord_provider.dart @@ -4,9 +4,9 @@ import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/env.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class Discord extends ChangeNotifier { final DiscordRPC? discordRPC; @@ -23,8 +23,7 @@ class Discord extends ChangeNotifier { void updatePresence(Track track) { clear(); - final artistNames = - TypeConversionUtils.artists_X_String(track.artists ?? []); + final artistNames = track.artists?.asString() ?? ""; discordRPC?.updatePresence( DiscordPresence( details: "Song: ${track.name} by $artistNames", diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index dc538938..c964f982 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -9,12 +9,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/download_manager/download_manager.dart'; import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/primitive_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class DownloadManagerProvider extends ChangeNotifier { DownloadManagerProvider({required this.ref}) @@ -52,8 +53,10 @@ class DownloadManagerProvider extends ChangeNotifier { } final imageBytes = await downloadImage( - TypeConversionUtils.image_X_UrlString(track.album?.images, - placeholder: ImagePlaceholder.albumArt, index: 1), + (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + index: 1, + ), ); final metadata = Metadata( @@ -134,7 +137,7 @@ class DownloadManagerProvider extends ChangeNotifier { String getTrackFileUrl(Track track) { final name = - "${track.name} - ${TypeConversionUtils.artists_X_String(track.artists ?? [])}.${downloadCodec.name}"; + "${track.name} - ${track.artists?.asString() ?? ""}.${downloadCodec.name}"; return join(downloadDirectory, PrimitiveUtils.toSafeFileName(name)); } diff --git a/lib/provider/proxy_playlist/player_listeners.dart b/lib/provider/proxy_playlist/player_listeners.dart new file mode 100644 index 00000000..9069f3e1 --- /dev/null +++ b/lib/provider/proxy_playlist/player_listeners.dart @@ -0,0 +1,132 @@ +// ignore_for_file: invalid_use_of_protected_member + +import 'dart:async'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/proxy_playlist/skip_segments.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/sourced_track/exceptions.dart'; + +extension ProxyPlaylistListeners on ProxyPlaylistNotifier { + StreamSubscription subscribeToSourceChanges() => + audioPlayer.activeSourceChangedStream.listen((event) { + try { + final newActiveTrack = mapSourcesToTracks([event]).firstOrNull; + + if (newActiveTrack == null || + newActiveTrack.id == state.activeTrack?.id) { + return; + } + + notificationService.addTrack(newActiveTrack); + discord.updatePresence(newActiveTrack); + state = state.copyWith( + active: state.tracks + .toList() + .indexWhere((element) => element.id == newActiveTrack.id), + ); + + updatePalette(); + } catch (e, stackTrace) { + Catcher2.reportCheckedError(e, stackTrace); + } + }); + + StreamSubscription subscribeToPercentCompletion() { + final isPreSearching = ObjectRef(false); + + return audioPlayer.percentCompletedStream(2).listen((event) async { + if (isPreSearching.value || + audioPlayer.currentSource == null || + audioPlayer.nextSource == null || + isPlayable(audioPlayer.nextSource!)) return; + + try { + isPreSearching.value = true; + + final track = await ensureSourcePlayable(audioPlayer.nextSource!); + + if (track != null) { + state = state.copyWith(tracks: mergeTracks([track], state.tracks)); + } + } catch (e, stackTrace) { + // Removing tracks that were not found to avoid queue interruption + if (e is TrackNotFoundError) { + final oldTrack = + mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull; + await removeTrack(oldTrack!.id!); + } + Catcher2.reportCheckedError(e, stackTrace); + } finally { + isPreSearching.value = false; + } + }); + } + + StreamSubscription subscribeToShuffleChanges() { + return audioPlayer.shuffledStream.listen((event) { + try { + final newlyOrderedTracks = mapSourcesToTracks(audioPlayer.sources); + + final newActiveIndex = newlyOrderedTracks.indexWhere( + (element) => element.id == state.activeTrack?.id, + ); + + if (newActiveIndex == -1) return; + + state = state.copyWith( + tracks: newlyOrderedTracks.toSet(), + active: newActiveIndex, + ); + } catch (e, stackTrace) { + Catcher2.reportCheckedError(e, stackTrace); + } + }); + } + + StreamSubscription subscribeToSkipSponsor() { + return audioPlayer.positionStream.listen((position) async { + final currentSegments = await ref.read(segmentProvider.future); + + if (currentSegments?.segments.isNotEmpty != true || + position < const Duration(seconds: 3)) return; + + for (final segment in currentSegments!.segments) { + final seconds = position.inSeconds; + + if (seconds < segment.start || seconds >= segment.end) continue; + + await audioPlayer.seek(Duration(seconds: segment.end + 1)); + } + }); + } + + StreamSubscription subscribeToScrobbleChanged() { + String? lastScrobbled; + return audioPlayer.positionStream.listen((position) { + try { + final uid = state.activeTrack is LocalTrack + ? (state.activeTrack as LocalTrack).path + : state.activeTrack?.id; + + if (state.activeTrack == null || + lastScrobbled == uid || + position.inSeconds < 30) { + return; + } + + scrobbler.scrobble(state.activeTrack!); + lastScrobbled = uid; + } catch (e, stack) { + Catcher2.reportCheckedError(e, stack); + } + }); + } + + StreamSubscription subscribeToPlayerError() { + return audioPlayer.errorStream.listen((event) {}); + } +} diff --git a/lib/provider/proxy_playlist/proxy_playlist.dart b/lib/provider/proxy_playlist/proxy_playlist.dart index 026b3403..efc818ed 100644 --- a/lib/provider/proxy_playlist/proxy_playlist.dart +++ b/lib/provider/proxy_playlist/proxy_playlist.dart @@ -27,6 +27,16 @@ class ProxyPlaylist { ); } + factory ProxyPlaylist.fromJsonRaw(Map json) => ProxyPlaylist( + json['tracks'] == null + ? {} + : (json['tracks'] as List).map((t) => Track.fromJson(t)).toSet(), + json['active'] as int?, + json['collections'] == null + ? {} + : (json['collections'] as List).toSet().cast(), + ); + Track? get activeTrack => active == null || active == -1 ? null : tracks.elementAtOrNull(active!); @@ -62,8 +72,8 @@ class ProxyPlaylist { /// Otherwise default super.toJson() is used static Map _makeAppropriateTrackJson(Track track) { return switch (track.runtimeType) { - LocalTrack => track.toJson(), - SourcedTrack => track.toJson(), + LocalTrack() => track.toJson(), + SourcedTrack() => track.toJson(), _ => track.toJson(), }; } diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index 0811fe35..438088de 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -1,23 +1,18 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:math'; -import 'package:catcher_2/catcher_2.dart'; import 'package:collection/collection.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:http/http.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; -import 'package:spotube/models/logger.dart'; - -import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/next_fetcher_mixin.dart'; +import 'package:spotube/provider/proxy_playlist/player_listeners.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; @@ -25,34 +20,10 @@ import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_services/audio_services.dart'; import 'package:spotube/provider/discord_provider.dart'; -import 'package:spotube/services/sourced_track/exceptions.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/services/sourced_track/sources/piped.dart'; -import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -/// Things implemented: -/// * [x] Sponsor-Block skip -/// * [x] Prefetch next track as [SourcedTrack] on 80% of current track -/// * [x] Mixed Queue containing both [SourcedTrack] and [LocalTrack] -/// * [x] Modification of the Queue -/// * [x] Add track at the end -/// * [x] Add track at the beginning -/// * [x] Remove track -/// * [x] Reorder track -/// * [x] Caching and loading of cache of tracks -/// * [x] Shuffling -/// * [x] loop => playlist, track, none -/// * [x] Alternative Track Source -/// * [x] Blacklisting of tracks and artist -/// -/// Don'ts: -/// * It'll not have any proxy method for [SpotubeAudioPlayer] -/// * It'll not store any sort of player state e.g playing, paused, shuffled etc -/// * For that, use [SpotubeAudioPlayer] class ProxyPlaylistNotifier extends PersistedStateNotifier with NextFetcher { @@ -74,162 +45,21 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier static AlwaysAliveRefreshable get notifier => provider.notifier; + List _subscriptions = []; + ProxyPlaylistNotifier(this.ref) : super(ProxyPlaylist({}), "playlist") { - () async { - notificationService = await AudioServices.create(ref, this); + AudioServices.create(ref, this).then( + (value) => notificationService = value, + ); - // listeners state - final currentSegments = - // using source as unique id because alternative track source support - ObjectRef<({String source, List segments})?>(null); - final isPreSearching = ObjectRef(false); - final isFetchingSegments = ObjectRef(false); - - audioPlayer.activeSourceChangedStream.listen((newActiveSource) async { - try { - final newActiveTrack = - mapSourcesToTracks([newActiveSource]).firstOrNull; - - if (newActiveTrack == null || - newActiveTrack.id == state.activeTrack?.id) { - return; - } - - notificationService.addTrack(newActiveTrack); - discord.updatePresence(newActiveTrack); - state = state.copyWith( - active: state.tracks - .toList() - .indexWhere((element) => element.id == newActiveTrack.id), - ); - - updatePalette(); - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }); - - audioPlayer.shuffledStream.listen((event) { - try { - final newlyOrderedTracks = mapSourcesToTracks(audioPlayer.sources); - - final newActiveIndex = newlyOrderedTracks.indexWhere( - (element) => element.id == state.activeTrack?.id, - ); - - if (newActiveIndex == -1) return; - - state = state.copyWith( - tracks: newlyOrderedTracks.toSet(), - active: newActiveIndex, - ); - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }); - - listenTo2Percent(int percent) async { - if (isPreSearching.value || - audioPlayer.currentSource == null || - audioPlayer.nextSource == null || - isPlayable(audioPlayer.nextSource!)) return; - - try { - isPreSearching.value = true; - - final track = await ensureSourcePlayable(audioPlayer.nextSource!); - - if (track != null) { - state = state.copyWith(tracks: mergeTracks([track], state.tracks)); - } - } catch (e, stackTrace) { - // Removing tracks that were not found to avoid queue interruption - if (e is TrackNotFoundError) { - final oldTrack = - mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull; - await removeTrack(oldTrack!.id!); - } - Catcher2.reportCheckedError(e, stackTrace); - } finally { - isPreSearching.value = false; - } - } - - audioPlayer.percentCompletedStream(2).listen(listenTo2Percent); - - audioPlayer.positionStream.listen((position) async { - if (state.activeTrack == null || state.activeTrack is LocalTrack) { - isFetchingSegments.value = false; - return; - } - try { - final isNotYTMode = state.activeTrack is! YoutubeSourcedTrack && - (state.activeTrack is PipedSourcedTrack && - preferences.searchMode == SearchMode.youtubeMusic); - - if (isNotYTMode || !preferences.skipNonMusic) return; - - final isNotSameSegmentId = - currentSegments.value?.source != audioPlayer.currentSource; - - if (currentSegments.value == null || - (isNotSameSegmentId && !isFetchingSegments.value)) { - isFetchingSegments.value = true; - try { - currentSegments.value = ( - source: audioPlayer.currentSource!, - segments: await getAndCacheSkipSegments( - (state.activeTrack as SourcedTrack).sourceInfo.id, - ), - ); - } catch (e) { - if (audioPlayer.currentSource != null) { - currentSegments.value = ( - source: audioPlayer.currentSource!, - segments: [], - ); - } - } finally { - isFetchingSegments.value = false; - } - } - - // skipping in first 2 second breaks stream - if (currentSegments.value == null || - currentSegments.value!.segments.isEmpty || - position < const Duration(seconds: 3)) return; - - for (final segment in currentSegments.value!.segments) { - if (position.inSeconds >= segment.start && - position.inSeconds < segment.end) { - await audioPlayer.seek(Duration(seconds: segment.end)); - } - } - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }); - - String? lastScrobbled; - audioPlayer.positionStream.listen((position) { - try { - final uid = state.activeTrack is LocalTrack - ? (state.activeTrack as LocalTrack).path - : state.activeTrack?.id; - - if (state.activeTrack == null || - lastScrobbled == uid || - position.inSeconds < 30) { - return; - } - - scrobbler.scrobble(state.activeTrack!); - lastScrobbled = uid; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - } - }); - }(); + _subscriptions = [ + // These are subscription methods from player_listeners.dart + subscribeToSourceChanges(), + subscribeToPercentCompletion(), + subscribeToShuffleChanges(), + subscribeToSkipSponsor(), + subscribeToScrobbleChanged(), + ]; } Future ensureSourcePlayable(String source) async { @@ -242,7 +72,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } final nthFetchedTrack = switch (track.runtimeType) { - SourcedTrack => track as SourcedTrack, + SourcedTrack() => track as SourcedTrack, _ => await SourcedTrack.fetchFromTrack(ref: ref, track: track), }; @@ -283,8 +113,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier }); } - // TODO: Safely Remove playing tracks - Future removeTrack(String trackId) async { final track = state.tracks.firstWhereOrNull((element) => element.id == trackId); @@ -522,8 +350,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier final palette = await PaletteGenerator.fromImageProvider( UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - state.activeTrack?.album?.images, + (state.activeTrack?.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, ), height: 50, @@ -534,72 +361,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier }); } - Future> getAndCacheSkipSegments(String id) async { - if (!preferences.skipNonMusic || - (preferences.audioSource == AudioSource.piped && - preferences.searchMode == SearchMode.youtubeMusic)) return []; - - try { - final cached = await SkipSegment.box.get(id); - if (cached != null && cached.isNotEmpty) { - return List.castFrom( - (cached as List) - .map( - (json) => SkipSegment.fromJson( - Map.castFrom(json), - ), - ) - .toList(), - ); - } - - final res = await get(Uri( - scheme: "https", - host: "sponsor.ajay.app", - path: "/api/skipSegments", - queryParameters: { - "videoID": id, - "category": [ - 'sponsor', - 'selfpromo', - 'interaction', - 'intro', - 'outro', - 'music_offtopic' - ], - "actionType": 'skip' - }, - )); - - if (res.body == "Not Found") { - return List.castFrom([]); - } - - final data = jsonDecode(res.body) as List; - final segments = data.map((obj) { - final start = obj["segment"].first.toInt(); - final end = obj["segment"].last.toInt(); - return SkipSegment( - start, - end, - ); - }).toList(); - getLogger('getSkipSegments').t( - "[SponsorBlock] successfully fetched skip segments for $id", - ); - - await SkipSegment.box.put( - id, - segments.map((e) => e.toJson()).toList(), - ); - return List.castFrom(segments); - } catch (e, stack) { - await SkipSegment.box.put(id, []); - Catcher2.reportCheckedError(e, stack); - return List.castFrom([]); - } - } - @override set state(state) { super.state = state; @@ -632,4 +393,12 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier final json = state.toJson(); return json; } + + @override + void dispose() { + for (final subscription in _subscriptions) { + subscription.cancel(); + } + super.dispose(); + } } diff --git a/lib/provider/proxy_playlist/skip_segments.dart b/lib/provider/proxy_playlist/skip_segments.dart new file mode 100644 index 00000000..94a63324 --- /dev/null +++ b/lib/provider/proxy_playlist/skip_segments.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/models/skip_segment.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; + +class SourcedSegments { + final String source; + final List segments; + + SourcedSegments({required this.source, required this.segments}); +} + +Future> getAndCacheSkipSegments(String id) async { + try { + final cached = await SkipSegment.box.get(id) as List?; + if (cached != null && cached.isNotEmpty) { + return List.castFrom( + cached + .map( + (json) => SkipSegment.fromJson( + Map.castFrom(json), + ), + ) + .toList(), + ); + } + + final res = await get(Uri( + scheme: "https", + host: "sponsor.ajay.app", + path: "/api/skipSegments", + queryParameters: { + "videoID": id, + "category": [ + 'sponsor', + 'selfpromo', + 'interaction', + 'intro', + 'outro', + 'music_offtopic' + ], + "actionType": 'skip' + }, + )); + + if (res.body == "Not Found") { + return List.castFrom([]); + } + + final data = jsonDecode(res.body) as List; + final segments = data.map((obj) { + final start = obj["segment"].first.toInt(); + final end = obj["segment"].last.toInt(); + return SkipSegment(start, end); + }).toList(); + + await SkipSegment.box.put( + id, + segments.map((e) => e.toJson()).toList(), + ); + return List.castFrom(segments); + } catch (e, stack) { + await SkipSegment.box.put(id, []); + Catcher2.reportCheckedError(e, stack); + return List.castFrom([]); + } +} + +final segmentProvider = FutureProvider( + (ref) async { + final track = ref.watch( + ProxyPlaylistNotifier.provider.select((s) => s.activeTrack), + ); + if (track == null) return null; + + if (track is LocalTrack || track is! SourcedTrack) return null; + + final skipNonMusic = ref.watch( + userPreferencesProvider.select( + (s) { + final isPipedYTMusicMode = s.audioSource == AudioSource.piped && + s.searchMode == SearchMode.youtubeMusic; + + return s.skipNonMusic && !isPipedYTMusicMode; + }, + ), + ); + + if (!skipNonMusic) { + return SourcedSegments( + segments: [], + source: track.sourceInfo.id, + ); + } + + final segments = await getAndCacheSkipSegments(track.sourceInfo.id); + + return SourcedSegments( + source: track.sourceInfo.id, + segments: segments, + ); + }, +); diff --git a/lib/provider/scrobbler_provider.dart b/lib/provider/scrobbler_provider.dart index bf234e62..9ad2a58b 100644 --- a/lib/provider/scrobbler_provider.dart +++ b/lib/provider/scrobbler_provider.dart @@ -5,9 +5,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:scrobblenaut/scrobblenaut.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/env.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/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; @@ -85,14 +85,14 @@ class ScrobblerNotifier extends PersistedStateNotifier { Future love(Track track) async { await state?.scrobblenaut.track.love( - artist: TypeConversionUtils.artists_X_String(track.artists!), + artist: track.artists!.asString(), track: track.name!, ); } Future unlove(Track track) async { await state?.scrobblenaut.track.unLove( - artist: TypeConversionUtils.artists_X_String(track.artists!), + artist: track.artists!.asString(), track: track.name!, ); } diff --git a/lib/provider/spotify/album/favorite.dart b/lib/provider/spotify/album/favorite.dart new file mode 100644 index 00000000..cf444d49 --- /dev/null +++ b/lib/provider/spotify/album/favorite.dart @@ -0,0 +1,86 @@ +part of '../spotify.dart'; + +class FavoriteAlbumState extends PaginatedState { + FavoriteAlbumState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + FavoriteAlbumState copyWith({items, offset, limit, hasMore}) { + return FavoriteAlbumState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class FavoriteAlbumNotifier + extends PaginatedAsyncNotifier { + @override + Future> fetch(int offset, int limit) { + return spotify.me + .savedAlbums() + .getPage(limit, offset) + .then((value) => value.items?.toList() ?? []); + } + + @override + build() async { + ref.watch(spotifyProvider); + final items = await fetch(0, 20); + return FavoriteAlbumState( + items: items, + offset: 0, + limit: 20, + hasMore: items.length == 20, + ); + } + + Future addFavorites(List ids) async { + if (state.value == null) return; + + state = await AsyncValue.guard(() async { + await spotify.me.saveAlbums(ids); + final albums = await spotify.albums.list(ids); + + return state.value!.copyWith( + items: [ + ...state.value!.items, + ...albums, + ], + ); + }); + + for (final id in ids) { + ref.invalidate(albumsIsSavedProvider(id)); + } + } + + Future removeFavorites(List ids) async { + if (state.value == null) return; + + state = await AsyncValue.guard(() async { + await spotify.me.removeAlbums(ids); + + return state.value!.copyWith( + items: state.value!.items + .where((element) => !ids.contains(element.id)) + .toList(), + ); + }); + + for (final id in ids) { + ref.invalidate(albumsIsSavedProvider(id)); + } + } +} + +final favoriteAlbumsProvider = + AsyncNotifierProvider( + () => FavoriteAlbumNotifier(), +); diff --git a/lib/provider/spotify/album/is_saved.dart b/lib/provider/spotify/album/is_saved.dart new file mode 100644 index 00000000..987ccdf2 --- /dev/null +++ b/lib/provider/spotify/album/is_saved.dart @@ -0,0 +1,10 @@ +part of '../spotify.dart'; + +final albumsIsSavedProvider = FutureProvider.autoDispose.family( + (ref, albumId) async { + final spotify = ref.watch(spotifyProvider); + return spotify.me.containsSavedAlbums([albumId]).then( + (value) => value[albumId] ?? false, + ); + }, +); diff --git a/lib/provider/spotify/album/releases.dart b/lib/provider/spotify/album/releases.dart new file mode 100644 index 00000000..cacddbdf --- /dev/null +++ b/lib/provider/spotify/album/releases.dart @@ -0,0 +1,87 @@ +part of '../spotify.dart'; + +class AlbumReleasesState extends PaginatedState { + AlbumReleasesState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + AlbumReleasesState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return AlbumReleasesState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class AlbumReleasesNotifier + extends PaginatedAsyncNotifier { + AlbumReleasesNotifier() : super(); + + @override + fetch(int offset, int limit) async { + final market = ref.read(userPreferencesProvider).recommendationMarket; + + final albums = await spotify.browse + .newReleases(country: market) + .getPage(limit, offset); + + return albums.items?.map((album) => album.toAlbum()).toList() ?? []; + } + + @override + build() async { + ref.watch(spotifyProvider); + ref.watch( + userPreferencesProvider.select((s) => s.recommendationMarket), + ); + ref.watch(allFollowedArtistsProvider); + + final albums = await fetch(0, 20); + + return AlbumReleasesState( + items: albums, + offset: 0, + limit: 20, + hasMore: albums.length == 20, + ); + } +} + +final albumReleasesProvider = + AsyncNotifierProvider( + () => AlbumReleasesNotifier(), +); + +final userArtistAlbumReleasesProvider = Provider>((ref) { + final newReleases = ref.watch(albumReleasesProvider); + final userArtistsQuery = ref.watch(allFollowedArtistsProvider); + + if (newReleases.isLoading || userArtistsQuery.isLoading) { + return const []; + } + + final userArtists = + userArtistsQuery.asData?.value.map((s) => s.id!).toList() ?? const []; + + final allReleases = newReleases.asData?.value.items; + final userArtistReleases = allReleases?.where((album) { + return album.artists?.any((artist) => userArtists.contains(artist.id!)) == + true; + }).toList(); + + if (userArtistReleases?.isEmpty == true) { + return allReleases?.toList() ?? []; + } + return userArtistReleases ?? []; +}); diff --git a/lib/provider/spotify/album/tracks.dart b/lib/provider/spotify/album/tracks.dart new file mode 100644 index 00000000..e9f712e7 --- /dev/null +++ b/lib/provider/spotify/album/tracks.dart @@ -0,0 +1,55 @@ +part of '../spotify.dart'; + +class AlbumTracksState extends PaginatedState { + AlbumTracksState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + AlbumTracksState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return AlbumTracksState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class AlbumTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier { + AlbumTracksNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + final tracks = await spotify.albums.tracks(arg.id!).getPage(limit, offset); + return tracks.items?.map((e) => e.asTrack(arg)).toList() ?? []; + } + + @override + build(arg) async { + ref.cacheFor(); + + ref.watch(spotifyProvider); + final tracks = await fetch(arg, 0, 20); + return AlbumTracksState( + items: tracks, + offset: 0, + limit: 20, + hasMore: tracks.length == 20, + ); + } +} + +final albumTracksProvider = AutoDisposeAsyncNotifierProviderFamily< + AlbumTracksNotifier, AlbumTracksState, AlbumSimple>( + () => AlbumTracksNotifier(), +); diff --git a/lib/provider/spotify/artist/albums.dart b/lib/provider/spotify/artist/albums.dart new file mode 100644 index 00000000..16bd8768 --- /dev/null +++ b/lib/provider/spotify/artist/albums.dart @@ -0,0 +1,62 @@ +part of '../spotify.dart'; + +class ArtistAlbumsState extends PaginatedState { + ArtistAlbumsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + ArtistAlbumsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return ArtistAlbumsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class ArtistAlbumsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< + Album, ArtistAlbumsState, String> { + ArtistAlbumsNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + final market = ref.read(userPreferencesProvider).recommendationMarket; + final albums = await spotify.artists + .albums(arg, country: market) + .getPage(limit, offset); + + return albums.items?.toList() ?? []; + } + + @override + build(arg) async { + ref.cacheFor(); + + ref.watch(spotifyProvider); + ref.watch( + userPreferencesProvider.select((s) => s.recommendationMarket), + ); + final albums = await fetch(arg, 0, 20); + return ArtistAlbumsState( + items: albums, + offset: 0, + limit: 20, + hasMore: albums.length == 20, + ); + } +} + +final artistAlbumsProvider = AutoDisposeAsyncNotifierProviderFamily< + ArtistAlbumsNotifier, ArtistAlbumsState, String>( + () => ArtistAlbumsNotifier(), +); diff --git a/lib/provider/spotify/artist/artist.dart b/lib/provider/spotify/artist/artist.dart new file mode 100644 index 00000000..c69badd2 --- /dev/null +++ b/lib/provider/spotify/artist/artist.dart @@ -0,0 +1,10 @@ +part of '../spotify.dart'; + +final artistProvider = + FutureProvider.autoDispose.family((ref, String artistId) { + ref.cacheFor(); + + final spotify = ref.watch(spotifyProvider); + + return spotify.artists.get(artistId); +}); diff --git a/lib/provider/spotify/artist/following.dart b/lib/provider/spotify/artist/following.dart new file mode 100644 index 00000000..4e6bcfe8 --- /dev/null +++ b/lib/provider/spotify/artist/following.dart @@ -0,0 +1,104 @@ +part of '../spotify.dart'; + +class FollowedArtistsState extends CursorPaginatedState { + FollowedArtistsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + FollowedArtistsState copyWith({ + List? items, + String? offset, + int? limit, + bool? hasMore, + }) { + return FollowedArtistsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class FollowedArtistsNotifier + extends CursorPaginatedAsyncNotifier { + FollowedArtistsNotifier() : super(); + + @override + fetch(offset, limit) async { + final artists = await spotify.me.following(FollowingType.artist).getPage( + limit, + offset ?? '', + ); + + return (artists.items?.toList() ?? [], artists.after); + } + + @override + build() async { + ref.watch(spotifyProvider); + final (artists, nextCursor) = await fetch(null, 50); + return FollowedArtistsState( + items: artists, + offset: nextCursor, + limit: 50, + hasMore: artists.length == 50, + ); + } + + Future saveArtists(List artistIds) async { + if (state.value == null) return; + await spotify.me.follow(FollowingType.artist, artistIds); + + state = await AsyncValue.guard(() async { + final artists = await spotify.artists.list(artistIds); + + return state.value!.copyWith( + items: [ + ...state.value!.items, + ...artists, + ], + ); + }); + + for (final id in artistIds) { + ref.invalidate(artistIsFollowingProvider(id)); + } + } + + Future removeArtists(List artistIds) async { + if (state.value == null) return; + await spotify.me.unfollow(FollowingType.artist, artistIds); + + state = await AsyncValue.guard(() async { + final artists = state.value!.items.where((artist) { + return !artistIds.contains(artist.id); + }).toList(); + + return state.value!.copyWith( + items: artists, + ); + }); + + for (final id in artistIds) { + ref.invalidate(artistIsFollowingProvider(id)); + } + } +} + +final followedArtistsProvider = + AsyncNotifierProvider( + () => FollowedArtistsNotifier(), +); + +final allFollowedArtistsProvider = FutureProvider>( + (ref) async { + final spotify = ref.watch(spotifyProvider); + final artists = await spotify.me.following(FollowingType.artist).all(); + return artists.toList(); + }, +); diff --git a/lib/provider/spotify/artist/is_following.dart b/lib/provider/spotify/artist/is_following.dart new file mode 100644 index 00000000..db1be184 --- /dev/null +++ b/lib/provider/spotify/artist/is_following.dart @@ -0,0 +1,10 @@ +part of '../spotify.dart'; + +final artistIsFollowingProvider = FutureProvider.family( + (ref, String artistId) async { + final spotify = ref.watch(spotifyProvider); + return spotify.me.checkFollowing(FollowingType.artist, [artistId]).then( + (value) => value[artistId] ?? false, + ); + }, +); diff --git a/lib/provider/spotify/artist/related.dart b/lib/provider/spotify/artist/related.dart new file mode 100644 index 00000000..317feba3 --- /dev/null +++ b/lib/provider/spotify/artist/related.dart @@ -0,0 +1,11 @@ +part of '../spotify.dart'; + +final relatedArtistsProvider = FutureProvider.autoDispose + .family, String>((ref, artistId) async { + ref.cacheFor(); + + final spotify = ref.watch(spotifyProvider); + final artists = await spotify.artists.relatedArtists(artistId); + + return artists.toList(); +}); diff --git a/lib/provider/spotify/artist/top_tracks.dart b/lib/provider/spotify/artist/top_tracks.dart new file mode 100644 index 00000000..fa40d646 --- /dev/null +++ b/lib/provider/spotify/artist/top_tracks.dart @@ -0,0 +1,15 @@ +part of '../spotify.dart'; + +final artistTopTracksProvider = + FutureProvider.autoDispose.family, String>( + (ref, artistId) async { + ref.cacheFor(); + + final spotify = ref.watch(spotifyProvider); + final market = ref + .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); + final tracks = await spotify.artists.topTracks(artistId, market); + + return tracks.toList(); + }, +); diff --git a/lib/provider/spotify/artist/wikipedia.dart b/lib/provider/spotify/artist/wikipedia.dart new file mode 100644 index 00000000..b2e2e6dc --- /dev/null +++ b/lib/provider/spotify/artist/wikipedia.dart @@ -0,0 +1,12 @@ +part of '../spotify.dart'; + +final artistWikipediaSummaryProvider = FutureProvider.autoDispose + .family((ref, artist) async { + final query = artist.name!.replaceAll(" ", "_"); + final res = await wikipedia.pageContent.pageSummaryTitleGet(query); + + if (res?.type != "standard") { + return await wikipedia.pageContent.pageSummaryTitleGet("${query}_(singer)"); + } + return res; +}); diff --git a/lib/provider/spotify/category/categories.dart b/lib/provider/spotify/category/categories.dart new file mode 100644 index 00000000..7652215c --- /dev/null +++ b/lib/provider/spotify/category/categories.dart @@ -0,0 +1,20 @@ +part of '../spotify.dart'; + +final categoriesProvider = FutureProvider( + (ref) async { + final spotify = ref.watch(spotifyProvider); + final market = ref + .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); + final locale = ref.watch(userPreferencesProvider.select((s) => s.locale)); + final categories = await spotify.categories + .list( + country: market, + locale: Intl.canonicalizedLocale( + locale.toString(), + ), + ) + .all(); + + return categories.toList()..shuffle(); + }, +); diff --git a/lib/provider/spotify/category/genres.dart b/lib/provider/spotify/category/genres.dart new file mode 100644 index 00000000..b4b75b7b --- /dev/null +++ b/lib/provider/spotify/category/genres.dart @@ -0,0 +1,6 @@ +part of '../spotify.dart'; + +final categoryGenresProvider = FutureProvider>((ref) async { + final customSpotify = ref.watch(customSpotifyEndpointProvider); + return await customSpotify.listGenreSeeds(); +}); diff --git a/lib/provider/spotify/category/playlists.dart b/lib/provider/spotify/category/playlists.dart new file mode 100644 index 00000000..979b7f31 --- /dev/null +++ b/lib/provider/spotify/category/playlists.dart @@ -0,0 +1,67 @@ +part of '../spotify.dart'; + +class CategoryPlaylistsState extends PaginatedState { + CategoryPlaylistsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + CategoryPlaylistsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return CategoryPlaylistsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class CategoryPlaylistsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< + PlaylistSimple, CategoryPlaylistsState, String> { + CategoryPlaylistsNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + final preferences = ref.read(userPreferencesProvider); + final playlists = await Pages( + spotify, + "v1/browse/categories/$arg/playlists?country=${preferences.recommendationMarket.name}&locale=${preferences.locale}", + (json) => json == null ? null : PlaylistSimple.fromJson(json), + 'playlists', + (json) => PlaylistsFeatured.fromJson(json), + ).getPage(limit, offset); + + return playlists.items?.whereNotNull().toList() ?? []; + } + + @override + build(arg) async { + ref.cacheFor(); + + ref.watch(spotifyProvider); + ref.watch(userPreferencesProvider.select((s) => s.locale)); + ref.watch(userPreferencesProvider.select((s) => s.recommendationMarket)); + + final playlists = await fetch(arg, 0, 8); + + return CategoryPlaylistsState( + items: playlists, + offset: 0, + limit: 8, + hasMore: playlists.length == 8, + ); + } +} + +final categoryPlaylistsProvider = AutoDisposeAsyncNotifierProviderFamily< + CategoryPlaylistsNotifier, CategoryPlaylistsState, String>( + () => CategoryPlaylistsNotifier(), +); diff --git a/lib/provider/spotify/lyrics/synced.dart b/lib/provider/spotify/lyrics/synced.dart new file mode 100644 index 00000000..6ce74ae7 --- /dev/null +++ b/lib/provider/spotify/lyrics/synced.dart @@ -0,0 +1,172 @@ +part of '../spotify.dart'; + +class SyncedLyricsNotifier extends FamilyAsyncNotifier + with Persistence { + SyncedLyricsNotifier() { + load(); + } + + Track get _track => arg!; + + Future getSpotifyLyrics(String? token) async { + final res = await http.get( + Uri.parse( + "https://spclient.wg.spotify.com/color-lyrics/v2/track/${_track.id}?format=json&market=from_token", + ), + headers: { + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36", + "App-platform": "WebPlayer", + "authorization": "Bearer $token" + }); + + if (res.statusCode != 200) { + return SubtitleSimple( + lyrics: [], + name: _track.name!, + uri: res.request!.url, + rating: 0, + provider: "Spotify", + ); + } + final linesRaw = Map.castFrom( + jsonDecode(res.body), + )["lyrics"]?["lines"] as List?; + + final lines = linesRaw?.map((line) { + return LyricSlice( + time: Duration(milliseconds: int.parse(line["startTimeMs"])), + text: line["words"] as String, + ); + }).toList() ?? + []; + + return SubtitleSimple( + lyrics: lines, + name: _track.name!, + uri: res.request!.url, + rating: 100, + provider: "Spotify", + ); + } + + /// Lyrics credits: [lrclib.net](https://lrclib.net) and their contributors + /// Thanks for their generous public API + Future getLRCLibLyrics() async { + final packageInfo = await PackageInfo.fromPlatform(); + + final res = await http.get( + Uri( + scheme: "https", + host: "lrclib.net", + path: "/api/get", + queryParameters: { + "artist_name": _track.artists?.first.name, + "track_name": _track.name, + "album_name": _track.album?.name, + "duration": _track.duration?.inSeconds.toString(), + }, + ), + headers: { + "User-Agent": + "Spotube v${packageInfo.version} (https://github.com/KRTirtho/spotube)" + }, + ); + + if (res.statusCode != 200) { + return SubtitleSimple( + lyrics: [], + name: _track.name!, + uri: res.request!.url, + rating: 0, + provider: "LRCLib", + ); + } + + final json = jsonDecode(res.body) as Map; + + final syncedLyricsRaw = json["syncedLyrics"] as String?; + final syncedLyrics = syncedLyricsRaw?.isNotEmpty == true + ? Lrc.parse(syncedLyricsRaw!) + .lyrics + .map(LyricSlice.fromLrcLine) + .toList() + : null; + + if (syncedLyrics?.isNotEmpty == true) { + return SubtitleSimple( + lyrics: syncedLyrics!, + name: _track.name!, + uri: res.request!.url, + rating: 100, + provider: "LRCLib", + ); + } + + final plainLyrics = (json["plainLyrics"] as String) + .split("\n") + .map((line) => LyricSlice(text: line, time: Duration.zero)) + .toList(); + + return SubtitleSimple( + lyrics: plainLyrics, + name: _track.name!, + uri: res.request!.url, + rating: 0, + provider: "LRCLib", + ); + } + + @override + FutureOr build(track) async { + try { + final spotify = ref.watch(spotifyProvider); + if (track == null) { + throw "No track currently"; + } + final token = await spotify.getCredentials(); + SubtitleSimple lyrics = await getSpotifyLyrics(token.accessToken); + + if (lyrics.lyrics.isEmpty) { + lyrics = await getLRCLibLyrics(); + } + + if (lyrics.lyrics.isEmpty) { + throw Exception("Unable to find lyrics"); + } + + return lyrics; + } catch (e, stackTrace) { + Catcher2.reportCheckedError(e, stackTrace); + rethrow; + } + } + + @override + FutureOr fromJson(Map json) => + SubtitleSimple.fromJson(json.castKeyDeep()); + + @override + Map toJson(SubtitleSimple data) => data.toJson(); +} + +final syncedLyricsDelayProvider = StateProvider((ref) => 0); + +final syncedLyricsProvider = + AsyncNotifierProviderFamily( + () => SyncedLyricsNotifier(), +); + +final syncedLyricsMapProvider = + FutureProvider.family((ref, Track? track) async { + final syncedLyrics = await ref.watch(syncedLyricsProvider(track).future); + + final isStaticLyrics = + syncedLyrics.lyrics.every((l) => l.time == Duration.zero); + + final lyricsMap = syncedLyrics.lyrics + .map((lyric) => {lyric.time.inSeconds: lyric.text}) + .reduce((accumulator, lyricSlice) => {...accumulator, ...lyricSlice}); + + return (static: isStaticLyrics, lyricsMap: lyricsMap); +}); diff --git a/lib/provider/spotify/playlist/favorite.dart b/lib/provider/spotify/playlist/favorite.dart new file mode 100644 index 00000000..a0e051aa --- /dev/null +++ b/lib/provider/spotify/playlist/favorite.dart @@ -0,0 +1,122 @@ +part of '../spotify.dart'; + +class FavoritePlaylistsState extends PaginatedState { + FavoritePlaylistsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + FavoritePlaylistsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return FavoritePlaylistsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class FavoritePlaylistsNotifier + extends PaginatedAsyncNotifier { + FavoritePlaylistsNotifier() : super(); + + @override + fetch(int offset, int limit) async { + final playlists = await spotify.playlists.me.getPage( + limit, + offset, + ); + + return playlists.items?.toList() ?? []; + } + + @override + build() async { + ref.watch(spotifyProvider); + final playlists = await fetch(0, 20); + + return FavoritePlaylistsState( + items: playlists, + offset: 0, + limit: 20, + hasMore: playlists.length == 20, + ); + } + + Future addFavorite(PlaylistSimple playlist) async { + await update((state) async { + await spotify.playlists.followPlaylist(playlist.id!); + return state.copyWith( + items: [...state.items, playlist], + ); + }); + + ref.invalidate(isFavoritePlaylistProvider(playlist.id!)); + } + + Future removeFavorite(PlaylistSimple playlist) async { + await update((state) async { + await spotify.playlists.unfollowPlaylist(playlist.id!); + return state.copyWith( + items: state.items.where((e) => e.id != playlist.id).toList(), + ); + }); + + ref.invalidate(isFavoritePlaylistProvider(playlist.id!)); + } + + Future addTracks(String playlistId, List trackIds) async { + if (state.value == null) return; + + final spotify = ref.read(spotifyProvider); + + await spotify.playlists.addTracks( + trackIds.map((id) => 'spotify:track:$id').toList(), + playlistId, + ); + + ref.invalidate(playlistTracksProvider(playlistId)); + } + + Future removeTracks(String playlistId, List trackIds) async { + if (state.value == null) return; + + final spotify = ref.read(spotifyProvider); + + await spotify.playlists.removeTracks( + trackIds.map((id) => 'spotify:track:$id').toList(), + playlistId, + ); + + ref.invalidate(playlistTracksProvider(playlistId)); + } +} + +final favoritePlaylistsProvider = + AsyncNotifierProvider( + () => FavoritePlaylistsNotifier(), +); + +final isFavoritePlaylistProvider = FutureProvider.family( + (ref, id) async { + final spotify = ref.watch(spotifyProvider); + final me = ref.watch(meProvider); + + if (me.value == null) { + return false; + } + + final follows = + await spotify.playlists.followedByUsers(id, [me.value!.id!]); + + return follows[me.value!.id!] ?? false; + }, +); diff --git a/lib/provider/spotify/playlist/featured.dart b/lib/provider/spotify/playlist/featured.dart new file mode 100644 index 00000000..69057e5d --- /dev/null +++ b/lib/provider/spotify/playlist/featured.dart @@ -0,0 +1,58 @@ +part of '../spotify.dart'; + +class FeaturedPlaylistsState extends PaginatedState { + FeaturedPlaylistsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + FeaturedPlaylistsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return FeaturedPlaylistsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class FeaturedPlaylistsNotifier + extends PaginatedAsyncNotifier { + FeaturedPlaylistsNotifier() : super(); + + @override + fetch(int offset, int limit) async { + final playlists = await spotify.playlists.featured.getPage( + limit, + offset, + ); + + return playlists.items?.toList() ?? []; + } + + @override + build() async { + ref.watch(spotifyProvider); + final playlists = await fetch(0, 20); + + return FeaturedPlaylistsState( + items: playlists, + offset: 0, + limit: 20, + hasMore: playlists.length == 20, + ); + } +} + +final featuredPlaylistsProvider = + AsyncNotifierProvider( + () => FeaturedPlaylistsNotifier(), +); diff --git a/lib/provider/spotify/playlist/generate.dart b/lib/provider/spotify/playlist/generate.dart new file mode 100644 index 00000000..15447b54 --- /dev/null +++ b/lib/provider/spotify/playlist/generate.dart @@ -0,0 +1,40 @@ +part of '../spotify.dart'; + +final generatePlaylistProvider = FutureProvider.autoDispose + .family, GeneratePlaylistProviderInput>( + (ref, input) async { + final spotify = ref.watch(spotifyProvider); + final market = ref.watch( + userPreferencesProvider.select((s) => s.recommendationMarket), + ); + + final recommendation = await spotify.recommendations + .get( + limit: input.limit, + seedArtists: input.seedArtists?.toList(), + seedGenres: input.seedGenres?.toList(), + seedTracks: input.seedTracks?.toList(), + market: market, + max: (input.max?.toJson()?..removeWhere((key, value) => value == null)) + ?.cast(), + min: (input.min?.toJson()?..removeWhere((key, value) => value == null)) + ?.cast(), + target: (input.target?.toJson() + ?..removeWhere((key, value) => value == null)) + ?.cast(), + ) + .catchError((e, stackTrace) { + Catcher2.reportCheckedError(e, stackTrace); + return Recommendations(); + }); + + if (recommendation.tracks?.isEmpty ?? true) { + return []; + } + + final tracks = await spotify.tracks + .list(recommendation.tracks!.map((e) => e.id!).toList()); + + return tracks.toList(); + }, +); diff --git a/lib/provider/spotify/playlist/liked.dart b/lib/provider/spotify/playlist/liked.dart new file mode 100644 index 00000000..52463d3d --- /dev/null +++ b/lib/provider/spotify/playlist/liked.dart @@ -0,0 +1,49 @@ +part of '../spotify.dart'; + +class LikedTracksNotifier extends AsyncNotifier> with Persistence { + LikedTracksNotifier() { + load(); + } + + @override + FutureOr> build() async { + final spotify = ref.watch(spotifyProvider); + final savedTracked = await spotify.tracks.me.saved.all(); + + return savedTracked.map((e) => e.track!).toList(); + } + + Future toggleFavorite(Track track) async { + if (state.value == null) return; + final spotify = ref.read(spotifyProvider); + + await update((tracks) async { + final isLiked = tracks.map((e) => e.id).contains(track.id); + + if (isLiked) { + await spotify.tracks.me.removeOne(track.id!); + return tracks.where((e) => e.id != track.id).toList(); + } else { + await spotify.tracks.me.saveOne(track.id!); + return [track, ...tracks]; + } + }); + } + + @override + FutureOr> fromJson(Map json) { + return (json['tracks'] as List).map((e) => Track.fromJson(e)).toList(); + } + + @override + Map toJson(List data) { + return { + 'tracks': data.map((e) => e.toJson()).toList(), + }; + } +} + +final likedTracksProvider = + AsyncNotifierProvider>( + () => LikedTracksNotifier(), +); diff --git a/lib/provider/spotify/playlist/playlist.dart b/lib/provider/spotify/playlist/playlist.dart new file mode 100644 index 00000000..fd420cd9 --- /dev/null +++ b/lib/provider/spotify/playlist/playlist.dart @@ -0,0 +1,90 @@ +part of '../spotify.dart'; + +typedef PlaylistInput = ({ + String playlistName, + bool? public, + bool? collaborative, + String? description, + String? base64Image, +}); + +class PlaylistNotifier extends FamilyAsyncNotifier { + @override + FutureOr build(String arg) { + final spotify = ref.watch(spotifyProvider); + return spotify.playlists.get(arg); + } + + Future create(PlaylistInput input, [ValueChanged? onError]) async { + if (state is AsyncLoading) return; + state = const AsyncLoading(); + + final spotify = ref.read(spotifyProvider); + final me = ref.read(meProvider); + + if (me.value == null) return; + + state = await AsyncValue.guard(() async { + try { + final playlist = await spotify.playlists.createPlaylist( + me.value!.id!, + input.playlistName, + collaborative: input.collaborative, + description: input.description, + public: input.public, + ); + + if (input.base64Image != null) { + await spotify.playlists.updatePlaylistImage( + playlist.id!, + input.base64Image!, + ); + } + + return playlist; + } catch (e) { + onError?.call(e); + rethrow; + } + }); + + ref.invalidate(favoritePlaylistsProvider); + } + + Future modify(PlaylistInput input, [ValueChanged? onError]) async { + if (state.value == null) return; + + final spotify = ref.read(spotifyProvider); + + await update((state) async { + try { + await spotify.playlists.updatePlaylist( + state.id!, + input.playlistName, + collaborative: input.collaborative, + description: input.description, + public: input.public, + ); + + if (input.base64Image != null) { + await spotify.playlists.updatePlaylistImage( + state.id!, + input.base64Image!, + ); + } + + return spotify.playlists.get(state.id!); + } catch (e) { + onError?.call(e); + rethrow; + } + }); + + ref.invalidate(favoritePlaylistsProvider); + } +} + +final playlistProvider = + AsyncNotifierProvider.family( + () => PlaylistNotifier(), +); diff --git a/lib/provider/spotify/playlist/tracks.dart b/lib/provider/spotify/playlist/tracks.dart new file mode 100644 index 00000000..1803f6fc --- /dev/null +++ b/lib/provider/spotify/playlist/tracks.dart @@ -0,0 +1,64 @@ +part of '../spotify.dart'; + +class PlaylistTracksState extends PaginatedState { + PlaylistTracksState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + PlaylistTracksState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return PlaylistTracksState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class PlaylistTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< + Track, PlaylistTracksState, String> { + PlaylistTracksNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + final tracks = await spotify.playlists + .getTracksByPlaylistId(arg) + .getPage(limit, offset); + + /// Filter out tracks with null id because some personal playlists + /// may contain local tracks that are not available in the Spotify catalog + return tracks.items + ?.where((track) => track.id != null && track.type == "track") + .toList() ?? + []; + } + + @override + build(arg) async { + ref.cacheFor(); + + ref.watch(spotifyProvider); + final tracks = await fetch(arg, 0, 20); + + return PlaylistTracksState( + items: tracks, + offset: 0, + limit: 20, + hasMore: tracks.length == 20, + ); + } +} + +final playlistTracksProvider = AutoDisposeAsyncNotifierProviderFamily< + PlaylistTracksNotifier, PlaylistTracksState, String>( + () => PlaylistTracksNotifier(), +); diff --git a/lib/provider/spotify/search/search.dart b/lib/provider/spotify/search/search.dart new file mode 100644 index 00000000..bd97f08b --- /dev/null +++ b/lib/provider/spotify/search/search.dart @@ -0,0 +1,76 @@ +part of '../spotify.dart'; + +final searchTermStateProvider = StateProvider.autoDispose( + (ref) { + ref.cacheFor(const Duration(minutes: 2)); + return ""; + }, +); + +class SearchState extends PaginatedState { + SearchState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + SearchState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return SearchState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class SearchNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier, SearchType> { + SearchNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + if (state.value == null) return []; + final results = await spotify.search + .get( + ref.read(searchTermStateProvider), + types: [arg], + market: ref.read(userPreferencesProvider).recommendationMarket, + ) + .getPage(limit, offset); + + return results.expand((e) => e.items ?? []).toList().cast(); + } + + @override + build(arg) async { + ref.cacheFor(const Duration(minutes: 2)); + + ref.watch(searchTermStateProvider); + ref.watch(spotifyProvider); + ref.watch( + userPreferencesProvider.select((value) => value.recommendationMarket), + ); + + final results = await fetch(arg, 0, 10); + + return SearchState( + items: results, + offset: 0, + limit: 10, + hasMore: results.length == 10, + ); + } +} + +final searchProvider = AsyncNotifierProvider.autoDispose + .family( + () => SearchNotifier(), +); diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart new file mode 100644 index 00000000..816420f6 --- /dev/null +++ b/lib/provider/spotify/spotify.dart @@ -0,0 +1,76 @@ +library spotify; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:intl/intl.dart'; +import 'package:lrc/lrc.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:spotify/spotify.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +// ignore: depend_on_referenced_packages, implementation_imports +import 'package:riverpod/src/async_notifier.dart'; +import 'package:spotube/extensions/album_simple.dart'; +import 'package:spotube/extensions/map.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/lyrics.dart'; +import 'package:spotube/models/spotify/recommendation_seeds.dart'; +import 'package:spotube/models/spotify_friends.dart'; +import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; +import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/wikipedia/wikipedia.dart'; +import 'package:spotube/utils/persisted_state_notifier.dart'; +import 'package:http/http.dart' as http; + +import 'package:wikipedia_api/wikipedia_api.dart'; + +part 'album/favorite.dart'; +part 'album/tracks.dart'; +part 'album/releases.dart'; +part 'album/is_saved.dart'; + +part 'artist/artist.dart'; +part 'artist/is_following.dart'; +part 'artist/following.dart'; +part 'artist/top_tracks.dart'; +part 'artist/albums.dart'; +part 'artist/wikipedia.dart'; +part 'artist/related.dart'; + +part 'category/genres.dart'; +part 'category/categories.dart'; +part 'category/playlists.dart'; + +part 'lyrics/synced.dart'; + +part 'playlist/favorite.dart'; +part 'playlist/playlist.dart'; +part 'playlist/liked.dart'; +part 'playlist/tracks.dart'; +part 'playlist/featured.dart'; +part 'playlist/generate.dart'; + +part 'search/search.dart'; + +part 'user/me.dart'; +part 'user/friends.dart'; + +part 'tracks/track.dart'; + +part 'views/view.dart'; + +part 'utils/mixin.dart'; +part 'utils/state.dart'; +part 'utils/provider.dart'; +part 'utils/persistence.dart'; +part 'utils/async.dart'; + +part 'utils/provider/paginated.dart'; +part 'utils/provider/cursor.dart'; +part 'utils/provider/paginated_family.dart'; +part 'utils/provider/cursor_family.dart'; diff --git a/lib/provider/spotify/tracks/track.dart b/lib/provider/spotify/tracks/track.dart new file mode 100644 index 00000000..e3913b1f --- /dev/null +++ b/lib/provider/spotify/tracks/track.dart @@ -0,0 +1,10 @@ +part of '../spotify.dart'; + +final trackProvider = + FutureProvider.autoDispose.family((ref, id) async { + ref.cacheFor(); + + final spotify = ref.watch(spotifyProvider); + + return spotify.tracks.get(id); +}); diff --git a/lib/provider/spotify/user/friends.dart b/lib/provider/spotify/user/friends.dart new file mode 100644 index 00000000..b9cc0f46 --- /dev/null +++ b/lib/provider/spotify/user/friends.dart @@ -0,0 +1,7 @@ +part of '../spotify.dart'; + +final friendsProvider = FutureProvider((ref) async { + final customSpotify = ref.watch(customSpotifyEndpointProvider); + + return customSpotify.getFriendActivity(); +}); diff --git a/lib/provider/spotify/user/me.dart b/lib/provider/spotify/user/me.dart new file mode 100644 index 00000000..c5949e1f --- /dev/null +++ b/lib/provider/spotify/user/me.dart @@ -0,0 +1,6 @@ +part of '../spotify.dart'; + +final meProvider = FutureProvider((ref) async { + final spotify = ref.watch(spotifyProvider); + return spotify.me.get(); +}); diff --git a/lib/provider/spotify/utils/async.dart b/lib/provider/spotify/utils/async.dart new file mode 100644 index 00000000..1040d682 --- /dev/null +++ b/lib/provider/spotify/utils/async.dart @@ -0,0 +1,5 @@ +part of '../spotify.dart'; + +extension PaginationExtension on AsyncValue { + bool get isLoadingNextPage => this is AsyncData && this is AsyncLoadingNext; +} diff --git a/lib/provider/spotify/utils/mixin.dart b/lib/provider/spotify/utils/mixin.dart new file mode 100644 index 00000000..0da14c6f --- /dev/null +++ b/lib/provider/spotify/utils/mixin.dart @@ -0,0 +1,24 @@ +part of '../spotify.dart'; + +// ignore: invalid_use_of_internal_member +mixin SpotifyMixin on AsyncNotifierBase { + SpotifyApi get spotify => ref.read(spotifyProvider); +} + +extension on AutoDisposeAsyncNotifierProviderRef { + // When invoked keeps your provider alive for [duration] + void cacheFor([Duration duration = const Duration(minutes: 5)]) { + final link = keepAlive(); + final timer = Timer(duration, () => link.close()); + onDispose(() => timer.cancel()); + } +} + +extension on AutoDisposeRef { + // When invoked keeps your provider alive for [duration] + void cacheFor([Duration duration = const Duration(minutes: 5)]) { + final link = keepAlive(); + final timer = Timer(duration, () => link.close()); + onDispose(() => timer.cancel()); + } +} diff --git a/lib/provider/spotify/utils/persistence.dart b/lib/provider/spotify/utils/persistence.dart new file mode 100644 index 00000000..14d3c940 --- /dev/null +++ b/lib/provider/spotify/utils/persistence.dart @@ -0,0 +1,40 @@ +part of '../spotify.dart'; + +// ignore: invalid_use_of_internal_member +mixin Persistence on BuildlessAsyncNotifier { + LazyBox get store => Hive.lazyBox("spotube_cache"); + + FutureOr fromJson(Map json); + Map toJson(T data); + + FutureOr onInit() {} + + Future load() async { + final json = await store.get(runtimeType.toString()); + if (json != null || + (json is Map && json.entries.isNotEmpty) || + (json is List && json.isNotEmpty)) { + state = AsyncData( + await fromJson( + PersistedStateNotifier.castNestedJson(json), + ), + ); + } + + await onInit(); + } + + Future save() async { + await store.put( + runtimeType.toString(), + state.value == null ? null : toJson(state.value as T), + ); + } + + @override + set state(AsyncValue value) { + if (state == value) return; + super.state = value; + save(); + } +} diff --git a/lib/provider/spotify/utils/provider.dart b/lib/provider/spotify/utils/provider.dart new file mode 100644 index 00000000..50458c3a --- /dev/null +++ b/lib/provider/spotify/utils/provider.dart @@ -0,0 +1,6 @@ +part of '../spotify.dart'; + +// ignore: subtype_of_sealed_class +class AsyncLoadingNext extends AsyncData { + const AsyncLoadingNext(super.value); +} diff --git a/lib/provider/spotify/utils/provider/cursor.dart b/lib/provider/spotify/utils/provider/cursor.dart new file mode 100644 index 00000000..c241827e --- /dev/null +++ b/lib/provider/spotify/utils/provider/cursor.dart @@ -0,0 +1,56 @@ +part of '../../spotify.dart'; + +mixin CursorPaginatedAsyncNotifierMixin> + // ignore: invalid_use_of_internal_member + on AsyncNotifierBase { + Future<(List items, String nextCursor)> fetch(String? offset, int limit); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch(state.value!.offset, state.value!.limit); + return state.value!.copyWith( + hasMore: items.$1.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items.$1, + ], + offset: items.$2, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch(state.offset, state.limit); + + hasMore = items.$1.length == state.limit; + return state.copyWith( + items: [...state.items, ...items.$1], + offset: items.$2, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} + +abstract class CursorPaginatedAsyncNotifier> extends AsyncNotifier + with CursorPaginatedAsyncNotifierMixin, SpotifyMixin {} + +abstract class AutoDisposeCursorPaginatedAsyncNotifier> extends AutoDisposeAsyncNotifier + with CursorPaginatedAsyncNotifierMixin, SpotifyMixin {} diff --git a/lib/provider/spotify/utils/provider/cursor_family.dart b/lib/provider/spotify/utils/provider/cursor_family.dart new file mode 100644 index 00000000..ea8577de --- /dev/null +++ b/lib/provider/spotify/utils/provider/cursor_family.dart @@ -0,0 +1,113 @@ +part of '../../spotify.dart'; + +abstract class FamilyCursorPaginatedAsyncNotifier< + K, + T extends CursorPaginatedState, + A> extends FamilyAsyncNotifier with SpotifyMixin { + Future<(List items, String nextCursor)> fetch( + A arg, + String? offset, + int limit, + ); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch(arg, state.value!.offset, state.value!.limit); + return state.value!.copyWith( + hasMore: items.$1.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items.$1, + ], + offset: items.$2, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch( + arg, + state.offset, + state.limit, + ); + + hasMore = items.$1.length == state.limit; + return state.copyWith( + items: [...state.items, ...items.$1], + offset: items.$2, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} + +abstract class AutoDisposeFamilyCursorPaginatedAsyncNotifier< + K, + T extends CursorPaginatedState, + A> extends AutoDisposeFamilyAsyncNotifier with SpotifyMixin { + Future<(List items, String nextCursor)> fetch( + A arg, + String? offset, + int limit, + ); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch(arg, state.value!.offset, state.value!.limit); + return state.value!.copyWith( + hasMore: items.$1.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items.$1, + ], + offset: items.$2, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch( + arg, + state.offset, + state.limit, + ); + + hasMore = items.$1.length == state.limit; + return state.copyWith( + items: [...state.items, ...items.$1], + offset: items.$2, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} diff --git a/lib/provider/spotify/utils/provider/paginated.dart b/lib/provider/spotify/utils/provider/paginated.dart new file mode 100644 index 00000000..30b66e67 --- /dev/null +++ b/lib/provider/spotify/utils/provider/paginated.dart @@ -0,0 +1,63 @@ +part of '../../spotify.dart'; + +mixin PaginatedAsyncNotifierMixin> + // ignore: invalid_use_of_internal_member + on AsyncNotifierBase { + Future> fetch(int offset, int limit); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch( + state.value!.offset + state.value!.limit, + state.value!.limit, + ); + return state.value!.copyWith( + hasMore: items.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items, + ], + offset: state.value!.offset + state.value!.limit, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch( + state.offset + state.limit, + state.limit, + ); + + hasMore = items.length == state.limit; + return state.copyWith( + items: [...state.items, ...items], + offset: state.offset + state.limit, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} + +abstract class PaginatedAsyncNotifier> + extends AsyncNotifier + with PaginatedAsyncNotifierMixin, SpotifyMixin {} + +abstract class AutoDisposePaginatedAsyncNotifier> + extends AutoDisposeAsyncNotifier + with PaginatedAsyncNotifierMixin, SpotifyMixin {} diff --git a/lib/provider/spotify/utils/provider/paginated_family.dart b/lib/provider/spotify/utils/provider/paginated_family.dart new file mode 100644 index 00000000..84c6ba20 --- /dev/null +++ b/lib/provider/spotify/utils/provider/paginated_family.dart @@ -0,0 +1,113 @@ +part of '../../spotify.dart'; + +abstract class FamilyPaginatedAsyncNotifier< + K, + T extends BasePaginatedState, + A> extends FamilyAsyncNotifier with SpotifyMixin { + Future> fetch(A arg, int offset, int limit); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch( + arg, + state.value!.offset + state.value!.limit, + state.value!.limit, + ); + return state.value!.copyWith( + hasMore: items.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items, + ], + offset: state.value!.offset + state.value!.limit, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch( + arg, + state.offset + state.limit, + state.limit, + ); + + hasMore = items.length == state.limit; + return state.copyWith( + items: [...state.items, ...items], + offset: state.offset + state.limit, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} + +abstract class AutoDisposeFamilyPaginatedAsyncNotifier< + K, + T extends BasePaginatedState, + A> extends AutoDisposeFamilyAsyncNotifier with SpotifyMixin { + Future> fetch(A arg, int offset, int limit); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch( + arg, + state.value!.offset + state.value!.limit, + state.value!.limit, + ); + return state.value!.copyWith( + hasMore: items.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items, + ], + offset: state.value!.offset + state.value!.limit, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch( + arg, + state.offset + state.limit, + state.limit, + ); + + hasMore = items.length == state.limit; + return state.copyWith( + items: [...state.items, ...items], + offset: state.offset + state.limit, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} diff --git a/lib/provider/spotify/utils/state.dart b/lib/provider/spotify/utils/state.dart new file mode 100644 index 00000000..4b79ac7d --- /dev/null +++ b/lib/provider/spotify/utils/state.dart @@ -0,0 +1,56 @@ +part of '../spotify.dart'; + +abstract class BasePaginatedState { + final List items; + final Cursor offset; + final int limit; + final bool hasMore; + + BasePaginatedState({ + required this.items, + required this.offset, + required this.limit, + required this.hasMore, + }); + + BasePaginatedState copyWith({ + List? items, + Cursor? offset, + int? limit, + bool? hasMore, + }); +} + +abstract class PaginatedState extends BasePaginatedState { + PaginatedState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + PaginatedState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }); +} + +abstract class CursorPaginatedState extends BasePaginatedState { + CursorPaginatedState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + CursorPaginatedState copyWith({ + List? items, + String? offset, + int? limit, + bool? hasMore, + }); +} diff --git a/lib/provider/spotify/views/view.dart b/lib/provider/spotify/views/view.dart new file mode 100644 index 00000000..f1af998b --- /dev/null +++ b/lib/provider/spotify/views/view.dart @@ -0,0 +1,19 @@ +part of '../spotify.dart'; + +final viewProvider = FutureProvider.family, String>( + (ref, viewName) async { + final customSpotify = ref.watch(customSpotifyEndpointProvider); + final market = ref.watch( + userPreferencesProvider.select((s) => s.recommendationMarket), + ); + final locale = ref.watch( + userPreferencesProvider.select((s) => s.locale), + ); + + return customSpotify.getView( + viewName, + market: market, + locale: Intl.canonicalizedLocale(locale.toString()), + ); + }, +); diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index 875f36cc..42b38746 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -127,6 +127,10 @@ class UserPreferencesNotifier extends PersistedStateNotifier { state = state.copyWith(endlessPlayback: endless); } + void setEnableConnect(bool enable) { + state = state.copyWith(enableConnect: enable); + } + Future _getDefaultDownloadDirectory() async { if (kIsAndroid) return "/storage/emulated/0/Download/Spotube"; diff --git a/lib/provider/user_preferences/user_preferences_state.dart b/lib/provider/user_preferences/user_preferences_state.dart index cf6c0597..e35c73b5 100644 --- a/lib/provider/user_preferences/user_preferences_state.dart +++ b/lib/provider/user_preferences/user_preferences_state.dart @@ -91,6 +91,7 @@ class UserPreferences with _$UserPreferences { @Default(SourceCodecs.m4a) SourceCodecs downloadMusicCodec, @Default(true) bool discordPresence, @Default(true) bool endlessPlayback, + @Default(false) bool enableConnect, }) = _UserPreferences; factory UserPreferences.fromJson(Map json) => _$UserPreferencesFromJson(json); diff --git a/lib/provider/user_preferences/user_preferences_state.freezed.dart b/lib/provider/user_preferences/user_preferences_state.freezed.dart index 4d08d1a9..a5b076bb 100644 --- a/lib/provider/user_preferences/user_preferences_state.freezed.dart +++ b/lib/provider/user_preferences/user_preferences_state.freezed.dart @@ -50,6 +50,7 @@ mixin _$UserPreferences { SourceCodecs get downloadMusicCodec => throw _privateConstructorUsedError; bool get discordPresence => throw _privateConstructorUsedError; bool get endlessPlayback => throw _privateConstructorUsedError; + bool get enableConnect => throw _privateConstructorUsedError; Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -93,7 +94,8 @@ abstract class $UserPreferencesCopyWith<$Res> { SourceCodecs streamMusicCodec, SourceCodecs downloadMusicCodec, bool discordPresence, - bool endlessPlayback}); + bool endlessPlayback, + bool enableConnect}); } /// @nodoc @@ -131,6 +133,7 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> Object? downloadMusicCodec = null, Object? discordPresence = null, Object? endlessPlayback = null, + Object? enableConnect = null, }) { return _then(_value.copyWith( audioQuality: null == audioQuality @@ -221,6 +224,10 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> ? _value.endlessPlayback : endlessPlayback // ignore: cast_nullable_to_non_nullable as bool, + enableConnect: null == enableConnect + ? _value.enableConnect + : enableConnect // ignore: cast_nullable_to_non_nullable + as bool, ) as $Val); } } @@ -263,7 +270,8 @@ abstract class _$$UserPreferencesImplCopyWith<$Res> SourceCodecs streamMusicCodec, SourceCodecs downloadMusicCodec, bool discordPresence, - bool endlessPlayback}); + bool endlessPlayback, + bool enableConnect}); } /// @nodoc @@ -299,6 +307,7 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> Object? downloadMusicCodec = null, Object? discordPresence = null, Object? endlessPlayback = null, + Object? enableConnect = null, }) { return _then(_$UserPreferencesImpl( audioQuality: null == audioQuality @@ -389,6 +398,10 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> ? _value.endlessPlayback : endlessPlayback // ignore: cast_nullable_to_non_nullable as bool, + enableConnect: null == enableConnect + ? _value.enableConnect + : enableConnect // ignore: cast_nullable_to_non_nullable + as bool, )); } } @@ -426,7 +439,8 @@ class _$UserPreferencesImpl implements _UserPreferences { this.streamMusicCodec = SourceCodecs.weba, this.downloadMusicCodec = SourceCodecs.m4a, this.discordPresence = true, - this.endlessPlayback = true}); + this.endlessPlayback = true, + this.enableConnect = false}); factory _$UserPreferencesImpl.fromJson(Map json) => _$$UserPreferencesImplFromJson(json); @@ -503,10 +517,13 @@ class _$UserPreferencesImpl implements _UserPreferences { @override @JsonKey() final bool endlessPlayback; + @override + @JsonKey() + final bool enableConnect; @override String toString() { - return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback)'; + return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)'; } @override @@ -556,7 +573,9 @@ class _$UserPreferencesImpl implements _UserPreferences { (identical(other.discordPresence, discordPresence) || other.discordPresence == discordPresence) && (identical(other.endlessPlayback, endlessPlayback) || - other.endlessPlayback == endlessPlayback)); + other.endlessPlayback == endlessPlayback) && + (identical(other.enableConnect, enableConnect) || + other.enableConnect == enableConnect)); } @JsonKey(ignore: true) @@ -584,7 +603,8 @@ class _$UserPreferencesImpl implements _UserPreferences { streamMusicCodec, downloadMusicCodec, discordPresence, - endlessPlayback + endlessPlayback, + enableConnect ]); @JsonKey(ignore: true) @@ -633,7 +653,8 @@ abstract class _UserPreferences implements UserPreferences { final SourceCodecs streamMusicCodec, final SourceCodecs downloadMusicCodec, final bool discordPresence, - final bool endlessPlayback}) = _$UserPreferencesImpl; + final bool endlessPlayback, + final bool enableConnect}) = _$UserPreferencesImpl; factory _UserPreferences.fromJson(Map json) = _$UserPreferencesImpl.fromJson; @@ -691,6 +712,8 @@ abstract class _UserPreferences implements UserPreferences { @override bool get endlessPlayback; @override + bool get enableConnect; + @override @JsonKey(ignore: true) _$$UserPreferencesImplCopyWith<_$UserPreferencesImpl> get copyWith => throw _privateConstructorUsedError; diff --git a/lib/provider/user_preferences/user_preferences_state.g.dart b/lib/provider/user_preferences/user_preferences_state.g.dart index ce488247..8bdd12cc 100644 --- a/lib/provider/user_preferences/user_preferences_state.g.dart +++ b/lib/provider/user_preferences/user_preferences_state.g.dart @@ -59,6 +59,7 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson( SourceCodecs.m4a, discordPresence: json['discordPresence'] as bool? ?? true, endlessPlayback: json['endlessPlayback'] as bool? ?? true, + enableConnect: json['enableConnect'] as bool? ?? false, ); Map _$$UserPreferencesImplToJson( @@ -87,6 +88,7 @@ Map _$$UserPreferencesImplToJson( 'downloadMusicCodec': _$SourceCodecsEnumMap[instance.downloadMusicCodec]!, 'discordPresence': instance.discordPresence, 'endlessPlayback': instance.endlessPlayback, + 'enableConnect': instance.enableConnect, }; const _$SourceQualitiesEnumMap = { diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index b3957964..0a22bec1 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -1,4 +1,5 @@ import 'package:catcher_2/catcher_2.dart'; +import 'package:media_kit/media_kit.dart'; import 'package:spotube/services/audio_player/mk_state_player.dart'; // import 'package:just_audio/just_audio.dart' as ja; import 'dart:async'; @@ -14,7 +15,7 @@ part 'audio_player_impl.dart'; abstract class AudioPlayerInterface { final MkPlayerWithState _mkPlayer; - // final ja.AudioPlayer? _justAudio; + // final ja.AudioPlayer? _justAudxio; AudioPlayerInterface() : _mkPlayer = MkPlayerWithState( @@ -60,6 +61,14 @@ abstract class AudioPlayerInterface { } } + Future get selectedDevice async { + return _mkPlayer.state.audioDevice; + } + + Future> get devices async { + return _mkPlayer.state.audioDevices; + } + bool get hasSource { return _mkPlayer.playlist.medias.isNotEmpty; // if (mkSupportedPlatform) { diff --git a/lib/services/audio_player/audio_player_impl.dart b/lib/services/audio_player/audio_player_impl.dart index 2af94dd7..bfa13220 100644 --- a/lib/services/audio_player/audio_player_impl.dart +++ b/lib/services/audio_player/audio_player_impl.dart @@ -83,6 +83,10 @@ class SpotubeAudioPlayer extends AudioPlayerInterface // await _justAudio?.setSpeed(speed); } + Future setAudioDevice(AudioDevice device) async { + await _mkPlayer.setAudioDevice(device); + } + Future dispose() async { await _mkPlayer.dispose(); // await _justAudio?.dispose(); diff --git a/lib/services/audio_player/audio_players_streams_mixin.dart b/lib/services/audio_player/audio_players_streams_mixin.dart index a736dc1c..54e36c6b 100644 --- a/lib/services/audio_player/audio_players_streams_mixin.dart +++ b/lib/services/audio_player/audio_players_streams_mixin.dart @@ -140,4 +140,12 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { // .cast(); // } } + + Stream> get devicesStream => + _mkPlayer.stream.audioDevices.asBroadcastStream(); + + Stream get selectedDeviceStream => + _mkPlayer.stream.audioDevice.asBroadcastStream(); + + Stream get errorStream => _mkPlayer.stream.error; } diff --git a/lib/services/audio_player/mk_state_player.dart b/lib/services/audio_player/mk_state_player.dart index 04df7111..8b796d66 100644 --- a/lib/services/audio_player/mk_state_player.dart +++ b/lib/services/audio_player/mk_state_player.dart @@ -17,13 +17,6 @@ class MkPlayerWithState extends Player { final StreamController _shuffleStream; final StreamController _loopModeStream; - static const String EXTRA_PACKAGE_NAME = "android.media.extra.PACKAGE_NAME"; - static const String EXTRA_AUDIO_SESSION = "android.media.extra.AUDIO_SESSION"; - static const String ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION = - "android.media.action.OPEN_AUDIO_EFFECT_CONTROL_SESSION"; - static const String ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION = - "android.media.action.CLOSE_AUDIO_EFFECT_CONTROL_SESSION"; - late final List _subscriptions; bool _shuffled; @@ -87,23 +80,28 @@ class MkPlayerWithState extends Player { await _androidAudioManager!.generateAudioSessionId(); notifyAudioSessionUpdate(true); - nativePlayer.setProperty( - "audiotrack-session-id", _androidAudioSessionId.toString()); - nativePlayer.setProperty("ao", "audiotrack,opensles,"); + await nativePlayer.setProperty( + "audiotrack-session-id", + _androidAudioSessionId.toString(), + ); + await nativePlayer.setProperty("ao", "audiotrack,opensles,"); }); } } Future notifyAudioSessionUpdate(bool active) async { if (DesktopTools.platform.isAndroid) { - sendBroadcast(BroadcastMessage( + sendBroadcast( + BroadcastMessage( name: active - ? ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION - : ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION, + ? "android.media.action.OPEN_AUDIO_EFFECT_CONTROL_SESSION" + : "android.media.action.CLOSE_AUDIO_EFFECT_CONTROL_SESSION", data: { - EXTRA_AUDIO_SESSION: _androidAudioSessionId, - EXTRA_PACKAGE_NAME: _packageName - })); + "android.media.extra.AUDIO_SESSION": _androidAudioSessionId, + "android.media.extra.PACKAGE_NAME": _packageName + }, + ), + ); } } diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index a6ecac3f..338427aa 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -2,11 +2,12 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_services/mobile_audio_service.dart'; import 'package:spotube/services/audio_services/windows_audio_service.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class AudioServices { final MobileAudioService? mobile; @@ -46,14 +47,15 @@ class AudioServices { id: track.id!, album: track.album?.name ?? "", title: track.name!, - artist: TypeConversionUtils.artists_X_String(track.artists ?? []), + artist: (track.artists)?.asString() ?? "", duration: track is SourcedTrack ? track.sourceInfo.duration : Duration(milliseconds: track.durationMs ?? 0), - artUri: Uri.parse(TypeConversionUtils.image_X_UrlString( - track.album?.images ?? [], - placeholder: ImagePlaceholder.albumArt, - )), + artUri: Uri.parse( + (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + ), playable: true, )); } diff --git a/lib/services/audio_services/linux_audio_service.dart b/lib/services/audio_services/linux_audio_service.dart index 436627e6..84a6f7b8 100644 --- a/lib/services/audio_services/linux_audio_service.dart +++ b/lib/services/audio_services/linux_audio_service.dart @@ -2,13 +2,13 @@ import 'dart:io'; import 'package:dbus/dbus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; final dbus = DBusClient.session(); @@ -258,7 +258,7 @@ class _MprisMediaPlayer2Player extends DBusObject { /// Gets value of property org.mpris.MediaPlayer2.Player.LoopStatus Future getLoopStatus() async { - final loopMode = switch (await audioPlayer.loopMode) { + final loopMode = switch (audioPlayer.loopMode) { PlaybackLoopMode.all => "Playlist", PlaybackLoopMode.one => "Track", PlaybackLoopMode.none => "None", @@ -309,8 +309,7 @@ class _MprisMediaPlayer2Player extends DBusObject { (await audioPlayer.duration)?.inMicroseconds ?? 0, ), "mpris:artUrl": DBusString( - TypeConversionUtils.image_X_UrlString( - playlist.activeTrack?.album?.images, + (playlist.activeTrack?.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, ), ), diff --git a/lib/services/audio_services/mobile_audio_service.dart b/lib/services/audio_services/mobile_audio_service.dart index 833df89c..d259317e 100644 --- a/lib/services/audio_services/mobile_audio_service.dart +++ b/lib/services/audio_services/mobile_audio_service.dart @@ -137,7 +137,7 @@ class MobileAudioService extends BaseAudioHandler { shuffleMode: await audioPlayer.isShuffled == true ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none, - repeatMode: (await audioPlayer.loopMode).toAudioServiceRepeatMode(), + repeatMode: (audioPlayer.loopMode).toAudioServiceRepeatMode(), processingState: playlist.isFetching == true ? AudioProcessingState.loading : AudioProcessingState.ready, diff --git a/lib/services/audio_services/windows_audio_service.dart b/lib/services/audio_services/windows_audio_service.dart index fde88145..a3ee31e1 100644 --- a/lib/services/audio_services/windows_audio_service.dart +++ b/lib/services/audio_services/windows_audio_service.dart @@ -3,10 +3,11 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:smtc_windows/smtc_windows.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/playback_state.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class WindowsAudioService { final SMTCWindows smtc; @@ -80,16 +81,17 @@ class WindowsAudioService { if (!smtc.enabled) { await smtc.enableSmtc(); } - await smtc.updateMetadata(MusicMetadata( - title: track.name!, - albumArtist: track.artists?.firstOrNull?.name ?? "Unknown", - artist: TypeConversionUtils.artists_X_String(track.artists ?? []), - album: track.album?.name ?? "Unknown", - thumbnail: TypeConversionUtils.image_X_UrlString( - track.album?.images ?? [], - placeholder: ImagePlaceholder.albumArt, + await smtc.updateMetadata( + MusicMetadata( + title: track.name!, + albumArtist: track.artists?.firstOrNull?.name ?? "Unknown", + artist: track.artists?.asString() ?? "Unknown", + album: track.album?.name ?? "Unknown", + thumbnail: (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), ), - )); + ); } void dispose() { diff --git a/lib/services/connectivity_adapter.dart b/lib/services/connectivity_adapter.dart index c628f2f7..1a3835ee 100644 --- a/lib/services/connectivity_adapter.dart +++ b/lib/services/connectivity_adapter.dart @@ -2,17 +2,17 @@ import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/widgets.dart'; -class FlQueryInternetConnectionCheckerAdapter extends ConnectivityAdapter - with WidgetsBindingObserver { +class ConnectionCheckerService with WidgetsBindingObserver { final _connectionStreamController = StreamController.broadcast(); final Dio dio; - FlQueryInternetConnectionCheckerAdapter() - : dio = Dio(), - super() { + static final _instance = ConnectionCheckerService._(); + + static ConnectionCheckerService get instance => _instance; + + ConnectionCheckerService._() : dio = Dio() { Timer? timer; onConnectivityChanged.listen((connected) { @@ -100,15 +100,16 @@ class FlQueryInternetConnectionCheckerAdapter extends ConnectivityAdapter await isVpnActive(); // when VPN is active that means we are connected } - @override + bool isConnectedSync = false; + Future get isConnected async { final connected = await _isConnected(); + isConnectedSync = connected; if (connected != isConnectedSync /*previous value*/) { _connectionStreamController.add(connected); } return connected; } - @override Stream get onConnectivityChanged => _connectionStreamController.stream; } diff --git a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart index e27b701b..d1c078a7 100644 --- a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart +++ b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart @@ -175,59 +175,4 @@ class CustomSpotifyEndpoints { ); return SpotifyFriends.fromJson(jsonDecode(res.body)); } - - Future artist({required String id}) async { - final pathQuery = "$_baseUrl/artists/$id"; - - final res = await _client.get( - Uri.parse(pathQuery), - headers: { - "content-type": "application/json", - if (accessToken.isNotEmpty) "authorization": "Bearer $accessToken", - "accept": "application/json", - }, - ); - final data = jsonDecode(res.body); - - return Artist.fromJson(_purifyArtistResponse(data)); - } - - Future> relatedArtists({required String id}) async { - final pathQuery = "$_baseUrl/artists/$id/related-artists"; - - final res = await _client.get( - Uri.parse(pathQuery), - headers: { - "content-type": "application/json", - if (accessToken.isNotEmpty) "authorization": "Bearer $accessToken", - "accept": "application/json", - }, - ); - - final data = jsonDecode(res.body); - - return List.castFrom( - data["artists"] - .map((artist) => Artist.fromJson(_purifyArtistResponse(artist))) - .toList(), - ); - } - - Map _purifyArtistResponse(Map data) { - if (data["popularity"] != null) { - data["popularity"] = data["popularity"].toInt(); - } - if (data["followers"]?["total"] != null) { - data["followers"]["total"] = data["followers"]["total"].toInt(); - } - if (data["images"] != null) { - data["images"] = data["images"].map((e) { - e["height"] = e["height"].toInt(); - e["width"] = e["width"].toInt(); - return e; - }).toList(); - } - - return data; - } } diff --git a/lib/services/device_info/device_info.dart b/lib/services/device_info/device_info.dart new file mode 100644 index 00000000..87ddd6eb --- /dev/null +++ b/lib/services/device_info/device_info.dart @@ -0,0 +1,34 @@ +import 'package:device_info_plus/device_info_plus.dart'; + +class DeviceInfoService { + final DeviceInfoPlugin deviceInfo; + DeviceInfoService._() : deviceInfo = DeviceInfoPlugin(); + + static final instance = DeviceInfoService._(); + + Future deviceId() async { + final info = await deviceInfo.deviceInfo; + + return switch (info) { + AndroidDeviceInfo() => info.id, + IosDeviceInfo() => info.identifierForVendor ?? info.model, + MacOsDeviceInfo() => info.systemGUID ?? info.model, + WindowsDeviceInfo() => info.deviceId, + LinuxDeviceInfo() => info.machineId ?? info.id, + _ => 'Unknown', + }; + } + + Future computerName() async { + final info = await deviceInfo.deviceInfo; + + return switch (info) { + AndroidDeviceInfo() => info.model, + IosDeviceInfo() => info.localizedModel, + MacOsDeviceInfo() => info.computerName, + WindowsDeviceInfo() => info.computerName, + LinuxDeviceInfo() => info.name, + _ => 'Unknown', + }; + } +} diff --git a/lib/services/download_manager/download_manager.dart b/lib/services/download_manager/download_manager.dart index d7a42430..dbb96791 100644 --- a/lib/services/download_manager/download_manager.dart +++ b/lib/services/download_manager/download_manager.dart @@ -207,7 +207,7 @@ class DownloadManager { // Do nothing return _cache[downloadRequest.url]!; } else { - _queue.remove(_cache[downloadRequest.url]); + _queue.remove(_cache[downloadRequest.url]?.request); } } @@ -286,21 +286,21 @@ class DownloadManager { } Future pauseBatchDownloads(List urls) async { - urls.forEach((element) { + for (var element in urls) { pauseDownload(element); - }); + } } Future cancelBatchDownloads(List urls) async { - urls.forEach((element) { + for (var element in urls) { cancelDownload(element); - }); + } } Future resumeBatchDownloads(List urls) async { - urls.forEach((element) { + for (var element in urls) { resumeDownload(element); - }); + } } ValueNotifier getBatchDownloadProgress(List urls) { @@ -315,9 +315,9 @@ class DownloadManager { return getDownload(urls.first)?.progress ?? progress; } - var progressMap = Map(); + var progressMap = {}; - urls.forEach((url) { + for (var url in urls) { DownloadTask? task = getDownload(url); if (task != null) { @@ -328,29 +328,27 @@ class DownloadManager { progress.value = progressMap.values.sum / total; } - var progressListener; - progressListener = () { + void progressListener() { progressMap[url] = task.progress.value; progress.value = progressMap.values.sum / total; - }; + } task.progress.addListener(progressListener); - var listener; - listener = () { + void listener() { if (task.status.value.isCompleted) { progressMap[url] = 1.0; progress.value = progressMap.values.sum / total; task.status.removeListener(listener); task.progress.removeListener(progressListener); } - }; + } task.status.addListener(listener); } else { total--; } - }); + } return progress; } @@ -374,8 +372,7 @@ class DownloadManager { } } - var listener; - listener = () { + void listener() { if (task.status.value.isCompleted) { completed++; @@ -384,7 +381,7 @@ class DownloadManager { task.status.removeListener(listener); } } - }; + } task.status.addListener(listener); } else { diff --git a/lib/services/download_manager/download_task.dart b/lib/services/download_manager/download_task.dart index 5d57a655..d65f167e 100644 --- a/lib/services/download_manager/download_task.dart +++ b/lib/services/download_manager/download_task.dart @@ -21,13 +21,14 @@ class DownloadTask { completer.complete(status.value); } - var listener; - listener = () { + void listener() { if (status.value.isCompleted) { completer.complete(status.value); status.removeListener(listener); } - }; + } + + ; status.addListener(listener); diff --git a/lib/services/kv_store/kv_store.dart b/lib/services/kv_store/kv_store.dart index 6f6807e0..f94ec4ee 100644 --- a/lib/services/kv_store/kv_store.dart +++ b/lib/services/kv_store/kv_store.dart @@ -10,6 +10,17 @@ abstract class KVStoreService { static bool get doneGettingStarted => sharedPreferences.getBool('doneGettingStarted') ?? false; - static set doneGettingStarted(bool value) => - sharedPreferences.setBool('doneGettingStarted', value); + static Future setDoneGettingStarted(bool value) async => + await sharedPreferences.setBool('doneGettingStarted', value); + + static bool get askedForBatteryOptimization => + sharedPreferences.getBool('askedForBatteryOptimization') ?? false; + static Future setAskedForBatteryOptimization(bool value) async => + await sharedPreferences.setBool('askedForBatteryOptimization', value); + + static List get recentSearches => + sharedPreferences.getStringList('recentSearches') ?? []; + + static Future setRecentSearches(List value) async => + await sharedPreferences.setStringList('recentSearches', value); } diff --git a/lib/services/mutations/album.dart b/lib/services/mutations/album.dart deleted file mode 100644 index 144b6a8f..00000000 --- a/lib/services/mutations/album.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/hooks/spotify/use_spotify_mutation.dart'; - -class AlbumMutations { - const AlbumMutations(); - - Mutation toggleFavorite( - WidgetRef ref, - String albumId, { - List? refreshQueries, - List? refreshInfiniteQueries, - MutationOnDataFn? onData, - }) { - return useSpotifyMutation( - "toggle-album-like/$albumId", - (isLiked, spotify) async { - if (isLiked) { - await spotify.me.removeAlbums([albumId]); - } else { - await spotify.me.saveAlbums([albumId]); - } - return !isLiked; - }, - ref: ref, - refreshQueries: refreshQueries, - refreshInfiniteQueries: refreshInfiniteQueries, - onData: onData, - ); - } -} diff --git a/lib/services/mutations/mutations.dart b/lib/services/mutations/mutations.dart deleted file mode 100644 index 28670486..00000000 --- a/lib/services/mutations/mutations.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:spotube/services/mutations/album.dart'; -import 'package:spotube/services/mutations/playlist.dart'; -import 'package:spotube/services/mutations/track.dart'; - -class _UseMutations { - const _UseMutations._(); - final playlist = const PlaylistMutations(); - final album = const AlbumMutations(); - final track = const TrackMutations(); -} - -const useMutations = _UseMutations._(); diff --git a/lib/services/mutations/playlist.dart b/lib/services/mutations/playlist.dart deleted file mode 100644 index f480c565..00000000 --- a/lib/services/mutations/playlist.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_mutation.dart'; -import 'package:spotube/services/queries/queries.dart'; - -typedef PlaylistCRUDVariables = ({ - String playlistName, - bool? public, - bool? collaborative, - String? description, - String? base64Image, -}); - -class PlaylistMutations { - const PlaylistMutations(); - - Mutation toggleFavorite( - WidgetRef ref, - String playlistId, { - List? refreshQueries, - List? refreshInfiniteQueries, - ValueChanged? onData, - }) { - return useSpotifyMutation( - "toggle-playlist-like/$playlistId", - (isLiked, spotify) async { - if (isLiked) { - await spotify.playlists.unfollowPlaylist(playlistId); - } else { - await spotify.playlists.followPlaylist(playlistId); - } - return !isLiked; - }, - ref: ref, - refreshQueries: refreshQueries, - refreshInfiniteQueries: [ - ...?refreshInfiniteQueries, - "current-user-playlists", - ], - onData: (data, recoveryData) { - onData?.call(data); - }, - ); - } - - Mutation removeTrackOf( - WidgetRef ref, - String playlistId, - ) { - return useSpotifyMutation( - "remove-track-from-playlist/$playlistId", - (trackId, spotify) async { - await spotify.playlists.removeTracks([trackId], playlistId); - return true; - }, - ref: ref, - refreshQueries: ["playlist-tracks/$playlistId"], - ); - } - - Mutation create( - WidgetRef ref, { - List? trackIds, - ValueChanged? onError, - ValueChanged? onData, - }) { - final me = useQueries.user.me(ref); - return useSpotifyMutation( - "create-playlist", - (variable, spotify) async { - final playlist = await spotify.playlists.createPlaylist( - me.data!.id!, - variable.playlistName, - collaborative: variable.collaborative, - description: variable.description, - public: variable.public, - ); - - if (variable.base64Image != null) { - await spotify.playlists.updatePlaylistImage( - playlist.id!, - variable.base64Image!, - ); - } - - if (trackIds != null && trackIds.isNotEmpty) { - await spotify.playlists.addTracks( - trackIds.map((id) => "spotify:track:$id").toList(), - playlist.id!, - ); - } - - return playlist; - }, - refreshInfiniteQueries: ["current-user-playlists"], - refreshQueries: ["current-user-all-playlists"], - ref: ref, - onError: (error, recoveryData) { - onError?.call(error); - }, - onData: (data, recoveryData) { - onData?.call(data); - }, - ); - } - - Mutation update( - WidgetRef ref, { - String? playlistId, - ValueChanged? onError, - ValueChanged? onData, - }) { - return useSpotifyMutation( - "update-playlist/$playlistId", - (variable, spotify) async { - if (playlistId == null) return; - await spotify.playlists.updatePlaylist( - playlistId, - variable.playlistName, - collaborative: variable.collaborative, - description: variable.description, - public: variable.public, - ); - if (variable.base64Image != null) { - await spotify.playlists.updatePlaylistImage( - playlistId, - variable.base64Image!, - ); - } - }, - refreshInfiniteQueries: [ - "playlist/$playlistId", - "current-user-playlists", - ], - refreshQueries: ["current-user-all-playlists"], - ref: ref, - onError: (error, recoveryData) { - onError?.call(error); - }, - onData: (data, recoveryData) { - onData?.call(data); - }, - ); - } -} diff --git a/lib/services/mutations/track.dart b/lib/services/mutations/track.dart deleted file mode 100644 index f8208b5e..00000000 --- a/lib/services/mutations/track.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/hooks/spotify/use_spotify_mutation.dart'; - -class TrackMutations { - const TrackMutations(); - - Mutation toggleFavorite( - WidgetRef ref, - String trackId, { - MutationOnMutationFn? onMutate, - MutationOnDataFn? onData, - MutationOnErrorFn? onError, - }) { - return useSpotifyMutation( - 'toggle-track-like/$trackId', - (isLiked, spotify) async { - if (isLiked) { - await spotify.tracks.me.removeOne(trackId); - } else { - await spotify.tracks.me.saveOne(trackId); - } - return !isLiked; - }, - ref: ref, - onData: onData, - onMutate: onMutate, - refreshQueries: ["playlist-tracks/user-liked-tracks"], - onError: onError, - ); - } -} diff --git a/lib/services/queries/album.dart b/lib/services/queries/album.dart deleted file mode 100644 index 0cc10256..00000000 --- a/lib/services/queries/album.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:catcher_2/catcher_2.dart'; -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class AlbumQueries { - const AlbumQueries(); - - InfiniteQuery, dynamic, int> ofMine(WidgetRef ref) { - return useSpotifyInfiniteQuery, dynamic, int>( - "current-user-albums", - (page, spotify) { - return spotify.me.savedAlbums().getPage( - 20, - page * 20, - ); - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) => - (lastPageData.items?.length ?? 0) < 20 || lastPageData.isLast - ? null - : lastPage + 1, - ref: ref, - ); - } - - static final tracksOfJob = InfiniteQueryJob.withVariableKey< - List, - dynamic, - int, - ({ - SpotifyApi spotify, - AlbumSimple album, - })>( - baseQueryKey: "album-tracks", - initialPage: 0, - task: (albumId, page, args) async { - final res = - await args!.spotify.albums.tracks(albumId).getPage(20, page * 20); - return res.items - ?.map((track) => - TypeConversionUtils.simpleTrack_X_Track(track, args.album)) - .toList() ?? - []; - }, - nextPage: (lastPage, lastPageData) { - if (lastPageData.length < 20) { - return null; - } - return lastPage + 1; - }, - ); - - InfiniteQuery, dynamic, int> tracksOf( - WidgetRef ref, - AlbumSimple album, - ) { - final spotify = ref.watch(spotifyProvider); - - return useInfiniteQueryJob( - job: tracksOfJob(album.id!), - args: (spotify: spotify, album: album), - ); - } - - Query isSavedForMe( - WidgetRef ref, - String album, - ) { - return useSpotifyQuery( - "is-saved-for-me/$album", - (spotify) { - return spotify.me - .containsSavedAlbums([album]).then((value) => value[album]); - }, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> newReleases(WidgetRef ref) { - final market = ref - .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); - - return useSpotifyInfiniteQuery, dynamic, int>( - "new-releases", - (pageParam, spotify) async { - try { - final albums = await spotify.browse - .newReleases(country: market) - .getPage(50, pageParam); - - return albums; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - rethrow; - } - }, - ref: ref, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast) { - return null; - } - return lastPageData.nextOffset; - }, - ); - } -} diff --git a/lib/services/queries/artist.dart b/lib/services/queries/artist.dart deleted file mode 100644 index 5ccc4955..00000000 --- a/lib/services/queries/artist.dart +++ /dev/null @@ -1,154 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/wikipedia/wikipedia.dart'; -import 'package:wikipedia_api/wikipedia_api.dart'; - -class ArtistQueries { - const ArtistQueries(); - - Query get( - WidgetRef ref, - String artist, - ) { - final customSpotify = ref.watch(customSpotifyEndpointProvider); - return useSpotifyQuery( - "artist-profile/$artist", - (spotify) => customSpotify.artist(id: artist), - ref: ref, - ); - } - - InfiniteQuery, dynamic, String> followedByMe( - WidgetRef ref) { - return useSpotifyInfiniteQuery, dynamic, String>( - "user-following-artists", - (pageParam, spotify) async { - return spotify.me - .following(FollowingType.artist) - .getPage(15, pageParam); - }, - initialPage: "", - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 15) { - return null; - } - return lastPageData.after; - }, - ref: ref, - ); - } - - Query, dynamic> followedByMeAll(WidgetRef ref) { - return useSpotifyQuery( - "user-following-artists-all", - (spotify) async { - CursorPage? page = - await spotify.me.following(FollowingType.artist).getPage(50); - - final following = []; - - if (page.isLast == true) { - return page.items?.toList() ?? []; - } - - following.addAll(page.items ?? []); - while (page?.isLast != true) { - page = await spotify.me - .following(FollowingType.artist) - .getPage(50, page?.after ?? ''); - following.addAll(page.items ?? []); - } - - return following; - }, - ref: ref, - ); - } - - Query doIFollow( - WidgetRef ref, - String artist, - ) { - return useSpotifyQuery( - "user-follows-artists-query/$artist", - (spotify) async { - final result = await spotify.me.checkFollowing( - FollowingType.artist, - [artist], - ); - return result[artist]; - }, - ref: ref, - ); - } - - Query, dynamic> topTracksOf( - WidgetRef ref, - String artist, - ) { - final preferences = ref.watch(userPreferencesProvider); - return useSpotifyQuery, dynamic>( - "artist-top-track-query/$artist", - (spotify) { - return spotify.artists - .topTracks(artist, preferences.recommendationMarket); - }, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> albumsOf( - WidgetRef ref, - String artist, - ) { - return useSpotifyInfiniteQuery, dynamic, int>( - "artist-albums/$artist", - (pageParam, spotify) async { - return spotify.artists.albums(artist).getPage(5, pageParam); - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 5) { - return null; - } - return lastPageData.nextOffset; - }, - ref: ref, - ); - } - - Query, dynamic> relatedArtistsOf( - WidgetRef ref, - String artist, - ) { - final customSpotify = ref.watch(customSpotifyEndpointProvider); - return useSpotifyQuery, dynamic>( - "artist-related-artist-query/$artist", - (spotify) { - return customSpotify.relatedArtists(id: artist); - }, - ref: ref, - ); - } - - Query wikipediaSummary(ArtistSimple artist) { - return useQuery( - "artist-wikipedia-query/${artist.id}", - () async { - final query = artist.name!.replaceAll(" ", "_"); - final res = await wikipedia.pageContent.pageSummaryTitleGet(query); - if (res?.type != "standard") { - return await wikipedia.pageContent - .pageSummaryTitleGet("${query}_(singer)"); - } - return res; - }, - ); - } -} diff --git a/lib/services/queries/category.dart b/lib/services/queries/category.dart deleted file mode 100644 index d520b909..00000000 --- a/lib/services/queries/category.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class CategoryQueries { - const CategoryQueries(); - - Query, dynamic> listAll( - WidgetRef ref, - Market recommendationMarket, - ) { - ref.watch(userPreferencesProvider.select((s) => s.locale)); - final locale = useContext().l10n.localeName; - final query = useSpotifyQuery, dynamic>( - "category-playlists", - (spotify) async { - final categories = await spotify.categories - .list( - country: recommendationMarket, - locale: locale, - ) - .all(); - - return categories.toList()..shuffle(); - }, - ref: ref, - ); - - return query; - } - - InfiniteQuery, dynamic, int> list( - WidgetRef ref, - Market recommendationMarket, - ) { - ref.watch(userPreferencesProvider.select((s) => s.locale)); - final locale = useContext().l10n.localeName; - return useSpotifyInfiniteQuery, dynamic, int>( - "category-playlists", - (pageParam, spotify) async { - final categories = await spotify.categories - .list( - country: recommendationMarket, - locale: locale, - ) - .getPage(8, pageParam); - - return categories; - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 8) { - return null; - } - return lastPageData.nextOffset; - }, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> playlistsOf( - WidgetRef ref, - String category, - ) { - ref.watch(userPreferencesProvider.select((s) => s.locale)); - final market = ref - .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); - final locale = useContext().l10n.localeName; - return useSpotifyInfiniteQuery, dynamic, int>( - "category-playlists/$category", - (pageParam, spotify) async { - final playlists = await Pages( - spotify, - "v1/browse/categories/$category/playlists?country=${market.name}&locale=$locale", - (json) => json == null ? null : PlaylistSimple.fromJson(json), - 'playlists', - (json) => PlaylistsFeatured.fromJson(json), - ).getPage(5, pageParam); - - return playlists; - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 5) { - return null; - } - return lastPageData.nextOffset; - }, - ref: ref, - ); - } - - Query, dynamic> genreSeeds(WidgetRef ref) { - final customSpotify = ref.watch(customSpotifyEndpointProvider); - final query = useQuery, dynamic>( - "genre-seeds", - customSpotify.listGenreSeeds, - ); - - useEffect(() { - return ref.listenManual( - customSpotifyEndpointProvider, - (previous, next) { - if (previous != next) { - query.refresh(); - } - }, - ).close; - }, [query]); - - return query; - } -} diff --git a/lib/services/queries/lyrics.dart b/lib/services/queries/lyrics.dart deleted file mode 100644 index 618f960f..00000000 --- a/lib/services/queries/lyrics.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'dart:convert'; - -import 'package:collection/collection.dart'; -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/map.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/models/lyrics.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:http/http.dart' as http; - -class LyricsQueries { - const LyricsQueries(); - - Query static( - Track? track, - String geniusAccessToken, - ) { - return useQuery( - "genius-lyrics-query/${track?.id}", - () async { - if (track == null) { - return "“Give this player a track to play”\n- S'Challa"; - } - final lyrics = await ServiceUtils.getLyrics( - track.name!, - track.artists?.map((s) => s.name).whereNotNull().toList() ?? [], - apiKey: geniusAccessToken, - optimizeQuery: true, - ); - - if (lyrics == null) throw Exception("Unable find lyrics"); - return lyrics; - }, - ); - } - - Query synced( - Track? track, - ) { - return useQuery( - "synced-lyrics/${track?.id}}", - () async { - if (track == null || track is! SourcedTrack) { - throw "No track currently"; - } - final timedLyrics = await ServiceUtils.getTimedLyrics(track); - if (timedLyrics == null) throw Exception("Unable to find lyrics"); - - return timedLyrics; - }, - ); - } - - /// The Concept behind this method was shamelessly stolen from - /// https://github.com/akashrchandran/spotify-lyrics-api - /// - /// Thanks to [akashrchandran](https://github.com/akashrchandran) for the idea - /// - /// Special thanks to [raptag](https://github.com/raptag) for discovering this - /// jem - - Query spotifySynced(WidgetRef ref, Track? track) { - return useSpotifyQuery( - "spotify-synced-lyrics/${track?.id}}", - (spotify) async { - if (track == null) { - throw "No track currently"; - } - final token = await spotify.getCredentials(); - final res = await http.get( - Uri.parse( - "https://spclient.wg.spotify.com/color-lyrics/v2/track/${track.id}?format=json&market=from_token", - ), - headers: { - "User-Agent": - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36", - "App-platform": "WebPlayer", - "authorization": "Bearer ${token.accessToken}" - }); - - if (res.statusCode != 200) { - throw Exception("Unable to find lyrics"); - } - final linesRaw = Map.castFrom( - jsonDecode(res.body), - )["lyrics"]?["lines"] as List?; - - final lines = linesRaw?.map((line) { - return LyricSlice( - time: Duration(milliseconds: int.parse(line["startTimeMs"])), - text: line["words"] as String, - ); - }).toList() ?? - []; - - return SubtitleSimple( - lyrics: lines, - name: track.name!, - uri: res.request!.url, - rating: 100, - ); - }, - jsonConfig: JsonConfig( - fromJson: (json) => SubtitleSimple.fromJson(json.castKeyDeep()), - toJson: (data) => data.toJson(), - ), - ref: ref, - ); - } -} diff --git a/lib/services/queries/playlist.dart b/lib/services/queries/playlist.dart deleted file mode 100644 index 836f9d72..00000000 --- a/lib/services/queries/playlist.dart +++ /dev/null @@ -1,318 +0,0 @@ -import 'package:catcher_2/catcher_2.dart'; -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/library/playlist_generate/recommendation_attribute_dials.dart'; -import 'package:spotube/extensions/map.dart'; -import 'package:spotube/extensions/track.dart'; -import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; -import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -typedef RecommendationParameters = ({ - RecommendationAttribute acousticness, - RecommendationAttribute danceability, - RecommendationAttribute duration_ms, - RecommendationAttribute energy, - RecommendationAttribute instrumentalness, - RecommendationAttribute key, - RecommendationAttribute liveness, - RecommendationAttribute loudness, - RecommendationAttribute mode, - RecommendationAttribute popularity, - RecommendationAttribute speechiness, - RecommendationAttribute tempo, - RecommendationAttribute time_signature, - RecommendationAttribute valence, -}); - -Map recommendationAttributeToMap(RecommendationAttribute attr) => { - "min": attr.min, - "target": attr.target, - "max": attr.max, - }; - -({Map min, Map target, Map max}) - recommendationParametersToMap(RecommendationParameters params) { - final maxMap = { - if (params.acousticness != zeroValues) - "acousticness": params.acousticness.max, - if (params.danceability != zeroValues) - "danceability": params.danceability.max, - if (params.duration_ms != zeroValues) "duration_ms": params.duration_ms.max, - if (params.energy != zeroValues) "energy": params.energy.max, - if (params.instrumentalness != zeroValues) - "instrumentalness": params.instrumentalness.max, - if (params.key != zeroValues) "key": params.key.max, - if (params.liveness != zeroValues) "liveness": params.liveness.max, - if (params.loudness != zeroValues) "loudness": params.loudness.max, - if (params.mode != zeroValues) "mode": params.mode.max, - if (params.popularity != zeroValues) "popularity": params.popularity.max, - if (params.speechiness != zeroValues) "speechiness": params.speechiness.max, - if (params.tempo != zeroValues) "tempo": params.tempo.max, - if (params.time_signature != zeroValues) - "time_signature": params.time_signature.max, - if (params.valence != zeroValues) "valence": params.valence.max, - }; - final minMap = { - if (params.acousticness != zeroValues) - "acousticness": params.acousticness.min, - if (params.danceability != zeroValues) - "danceability": params.danceability.min, - if (params.duration_ms != zeroValues) "duration_ms": params.duration_ms.min, - if (params.energy != zeroValues) "energy": params.energy.min, - if (params.instrumentalness != zeroValues) - "instrumentalness": params.instrumentalness.min, - if (params.key != zeroValues) "key": params.key.min, - if (params.liveness != zeroValues) "liveness": params.liveness.min, - if (params.loudness != zeroValues) "loudness": params.loudness.min, - if (params.mode != zeroValues) "mode": params.mode.min, - if (params.popularity != zeroValues) "popularity": params.popularity.min, - if (params.speechiness != zeroValues) "speechiness": params.speechiness.min, - if (params.tempo != zeroValues) "tempo": params.tempo.min, - if (params.time_signature != zeroValues) - "time_signature": params.time_signature.min, - if (params.valence != zeroValues) "valence": params.valence.min, - }; - final targetMap = { - if (params.acousticness != zeroValues) - "acousticness": params.acousticness.target, - if (params.danceability != zeroValues) - "danceability": params.danceability.target, - if (params.duration_ms != zeroValues) - "duration_ms": params.duration_ms.target, - if (params.energy != zeroValues) "energy": params.energy.target, - if (params.instrumentalness != zeroValues) - "instrumentalness": params.instrumentalness.target, - if (params.key != zeroValues) "key": params.key.target, - if (params.liveness != zeroValues) "liveness": params.liveness.target, - if (params.loudness != zeroValues) "loudness": params.loudness.target, - if (params.mode != zeroValues) "mode": params.mode.target, - if (params.popularity != zeroValues) "popularity": params.popularity.target, - if (params.speechiness != zeroValues) - "speechiness": params.speechiness.target, - if (params.tempo != zeroValues) "tempo": params.tempo.target, - if (params.time_signature != zeroValues) - "time_signature": params.time_signature.target, - if (params.valence != zeroValues) "valence": params.valence.target, - }; - - return ( - max: maxMap, - min: minMap, - target: targetMap, - ); -} - -class PlaylistQueries { - const PlaylistQueries(); - - Query doesUserFollow( - WidgetRef ref, - String playlistId, - String userId, - ) { - return useSpotifyQuery( - "playlist-is-followed/$playlistId/$userId", - (spotify) async { - final result = - await spotify.playlists.followedByUsers(playlistId, [userId]); - return result[userId] ?? false; - }, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> ofMine(WidgetRef ref) { - return useSpotifyInfiniteQuery, dynamic, int>( - "current-user-playlists", - (page, spotify) async { - final playlists = await spotify.playlists.me.getPage(10, page * 10); - return playlists; - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) => - (lastPageData.items?.length ?? 0) < 10 || lastPageData.isLast - ? null - : lastPage + 1, - ref: ref, - ); - } - - Query, dynamic> ofMineAll(WidgetRef ref) { - return useSpotifyQuery, dynamic>( - "current-user-all-playlists", - (spotify) async { - var page = await spotify.playlists.me.getPage(50); - final playlists = []; - - if (page.isLast == true) { - return page.items?.toList() ?? []; - } - - playlists.addAll(page.items ?? []); - while (!page.isLast) { - page = await spotify.playlists.me.getPage(50, page.nextOffset); - playlists.addAll(page.items ?? []); - } - - return playlists; - }, - ref: ref, - ); - } - - Future> likedTracks(SpotifyApi spotify) async { - final tracks = await spotify.tracks.me.saved.all(); - - return tracks.map((e) => e.track!).toList(); - } - - Query, dynamic> likedTracksQuery(WidgetRef ref) { - final query = useCallback((spotify) => likedTracks(spotify), []); - final context = useContext(); - - return useSpotifyQuery, dynamic>( - "user-liked-tracks", - query, - jsonConfig: JsonConfig( - toJson: (tracks) => { - 'tracks': tracks.map((e) => e.toJson()).toList(), - }, - fromJson: (json) => (json['tracks'] as List) - .map( - (e) => Track.fromJson((e as Map).castKeyDeep()), - ) - .toList(), - ), - refreshConfig: RefreshConfig.withDefaults( - context, - // will never make it stale - staleDuration: const Duration(days: 60), - ), - ref: ref, - ); - } - - Query byId(WidgetRef ref, String id) { - return useSpotifyQuery( - "playlist/$id", - (spotify) async { - return await spotify.playlists.get(id); - }, - ref: ref, - ); - } - - Future> tracksOf( - int pageParam, - SpotifyApi spotify, - String playlistId, - ) async { - try { - final playlists = await spotify.playlists - .getTracksByPlaylistId(playlistId) - .getPage(20, pageParam * 20); - return playlists.items?.toList() ?? []; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - rethrow; - } - } - - int? tracksOfQueryNextPage(int lastPage, List lastPageData) { - if (lastPageData.length < 20) { - return null; - } - return lastPage + 1; - } - - InfiniteQuery, dynamic, int> tracksOfQuery( - WidgetRef ref, - String playlistId, - ) { - return useSpotifyInfiniteQuery, dynamic, int>( - "playlist-tracks/$playlistId", - (page, spotify) => tracksOf(page, spotify, playlistId), - initialPage: 0, - nextPage: tracksOfQueryNextPage, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> featured( - WidgetRef ref, - ) { - return useSpotifyInfiniteQuery, dynamic, int>( - "featured-playlists", - (pageParam, spotify) async { - try { - final playlists = - await spotify.playlists.featured.getPage(5, pageParam); - return playlists; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - rethrow; - } - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 5) { - return null; - } - return lastPageData.nextOffset; - }, - ref: ref, - ); - } - - Query, dynamic> generate( - WidgetRef ref, { - ({List tracks, List artists, List genres})? seeds, - RecommendationParameters? parameters, - int limit = 20, - Market? market, - }) { - final marketOfPreference = ref.watch( - userPreferencesProvider.select((s) => s.recommendationMarket), - ); - final customSpotify = ref.watch(customSpotifyEndpointProvider); - - final parametersMap = - parameters == null ? null : recommendationParametersToMap(parameters); - - final query = useQuery, dynamic>( - "generate-playlist", - () async { - final tracks = await customSpotify.getRecommendations( - limit: limit, - market: market ?? marketOfPreference, - max: parametersMap?.max, - min: parametersMap?.min, - target: parametersMap?.target, - seedArtists: seeds?.artists, - seedGenres: seeds?.genres, - seedTracks: seeds?.tracks, - ); - return tracks; - }, - ); - - useEffect(() { - return ref.listenManual( - customSpotifyEndpointProvider, - (previous, next) { - if (previous != next) { - query.refresh(); - } - }, - ).close; - }, [query]); - - return query; - } -} diff --git a/lib/services/queries/queries.dart b/lib/services/queries/queries.dart deleted file mode 100644 index 30c23268..00000000 --- a/lib/services/queries/queries.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:spotube/services/queries/album.dart'; -import 'package:spotube/services/queries/artist.dart'; -import 'package:spotube/services/queries/category.dart'; -import 'package:spotube/services/queries/lyrics.dart'; -import 'package:spotube/services/queries/playlist.dart'; -import 'package:spotube/services/queries/search.dart'; -import 'package:spotube/services/queries/tracks.dart'; -import 'package:spotube/services/queries/user.dart'; -import 'package:spotube/services/queries/views.dart'; - -class Queries { - const Queries._(); - final album = const AlbumQueries(); - final artist = const ArtistQueries(); - final category = const CategoryQueries(); - final lyrics = const LyricsQueries(); - final playlist = const PlaylistQueries(); - final search = const SearchQueries(); - final user = const UserQueries(); - final views = const ViewsQueries(); - final tracks = const TracksQueries(); -} - -const useQueries = Queries._(); diff --git a/lib/services/queries/search.dart b/lib/services/queries/search.dart deleted file mode 100644 index 3c6ee064..00000000 --- a/lib/services/queries/search.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/spotify_provider.dart'; - -typedef SearchParams = ({ - SpotifyApi spotify, - SearchType searchType, - String query -}); - -class SearchQueries { - const SearchQueries(); - - static final queryJob = - InfiniteQueryJob.withVariableKey, dynamic, int, SearchParams>( - baseQueryKey: "search-query", - task: (variableKey, page, args) => args!.spotify.search.get( - args.query, - types: [args.searchType], - ).getPage(10, page), - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isEmpty) return null; - if ((lastPageData.first.isLast || - (lastPageData.first.items ?? []).length < 10)) { - return null; - } - return lastPageData.first.nextOffset; - }, - enabled: false, - ); - - InfiniteQuery, dynamic, int> query( - WidgetRef ref, - String queryStr, - SearchType searchType, - ) { - final spotify = ref.watch(spotifyProvider); - final query = useInfiniteQueryJob, dynamic, int, SearchParams>( - job: queryJob(searchType.name), - args: (spotify: spotify, searchType: searchType, query: queryStr), - ); - - useEffect(() { - return ref.listenManual( - spotifyProvider, - (previous, next) { - if (previous != next) { - query.refreshAll(); - } - }, - ).close; - }, [query]); - - return query; - } -} diff --git a/lib/services/queries/tracks.dart b/lib/services/queries/tracks.dart deleted file mode 100644 index 52bab984..00000000 --- a/lib/services/queries/tracks.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; - -class TracksQueries { - const TracksQueries(); - - Query track(WidgetRef ref, String id) { - return useSpotifyQuery( - "track/$id", - (spotify) => spotify.tracks.get(id), - ref: ref, - ); - } -} diff --git a/lib/services/queries/user.dart b/lib/services/queries/user.dart deleted file mode 100644 index 82af600f..00000000 --- a/lib/services/queries/user.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/models/spotify_friends.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class UserQueries { - const UserQueries(); - Query me(WidgetRef ref) { - final context = useContext(); - - return useSpotifyQuery( - "current-user", - (spotify) async { - final me = await spotify.me.get(); - if (ref.read(AuthenticationNotifier.provider) == null) return null; - if (me.images == null || me.images?.isEmpty == true) { - me.images = [ - Image() - ..height = 50 - ..width = 50 - ..url = TypeConversionUtils.image_X_UrlString( - me.images, - placeholder: ImagePlaceholder.artist, - ), - ]; - } - return me; - }, - refreshConfig: RefreshConfig.withDefaults( - context, - // will never make it stale - staleDuration: const Duration(days: 60), - ), - ref: ref, - ); - } - - Query friendActivity(WidgetRef ref) { - final customSpotify = ref.read(customSpotifyEndpointProvider); - return useSpotifyQuery( - "friend-activity", - (spotify) { - return customSpotify.getFriendActivity(); - }, - ref: ref, - ); - } -} diff --git a/lib/services/queries/views.dart b/lib/services/queries/views.dart deleted file mode 100644 index 4864ffe1..00000000 --- a/lib/services/queries/views.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class ViewsQueries { - const ViewsQueries(); - - Query?, dynamic> get( - WidgetRef ref, - String view, - ) { - final customSpotify = ref.watch(customSpotifyEndpointProvider); - final auth = ref.watch(AuthenticationNotifier.provider); - final market = ref - .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); - - final locale = useContext().l10n.localeName; - - final query = useQuery?, dynamic>("views/$view", () { - if (auth == null) return null; - return customSpotify.getView( - view, - market: market, - country: market, - locale: locale, - ); - }); - - useEffect(() { - return ref.listenManual( - customSpotifyEndpointProvider, - (previous, next) { - if (previous != next) { - query.refresh(); - } - }, - ).close; - }, [query]); - - return query; - } -} diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index c73f3078..a5e094ed 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -127,10 +127,12 @@ abstract class SourcedTrack extends Track { weakMatch: true, ), AudioSource.jiosaavn => - await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref), + await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref), }; } on HttpClientClosedException catch (_) { return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); + } on VideoUnplayableException catch (_) { + return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); } catch (e) { if (e is DioException || e is ClientException || e is SocketException) { if (preferences.audioSource == AudioSource.jiosaavn) { diff --git a/lib/utils/persisted_state_notifier.dart b/lib/utils/persisted_state_notifier.dart index 60f7b96e..9416a340 100644 --- a/lib/utils/persisted_state_notifier.dart +++ b/lib/utils/persisted_state_notifier.dart @@ -126,7 +126,7 @@ abstract class PersistedStateNotifier extends StateNotifier { } } - Map castNestedJson(Map map) { + static Map castNestedJson(Map map) { return Map.castFrom( map.map((key, value) { if (value is Map) { diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart index 60c77e59..88c52896 100644 --- a/lib/utils/service_utils.dart +++ b/lib/utils/service_utils.dart @@ -251,6 +251,7 @@ abstract class ServiceUtils { uri: subtitleUri, lyrics: lrcList, rating: rateSortedResults.first["points"] as int, + provider: "Rent An Adviser", ); return subtitle; @@ -307,7 +308,9 @@ abstract class ServiceUtils { case SortBy.duration: return a.durationMs?.compareTo(b.durationMs ?? 0) ?? 0; case SortBy.artist: - return a.artists?.first.name?.compareTo(b.artists?.first.name ?? "") ?? 0; + return a.artists?.first.name + ?.compareTo(b.artists?.first.name ?? "") ?? + 0; case SortBy.album: return a.album?.name?.compareTo(b.album?.name ?? "") ?? 0; default: diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart deleted file mode 100644 index 662b611c..00000000 --- a/lib/utils/type_conversion_utils.dart +++ /dev/null @@ -1,154 +0,0 @@ -// ignore_for_file: non_constant_identifier_names - -import 'dart:io'; - -import 'package:flutter/widgets.dart' hide Image; -import 'package:metadata_god/metadata_god.dart'; -import 'package:path/path.dart'; -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/components/shared/links/anchor_button.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/utils/primitive_utils.dart'; -import 'package:spotube/utils/service_utils.dart'; - -enum ImagePlaceholder { - albumArt, - artist, - collection, - online, -} - -abstract class TypeConversionUtils { - static String image_X_UrlString( - List? images, { - int index = 1, - required ImagePlaceholder placeholder, - }) { - final String placeholderUrl = { - ImagePlaceholder.albumArt: Assets.albumPlaceholder.path, - ImagePlaceholder.artist: Assets.userPlaceholder.path, - ImagePlaceholder.collection: Assets.placeholder.path, - ImagePlaceholder.online: - "https://avatars.dicebear.com/api/bottts/${PrimitiveUtils.uuid.v4()}.png", - }[placeholder]!; - - return images != null && images.isNotEmpty - ? images[index > images.length - 1 ? images.length - 1 : index].url! - : placeholderUrl; - } - - static String artists_X_String(List artists) { - return artists.map((e) => e.name?.replaceAll(",", " ")).join(", "); - } - - static Widget artists_X_ClickableArtists( - List artists, { - WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.center, - WrapAlignment mainAxisAlignment = WrapAlignment.center, - TextStyle textStyle = const TextStyle(), - void Function(String route)? onRouteChange, - }) { - return Wrap( - crossAxisAlignment: crossAxisAlignment, - alignment: mainAxisAlignment, - children: artists - .asMap() - .entries - .map( - (artist) => Builder(builder: (context) { - return AnchorButton( - (artist.key != artists.length - 1) - ? "${artist.value.name}, " - : artist.value.name!, - onTap: () { - if (onRouteChange != null) { - onRouteChange("/artist/${artist.value.id}"); - } else { - ServiceUtils.push( - context, - "/artist/${artist.value.id}", - ); - } - }, - overflow: TextOverflow.ellipsis, - style: textStyle, - ); - }), - ) - .toList(), - ); - } - - static Album simpleAlbum_X_Album(AlbumSimple albumSimple) { - Album album = Album(); - album.albumType = albumSimple.albumType; - album.artists = albumSimple.artists; - album.availableMarkets = albumSimple.availableMarkets; - album.externalUrls = albumSimple.externalUrls; - album.href = albumSimple.href; - album.id = albumSimple.id; - album.images = albumSimple.images; - album.name = albumSimple.name; - album.releaseDate = albumSimple.releaseDate; - album.releaseDatePrecision = albumSimple.releaseDatePrecision; - album.tracks = albumSimple.tracks; - album.type = albumSimple.type; - album.uri = albumSimple.uri; - return album; - } - - static Track simpleTrack_X_Track(TrackSimple trackSmp, AlbumSimple album) { - Track track = Track(); - track.name = trackSmp.name; - track.album = album; - track.artists = trackSmp.artists; - track.availableMarkets = trackSmp.availableMarkets; - track.discNumber = trackSmp.discNumber; - track.durationMs = trackSmp.durationMs; - track.explicit = trackSmp.explicit; - track.externalUrls = trackSmp.externalUrls; - track.href = trackSmp.href; - track.id = trackSmp.id; - track.isPlayable = trackSmp.isPlayable; - track.linkedFrom = trackSmp.linkedFrom; - track.name = trackSmp.name; - track.previewUrl = trackSmp.previewUrl; - track.trackNumber = trackSmp.trackNumber; - track.type = trackSmp.type; - track.uri = trackSmp.uri; - return track; - } - - static Track localTrack_X_Track( - File file, { - Metadata? metadata, - String? art, - }) { - final track = Track(); - track.album = Album() - ..name = metadata?.album ?? "Unknown" - ..images = [if (art != null) Image()..url = art] - ..genres = [if (metadata?.genre != null) metadata!.genre!] - ..artists = [ - Artist() - ..name = metadata?.albumArtist ?? "Unknown" - ..id = metadata?.albumArtist ?? "Unknown" - ..type = "artist", - ] - ..id = metadata?.album - ..releaseDate = metadata?.year?.toString(); - track.artists = [ - Artist() - ..name = metadata?.artist ?? "Unknown" - ..id = metadata?.artist ?? "Unknown" - ]; - - track.id = metadata?.title ?? basenameWithoutExtension(file.path); - track.name = metadata?.title ?? basenameWithoutExtension(file.path); - track.type = "track"; - track.uri = file.path; - track.durationMs = (metadata?.durationMs?.toInt() ?? 0); - - return track; - } -} diff --git a/linux/com.github.KRTirtho.Spotube.appdata.xml b/linux/com.github.KRTirtho.Spotube.appdata.xml index 7b6c92c2..ebe2fb7d 100644 --- a/linux/com.github.KRTirtho.Spotube.appdata.xml +++ b/linux/com.github.KRTirtho.Spotube.appdata.xml @@ -3,7 +3,7 @@ com.github.KRTirtho.Spotube Spotube - 🎧 Open source Spotify client that doesn't require Premium nor uses Electron! Available for both desktop & mobile! + Freedom of music CC0-1.0 BSD-4-Clause @@ -13,9 +13,11 @@ touch Kingkor Roy Tirtho - https://github.com/krtirtho/spotube + https://github.com/krtirtho/spotube/issues + https://spotube.krtirtho.dev + https://opencollective.com/spotube -

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

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

Following are the features that currently spotube offers:

    @@ -30,12 +32,13 @@
  • 📖 Open source/libre software
  • 🔉 Playback control is done locally, not on the server
-
- + - https://rawcdn.githack.com/KRTirtho/spotube/62055018feade0b895663a0bfc5f85f265ae2154/assets/spotube-screenshot.png + https://rawcdn.githack.com/KRTirtho/spotube/62055018feade0b895663a0bfc5f85f265ae2154/assets/spotube-screenshot.png + + Spotube screenshot com.github.KRTirtho.Spotube.desktop diff --git a/linux/packaging/deb/make_config.yaml b/linux/packaging/deb/make_config.yaml index f4c279b4..95777f56 100644 --- a/linux/packaging/deb/make_config.yaml +++ b/linux/packaging/deb/make_config.yaml @@ -18,6 +18,11 @@ dependencies: - libjsoncpp25 - libmpv1 | libmpv2 - xdg-user-dirs + - avahi-daemon + - avahi-discover + - avahi-utils + - libnss-mdns + - mdns-scan essential: false icon: assets/spotube-logo.png diff --git a/linux/packaging/rpm/make_config.yaml b/linux/packaging/rpm/make_config.yaml index 1f952d0e..12b4473e 100644 --- a/linux/packaging/rpm/make_config.yaml +++ b/linux/packaging/rpm/make_config.yaml @@ -13,6 +13,9 @@ requires: - libsecret - libnotify - xdg-user-dirs + - avahi + - mdns-scan + - nss-mdns display_name: Spotube diff --git a/linux/spotube.desktop b/linux/spotube.desktop index 0bda851f..53f381e1 100644 --- a/linux/spotube.desktop +++ b/linux/spotube.desktop @@ -6,3 +6,4 @@ Icon=/usr/share/icons/spotube/spotube-logo.png Comment=A music streaming app combining the power of Spotify & YouTube Terminal=false Categories=Audio;Music;Player;AudioVideo; +MimeType=x-scheme-handler/spotify; \ No newline at end of file diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a7965e14..a9f6650f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,8 +8,10 @@ import Foundation import app_links import audio_service import audio_session +import bonsoir_darwin import device_info_plus import file_selector_macos +import flutter_inappwebview_macos import flutter_secure_storage_macos import local_notifier import media_kit_libs_macos_audio @@ -28,8 +30,10 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin")) MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin")) diff --git a/macos/Podfile b/macos/Podfile index 049abe29..9ec46f8c 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 566e8196..317de385 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -5,10 +5,16 @@ PODS: - FlutterMacOS - audio_session (0.0.1): - FlutterMacOS + - bonsoir_darwin (0.0.1): + - Flutter + - FlutterMacOS - device_info_plus (0.0.1): - FlutterMacOS - file_selector_macos (0.0.1): - FlutterMacOS + - flutter_inappwebview_macos (0.0.1): + - FlutterMacOS + - OrderedSet (~> 5.0) - flutter_secure_storage_macos (6.1.1): - FlutterMacOS - FlutterMacOS (1.0.0) @@ -22,6 +28,7 @@ PODS: - media_kit_native_event_loop (1.0.0): - FlutterMacOS - metadata_god (0.0.1) + - OrderedSet (5.0.0) - package_info_plus (0.0.1): - FlutterMacOS - path_provider_foundation (0.0.1): @@ -50,8 +57,10 @@ DEPENDENCIES: - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) - audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/macos`) - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) + - bonsoir_darwin (from `Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`) - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`) @@ -72,6 +81,7 @@ DEPENDENCIES: SPEC REPOS: trunk: - FMDB + - OrderedSet EXTERNAL SOURCES: app_links: @@ -80,10 +90,14 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/audio_service/macos audio_session: :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos + bonsoir_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin device_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos file_selector_macos: :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + flutter_inappwebview_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos flutter_secure_storage_macos: :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos FlutterMacOS: @@ -121,8 +135,10 @@ SPEC CHECKSUMS: app_links: 4481ed4d71f384b0c3ae5016f4633aa73d32ff67 audio_service: b88ff778e0e3915efd4cd1a5ad6f0beef0c950a9 audio_session: dea1f41890dbf1718f04a56f1d6150fd50039b72 + bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 + flutter_inappwebview_macos: 9600c9df9fdb346aaa8933812009f8d94304203d flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a @@ -130,6 +146,7 @@ SPEC CHECKSUMS: media_kit_libs_macos_audio: 3871782a4f3f84c77f04d7666c87800a781c24da media_kit_native_event_loop: 7321675377cb9ae8596a29bddf3a3d2b5e8792c5 metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7 + OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 @@ -141,6 +158,6 @@ SPEC CHECKSUMS: window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 window_size: 339dafa0b27a95a62a843042038fa6c3c48de195 -PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 +PODFILE CHECKSUM: 0d3963a09fc94f580682bd88480486da345dc3f0 COCOAPODS: 1.15.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index e2e72334..bf5d70cf 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -428,6 +428,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_HARDENED_RUNTIME = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Spotube; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; @@ -435,7 +436,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -558,6 +559,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_HARDENED_RUNTIME = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Spotube; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; @@ -565,7 +567,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -582,6 +584,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_HARDENED_RUNTIME = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Spotube; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; @@ -589,7 +592,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index e9de2261..f05277de 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -6,8 +6,6 @@ com.apple.security.assets.music.read-write - com.apple.security.cs.allow-jit - com.apple.security.files.downloads.read-write com.apple.security.files.user-selected.read-write diff --git a/macos/Runner/RunnerDebug.entitlements b/macos/Runner/RunnerDebug.entitlements index e9de2261..f05277de 100644 --- a/macos/Runner/RunnerDebug.entitlements +++ b/macos/Runner/RunnerDebug.entitlements @@ -6,8 +6,6 @@ com.apple.security.assets.music.read-write - com.apple.security.cs.allow-jit - com.apple.security.files.downloads.read-write com.apple.security.files.user-selected.read-write diff --git a/metadata/tr/full_description.txt b/metadata/tr/full_description.txt new file mode 100644 index 00000000..8b8b814c --- /dev/null +++ b/metadata/tr/full_description.txt @@ -0,0 +1,14 @@ +Premium gerektirmeyen ve Electron kullanmayan açık kaynaklı Spotify istemcisi! Hem masaüstü hem de mobil için kullanılabilir! + + +Özellikler: +* Herkese açık ve ücretsiz Spotify ve YT Music API'lerinin kullanımı sayesinde reklam yok¹ +* İndirilebilir parçalar +* Çapraz platform desteği +* Küçük boyut ve daha az veri kullanımı +* Anonim/misafir girişi +* Zaman senkronize şarkı sözleri +* Telemetri, tanılama veya kullanıcı verisi toplama yok +* Yerel performans +* Açık kaynak yazılım +* Oynatma kontrolü sunucu üzerinde değil, yerel olarak yapılır diff --git a/metadata/tr/images/icon.png b/metadata/tr/images/icon.png new file mode 100644 index 00000000..b24a8c23 Binary files /dev/null and b/metadata/tr/images/icon.png differ diff --git a/metadata/tr/images/phoneScreenshots/android-1.jpg b/metadata/tr/images/phoneScreenshots/android-1.jpg new file mode 100644 index 00000000..ae1ef8ac Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/android-1.jpg differ diff --git a/metadata/tr/images/phoneScreenshots/android-2.jpg b/metadata/tr/images/phoneScreenshots/android-2.jpg new file mode 100644 index 00000000..b6668d2b Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/android-2.jpg differ diff --git a/metadata/tr/images/phoneScreenshots/android-3.jpg b/metadata/tr/images/phoneScreenshots/android-3.jpg new file mode 100644 index 00000000..87619b21 Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/android-3.jpg differ diff --git a/metadata/tr/images/phoneScreenshots/android-4.jpg b/metadata/tr/images/phoneScreenshots/android-4.jpg new file mode 100644 index 00000000..2d1e58e2 Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/android-4.jpg differ diff --git a/metadata/tr/images/phoneScreenshots/android-5.jpg b/metadata/tr/images/phoneScreenshots/android-5.jpg new file mode 100644 index 00000000..fc4b2c9a Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/android-5.jpg differ diff --git a/metadata/tr/short_description.txt b/metadata/tr/short_description.txt new file mode 100644 index 00000000..2a0d24cd --- /dev/null +++ b/metadata/tr/short_description.txt @@ -0,0 +1 @@ +Spotify Premium gerektirmeyen hafif ve kaynak dostu spotify istemcisi \ No newline at end of file diff --git a/metadata/tr/title.txt b/metadata/tr/title.txt new file mode 100644 index 00000000..0271be7e --- /dev/null +++ b/metadata/tr/title.txt @@ -0,0 +1 @@ +Spotube \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 4df87392..5793c484 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.13.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: c1d5f167683de03d5ab6c3b53fc9aeefc5d59476e7810ba7bbddff50c6f4392d + url: "https://pub.dev" + source: hosted + version: "0.11.2" ansicolor: dependency: transitive description: @@ -169,6 +177,54 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + bonsoir: + dependency: "direct main" + description: + name: bonsoir + sha256: "9703ca3ce201c7ab6cd278ae5a530a125959687f59c2b97822f88a8db5bef106" + url: "https://pub.dev" + source: hosted + version: "5.1.9" + bonsoir_android: + dependency: transitive + description: + name: bonsoir_android + sha256: "19583ae34a5e5743fa2c16619e4ec699b35ae5e6cece59b99b1cf21c1b4ed618" + url: "https://pub.dev" + source: hosted + version: "5.1.4" + bonsoir_darwin: + dependency: transitive + description: + name: bonsoir_darwin + sha256: "985c4c38b4cbfa57ed5870e724a7e17aa080ee7f49d03b43e6d08781511505c6" + url: "https://pub.dev" + source: hosted + version: "5.1.2" + bonsoir_linux: + dependency: transitive + description: + name: bonsoir_linux + sha256: "65554b20bc169c68c311eb31fab46ccdd8ee3d3dd89a2d57c338f4cbf6ceb00d" + url: "https://pub.dev" + source: hosted + version: "5.1.2" + bonsoir_platform_interface: + dependency: transitive + description: + name: bonsoir_platform_interface + sha256: "4ee898bec0b5a63f04f82b06da9896ae8475f32a33b6fa395bea56399daeb9f0" + url: "https://pub.dev" + source: hosted + version: "5.1.2" + bonsoir_windows: + dependency: transitive + description: + name: bonsoir_windows + sha256: abbc90b73ac39e823b0c127da43b91d8906dcc530fc0cec4e169cf0d8c4404b1 + url: "https://pub.dev" + source: hosted + version: "5.1.4" boolean_selector: dependency: transitive description: @@ -221,10 +277,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "10c6bcdbf9d049a0b666702cf1cee4ddfdc38f02a19d35ae392863b47519848b" + sha256: "3ac61a79bfb6f6cc11f693591063a7f19a7af628dc52f141743edac5c16e8c22" url: "https://pub.dev" source: hosted - version: "2.4.6" + version: "2.4.9" build_runner_core: dependency: transitive description: @@ -313,6 +369,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + ci: + dependency: transitive + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" cli_util: dependency: transitive description: @@ -401,6 +465,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.3" + custom_lint: + dependency: "direct dev" + description: + name: custom_lint + sha256: "22bd87a362f433ba6aae127a7bac2838645270737f3721b180916d7c5946cb5d" + url: "https://pub.dev" + source: hosted + version: "0.5.11" + custom_lint_builder: + dependency: transitive + description: + name: custom_lint_builder + sha256: "0d48e002438950f9582e574ef806b2bea5719d8d14c0f9f754fbad729bcf3b19" + url: "https://pub.dev" + source: hosted + version: "0.5.14" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "2952837953022de610dacb464f045594854ced6506ac7f76af28d4a6490e189b" + url: "https://pub.dev" + source: hosted + version: "0.5.14" dart_des: dependency: transitive description: @@ -438,10 +526,10 @@ packages: dependency: "direct main" description: name: dbus - sha256: "6f07cba3f7b3448d42d015bfd3d53fe12e5b36da2423f23838efc1d5fb31a263" + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" url: "https://pub.dev" source: hosted - version: "0.7.8" + version: "0.7.10" device_frame: dependency: transitive description: @@ -454,10 +542,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: "86add5ef97215562d2e090535b0a16f197902b10c369c558a100e74ea06e8659" + sha256: "77f757b789ff68e4eaf9c56d1752309bd9f7ad557cb105b938a7f8eb89e59110" url: "https://pub.dev" source: hosted - version: "9.0.3" + version: "9.1.2" device_info_plus_platform_interface: dependency: transitive description: @@ -635,30 +723,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" - fl_query: - dependency: "direct main" - description: - name: fl_query - sha256: daee5ab0ed8899baa201b89b5813107df5258144a9e2bcf192dbcf922c57d985 - url: "https://pub.dev" - source: hosted - version: "1.0.0" - fl_query_devtools: - dependency: "direct main" - description: - name: fl_query_devtools - sha256: "2ae8905fd4a95f1d245a1b54057c31c8d27fc961223bcb7ce13088bcf6595059" - url: "https://pub.dev" - source: hosted - version: "0.1.0" - fl_query_hooks: - dependency: "direct main" - description: - name: fl_query_hooks - sha256: "6c88b3bfbdc3e1330931b927903929d7351f86fc63266ac93b3acb9f133a09a9" - url: "https://pub.dev" - source: hosted - version: "1.0.0" fluentui_system_icons: dependency: "direct main" description: @@ -746,18 +810,18 @@ packages: dependency: transitive description: name: flutter_gen_core - sha256: e8637dd6a59860f89e5e71be0a27101ec32dad1a0ed7fd879fd23b6e91d5004d + sha256: "3a6c3dbc1c0e260088e9c7ed1ba905436844e8c01a44799f6281edada9e45308" url: "https://pub.dev" source: hosted - version: "5.3.1" + version: "5.4.0" flutter_gen_runner: dependency: "direct dev" description: name: flutter_gen_runner - sha256: "7de1bf4fc0439be0fef3178b6423d5c7f1f9f3a38a7c6fafe75d7f70ff4856d7" + sha256: "24889d5140b03997f7148066a9c5fab8b606dff36093434c782d7a7fb22c6fb6" url: "https://pub.dev" source: hosted - version: "5.3.1" + version: "5.4.0" flutter_hooks: dependency: "direct main" description: @@ -770,10 +834,58 @@ packages: dependency: "direct main" description: name: flutter_inappwebview - sha256: f73505c792cf083d5566e1a94002311be497d984b5607f25be36d685cf6361cf + sha256: "3e9a443a18ecef966fb930c3a76ca5ab6a7aafc0c7b5e14a4a850cf107b09959" url: "https://pub.dev" source: hosted - version: "5.7.2+3" + version: "6.0.0" + flutter_inappwebview_android: + dependency: transitive + description: + name: flutter_inappwebview_android + sha256: d247f6ed417f1f8c364612fa05a2ecba7f775c8d0c044c1d3b9ee33a6515c421 + url: "https://pub.dev" + source: hosted + version: "1.0.13" + flutter_inappwebview_internal_annotations: + dependency: transitive + description: + name: flutter_inappwebview_internal_annotations + sha256: "5f80fd30e208ddded7dbbcd0d569e7995f9f63d45ea3f548d8dd4c0b473fb4c8" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter_inappwebview_ios: + dependency: transitive + description: + name: flutter_inappwebview_ios + sha256: f363577208b97b10b319cd0c428555cd8493e88b468019a8c5635a0e4312bd0f + url: "https://pub.dev" + source: hosted + version: "1.0.13" + flutter_inappwebview_macos: + dependency: transitive + description: + name: flutter_inappwebview_macos + sha256: b55b9e506c549ce88e26580351d2c71d54f4825901666bd6cfa4be9415bb2636 + url: "https://pub.dev" + source: hosted + version: "1.0.11" + flutter_inappwebview_platform_interface: + dependency: transitive + description: + name: flutter_inappwebview_platform_interface + sha256: "545fd4c25a07d2775f7d5af05a979b2cac4fbf79393b0a7f5d33ba39ba4f6187" + url: "https://pub.dev" + source: hosted + version: "1.0.10" + flutter_inappwebview_web: + dependency: transitive + description: + name: flutter_inappwebview_web + sha256: d8c680abfb6fec71609a700199635d38a744df0febd5544c5a020bd73de8ee07 + url: "https://pub.dev" + source: hosted + version: "1.0.8" flutter_keyboard_visibility: dependency: transitive description: @@ -1098,6 +1210,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.10" + hotreloader: + dependency: transitive + description: + name: hotreloader + sha256: ed56fdc1f3a8ac924e717257621d09e9ec20e308ab6352a73a50a1d7a4d9158e + url: "https://pub.dev" + source: hosted + version: "4.2.0" html: dependency: "direct main" description: @@ -1118,10 +1238,18 @@ packages: dependency: "direct main" description: name: http - sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" + sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.1" + http_methods: + dependency: transitive + description: + name: http_methods + sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566" + url: "https://pub.dev" + source: hosted + version: "1.1.1" http_multi_server: dependency: transitive description: @@ -1271,14 +1399,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.7.1" - json_view: - dependency: transitive - description: - name: json_view - sha256: "905c69f9e69d1eab5406b87ab6c10c3706c04c70c6a4959621bd2b43c2d27374" - url: "https://pub.dev" - source: hosted - version: "0.4.2" leak_tracker: dependency: transitive description: @@ -1335,6 +1455,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + lrc: + dependency: "direct main" + description: + name: lrc + sha256: "5100362b5c8e97f4d3f03ff87efeb40e73a6dd780eca2cbde9312e0d44b8e5ba" + url: "https://pub.dev" + source: hosted + version: "1.0.2" mailer: dependency: transitive description: @@ -1447,14 +1575,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" - mutex: - dependency: transitive - description: - name: mutex - sha256: "03116a4e46282a671b46c12de649d72c0ed18188ffe12a8d0fc63e83f4ad88f4" - url: "https://pub.dev" - source: hosted - version: "3.0.1" nested: dependency: transitive description: @@ -1676,10 +1796,10 @@ packages: dependency: "direct main" description: name: popover - sha256: "59f4a55ebb484d012c8aaa273ad58eee571945231b71fb938c5a69f63b5a94d4" + sha256: ca3bef9d88ebf5c5c3823946a5de3ce8360018fbb6a3e25819586a7d5a203db2 url: "https://pub.dev" source: hosted - version: "0.2.8+2" + version: "0.3.0" process: dependency: transitive description: @@ -1752,6 +1872,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.0" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: d72d7096964baf288b55619fe48100001fc4564ab7923ed0a7f5c7650e03c0d6 + url: "https://pub.dev" + source: hosted + version: "0.3.4" + riverpod_lint: + dependency: "direct dev" + description: + name: riverpod_lint + sha256: "70198738c3047ae4f6517ef1a2011a8514a980a52576c7f629a3a08810319a02" + url: "https://pub.dev" + source: hosted + version: "2.1.1" rxdart: dependency: transitive description: @@ -1858,13 +1994,21 @@ packages: source: hosted version: "2.3.2" shelf: - dependency: transitive + dependency: "direct main" description: name: shelf sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 url: "https://pub.dev" source: hosted version: "1.4.1" + shelf_router: + dependency: "direct main" + description: + name: shelf_router + sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864 + url: "https://pub.dev" + source: hosted + version: "1.1.4" shelf_static: dependency: transitive description: @@ -1874,7 +2018,7 @@ packages: source: hosted version: "1.1.2" shelf_web_socket: - dependency: transitive + dependency: "direct main" description: name: shelf_web_socket sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" @@ -1962,10 +2106,10 @@ packages: dependency: "direct main" description: name: spotify - sha256: e967c5e295792e9d38f4c5e9e60d7c2868ed9cb2a8fac2a67c75303f8395e374 + sha256: "2308a84511c18ec1e72515a57e28abb1467389549d571c460732b4538c2e34de" url: "https://pub.dev" source: hosted - version: "0.12.0" + version: "0.13.3" sqflite: dependency: transitive description: @@ -2278,14 +2422,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" - web_socket_channel: + web: dependency: transitive description: - name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + name: web + sha256: "1d9158c616048c38f712a6646e317a3426da10e884447626167240d45209cbad" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "0.5.0" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2" + url: "https://pub.dev" + source: hosted + version: "2.4.4" webdriver: dependency: transitive description: @@ -2368,5 +2520,5 @@ packages: source: hosted version: "2.0.2" sdks: - dart: ">=3.2.0 <4.0.0" + dart: ">=3.3.0 <4.0.0" flutter: ">=3.13.0" diff --git a/pubspec.yaml b/pubspec.yaml index 36726b09..153d0f07 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Open source Spotify client that doesn't require Premium nor uses El publish_to: "none" -version: 3.4.1+28 +version: 3.5.0+29 homepage: https://spotube.krtirtho.dev repository: https://github.com/KRTirtho/spotube @@ -25,16 +25,13 @@ dependencies: cupertino_icons: ^1.0.5 curved_navigation_bar: ^1.0.3 dbus: ^0.7.8 - device_info_plus: ^9.0.3 + device_info_plus: ^9.1.2 device_preview: ^1.1.0 dio: ^5.4.1 disable_battery_optimization: ^1.1.0+1 duration: ^3.0.12 envied: ^0.3.0 file_selector: ^1.0.1 - fl_query: ^1.0.0 - fl_query_hooks: ^1.0.0 - fl_query_devtools: ^0.1.0 fluentui_system_icons: ^1.1.189 flutter: sdk: flutter @@ -46,7 +43,7 @@ dependencies: flutter_displaymode: ^0.6.0 flutter_feather_icons: ^2.0.0+1 flutter_hooks: ^0.20.5 - flutter_inappwebview: ^5.7.2+3 + flutter_inappwebview: ^6.0.0 flutter_localizations: sdk: flutter flutter_native_splash: ^2.3.10 @@ -61,7 +58,7 @@ dependencies: hive_flutter: ^1.1.0 hooks_riverpod: ^2.4.3 html: ^0.15.1 - http: ^1.1.0 + http: ^1.2.0 image_picker: ^1.0.4 intl: ^0.18.0 introduction_screen: ^3.0.2 @@ -79,7 +76,7 @@ dependencies: piped_client: git: url: https://github.com/KRTirtho/piped_client.git - popover: ^0.2.6+3 + popover: ^0.3.0 scrobblenaut: git: url: https://github.com/KRTirtho/scrobblenaut.git @@ -89,7 +86,6 @@ dependencies: shared_preferences: ^2.2.2 skeleton_text: ^3.0.1 smtc_windows: ^0.1.1 - spotify: ^0.12.0 stroke_text: ^0.0.2 system_theme: ^2.1.0 titlebar_buttons: ^1.0.0 @@ -126,12 +122,19 @@ dependencies: flutter_sharing_intent: ^1.1.0 flutter_broadcasts: ^0.4.0 freezed_annotation: ^2.4.1 + spotify: ^0.13.3 + bonsoir: ^5.1.9 + shelf: ^1.4.1 + shelf_router: ^1.1.4 + shelf_web_socket: ^1.0.4 + web_socket_channel: ^2.4.4 + lrc: ^1.0.2 dev_dependencies: - build_runner: ^2.3.2 + build_runner: ^2.4.9 envied_generator: ^0.3.0+3 flutter_distributor: ^0.0.2 - flutter_gen_runner: ^5.1.0+1 + flutter_gen_runner: ^5.4.0 flutter_launcher_icons: ^0.13.1 flutter_lints: ^3.0.1 flutter_test: @@ -143,9 +146,10 @@ dev_dependencies: pub_api_client: ^2.4.0 pubspec_parse: ^1.2.2 freezed: ^2.4.6 + custom_lint: ^0.5.11 + riverpod_lint: ^2.1.1 dependency_overrides: - http: ^1.1.0 system_tray: 2.0.2 flutter: diff --git a/server/.pocketbase b/server/.pocketbase deleted file mode 100644 index bcb8312e..00000000 --- a/server/.pocketbase +++ /dev/null @@ -1 +0,0 @@ -version=0.12.1 \ No newline at end of file diff --git a/server/pb_migrations/1675256468_created_tracks.js b/server/pb_migrations/1675256468_created_tracks.js deleted file mode 100644 index 46d03fbb..00000000 --- a/server/pb_migrations/1675256468_created_tracks.js +++ /dev/null @@ -1,63 +0,0 @@ -migrate((db) => { - const collection = new Collection({ - "id": "pevn93oxbnovw0s", - "created": "2023-02-01 13:01:08.893Z", - "updated": "2023-02-01 13:01:08.893Z", - "name": "tracks", - "type": "base", - "system": false, - "schema": [ - { - "system": false, - "id": "ycnix0ai", - "name": "spotify_id", - "type": "text", - "required": true, - "unique": false, - "options": { - "min": 20, - "max": 22, - "pattern": "" - } - }, - { - "system": false, - "id": "ih8fxzgh", - "name": "youtube_id", - "type": "text", - "required": true, - "unique": false, - "options": { - "min": 10, - "max": 11, - "pattern": "" - } - }, - { - "system": false, - "id": "vzvqgsjf", - "name": "votes", - "type": "number", - "required": true, - "unique": false, - "options": { - "min": null, - "max": null - } - } - ], - "listRule": null, - "viewRule": null, - "createRule": null, - "updateRule": null, - "deleteRule": null, - "options": {} - }); - - return Dao(db).saveCollection(collection); -}, (db) => { - const dao = new Dao(db); - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s"); - - return dao.deleteCollection(collection); -}) diff --git a/server/pb_migrations/1675256557_updated_tracks.js b/server/pb_migrations/1675256557_updated_tracks.js deleted file mode 100644 index cdcf19bc..00000000 --- a/server/pb_migrations/1675256557_updated_tracks.js +++ /dev/null @@ -1,17 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.listRule = "" - collection.viewRule = "" - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.listRule = null - collection.viewRule = null - - return dao.saveCollection(collection) -}) diff --git a/server/pb_migrations/1675256593_updated_users.js b/server/pb_migrations/1675256593_updated_users.js deleted file mode 100644 index 5643c3a0..00000000 --- a/server/pb_migrations/1675256593_updated_users.js +++ /dev/null @@ -1,19 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("_pb_users_auth_") - - collection.createRule = null - collection.updateRule = null - collection.deleteRule = null - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("_pb_users_auth_") - - collection.createRule = "" - collection.updateRule = "id = @request.auth.id" - collection.deleteRule = "id = @request.auth.id" - - return dao.saveCollection(collection) -}) diff --git a/server/pb_migrations/1675256678_updated_tracks.js b/server/pb_migrations/1675256678_updated_tracks.js deleted file mode 100644 index 4b472ad1..00000000 --- a/server/pb_migrations/1675256678_updated_tracks.js +++ /dev/null @@ -1,17 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.createRule = "@request.auth.id != ''" - collection.updateRule = "@request.auth.id != ''" - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.createRule = null - collection.updateRule = null - - return dao.saveCollection(collection) -}) diff --git a/server/pb_migrations/1675257121_updated_tracks.js b/server/pb_migrations/1675257121_updated_tracks.js deleted file mode 100644 index a1b7604f..00000000 --- a/server/pb_migrations/1675257121_updated_tracks.js +++ /dev/null @@ -1,17 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.createRule = "@request.auth.id != '' && ((spotify_id ?= @collection.tracks.spotify_id && youtube_id ?= @collection.tracks.youtube_id) || (spotify_id ?!= @collection.tracks.spotify_id && youtube_id ?!= @collection.tracks.youtube_id))" - collection.updateRule = "@request.auth.id != '' && ((spotify_id ?= @collection.tracks.spotify_id && youtube_id ?= @collection.tracks.youtube_id) || (spotify_id ?!= @collection.tracks.spotify_id && youtube_id ?!= @collection.tracks.youtube_id))" - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.createRule = null - collection.updateRule = null - - return dao.saveCollection(collection) -}) diff --git a/server/pb_migrations/1675257148_updated_tracks.js b/server/pb_migrations/1675257148_updated_tracks.js deleted file mode 100644 index 544d0e85..00000000 --- a/server/pb_migrations/1675257148_updated_tracks.js +++ /dev/null @@ -1,39 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - // update - collection.schema.addField(new SchemaField({ - "system": false, - "id": "vzvqgsjf", - "name": "votes", - "type": "number", - "required": false, - "unique": false, - "options": { - "min": null, - "max": null - } - })) - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - // update - collection.schema.addField(new SchemaField({ - "system": false, - "id": "vzvqgsjf", - "name": "votes", - "type": "number", - "required": true, - "unique": false, - "options": { - "min": null, - "max": null - } - })) - - return dao.saveCollection(collection) -}) diff --git a/untranslated_messages.json b/untranslated_messages.json index 14eead0f..be7d38f1 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1,574 +1,203 @@ { "ar": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ], "bn": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ], "ca": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ], "de": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ], "es": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ], "fa": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ], "fr": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ], "hi": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ], "it": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ], "ja": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "ko": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ], "ne": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ], "nl": [ - "sort_duration", - "audio_source", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ], "pl": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ], "pt": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ], "ru": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ], "tr": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ], "uk": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ], "vi": [ - "sort_duration", "friends", "no_lyrics_available", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ], "zh": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ] } diff --git a/website/src/routes/+page.svelte b/website/src/routes/+page.svelte index 8b78dbc5..7fe0a36f 100644 --- a/website/src/routes/+page.svelte +++ b/website/src/routes/+page.svelte @@ -62,8 +62,12 @@ Download on Flathub +
+ + HackerNews + -
+
Download diff --git a/website/src/routes/downloads/packages/+page.svx b/website/src/routes/downloads/packages/+page.svx index e7da4e74..0ab570c2 100644 --- a/website/src/routes/downloads/packages/+page.svx +++ b/website/src/routes/downloads/packages/+page.svx @@ -4,7 +4,7 @@ author: Kingkor Roy Tirtho --- @@ -15,7 +15,7 @@ author: Kingkor Roy Tirtho ## Linux -### Flatpak +### Flatpak📦 Make sure [Flatpak](https://flatpak.org) is installed in your Linux device & Run the following command in the terminal: @@ -23,7 +23,7 @@ Make sure [Flatpak](https://flatpak.org) is installed in your Linux device & Run $ flatpak install com.github.KRTirtho.Spotube ``` -### Arch User Repository (AUR) +### Arch User Repository (AUR)♾️ If you're an Arch Linux user, you can also install Spotube from AUR. Make sure you have `yay`/`pamac`/`paru` installed in your system. And Run the Following command in the Terminal: @@ -40,9 +40,20 @@ $ pamac install spotube-bin $ paru -Sy spotube-bin ``` +## MacOS + +### Homebrew🍻 + +Spotube can be installed through Homebrew. We host our own cask definition thus you'll need to add our tap first: + +```bash +$ brew tap krtirtho/apps +$ brew install --cask spotube +``` + ## Windows -### Chocolatey +### Chocolatey🍫 Spotube is available in [community.chocolatey.org](https://community.chocolatey.org) repo. If you have chocolatey install in your system just run following command in an Elevated Command Prompt or PowerShell: @@ -50,7 +61,7 @@ Spotube is available in [community.chocolatey.org](https://community.chocolatey. $ choco install spotube ``` -### WinGet +### WinGet💫 Spotube is also available in the Official Windows PackageManager WinGet. Make sure you have WinGet installed in your Windows machine and run following in a Terminal: @@ -58,7 +69,7 @@ Spotube is also available in the Official Windows PackageManager WinGet. Make su $ winget install --id KRTirtho.Spotube ``` -### Scoop +### Scoop🥄 Spotube is also available in [Scoop](https://scoop.sh) bucket. Make sure you have Scoop installed in your Windows machine and run following in a Terminal: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index fcf9927e..d8a9db29 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -23,6 +24,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + BonsoirWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("BonsoirWindowsPluginCApi")); DartDiscordRpcPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("DartDiscordRpcPlugin")); FileSelectorWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 0fe6e076..90292744 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + bonsoir_windows dart_discord_rpc file_selector_windows flutter_secure_storage_windows