diff --git a/.circleci/config.yml b/.circleci/config.yml index a5c71033..a55310ce 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -82,8 +82,6 @@ jobs: name: Generate .env file command: | echo "SPOTIFY_SECRETS=${SPOTIFY_SECRETS}" >> .env - echo "SUPABASE_URL=${SUPABASE_URL}" >> .env - echo "SUPABASE_API_KEY=${SUPABASE_API_KEY}" >> .env - run: name: Replace Version in files diff --git a/.env.example b/.env.example index 67d1be8e..22abd24b 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,3 @@ -SUPABASE_URL= -SUPABASE_API_KEY= - # The format: # SPOTIFY_SECRETS=clintId1:clientSecret1,clientId2:clientSecret2 SPOTIFY_SECRETS= diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index ba129cfd..f1f9ceed 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,4 @@ { - "flutterSdkVersion": "3.10.0", + "flutterSdkVersion": "3.16.0", "flavors": {} } \ No newline at end of file diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index d461e296..d57cc0e8 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -4,7 +4,7 @@ on: inputs: version: description: Version to release (x.x.x) - default: 3.2.0 + default: 3.3.0 required: true channel: type: choice @@ -26,13 +26,18 @@ on: default: true env: - FLUTTER_VERSION: '3.13.2' + FLUTTER_VERSION: '3.16.0' jobs: windows: runs-on: windows-latest steps: - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: KRTirtho/flutter_distributor + path: flutter_distributor + ref: fix-windows-build - uses: subosito/flutter-action@v2.10.0 with: cache: true @@ -74,9 +79,10 @@ jobs: - name: Build Windows Executable run: | - dart pub global activate flutter_distributor + dart pub global activate melos + cd flutter_distributor && melos bs && cd .. make innoinstall - flutter_distributor package --platform=windows --targets=exe --skip-clean + dart run ./flutter_distributor/packages/flutter_distributor/bin/main.dart package --platform=windows --targets=exe --skip-clean mv dist/**/spotube-*-windows-setup.exe dist/Spotube-windows-x86_64-setup.exe - name: Create Chocolatey Package and set hash @@ -319,6 +325,7 @@ jobs: - name: Package Macos App run: | + python3 -m pip install setuptools npm install -g appdmg mkdir -p build/${{ env.BUILD_VERSION }} appdmg appdmg.json build/Spotube-macos-universal.dmg diff --git a/CHANGELOG.md b/CHANGELOG.md index 3710d812..1544f055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,45 @@ 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.3.0](https://github.com/KRTirtho/spotube/compare/v3.2.0...v3.3.0) (2023-11-27) + + +### Features + +* Add JioSaavn as audio source ([#881](https://github.com/KRTirtho/spotube/issues/881)) ([14069cd](https://github.com/KRTirtho/spotube/commit/14069cd4fe08597c8d9aa0810270fb4c386c1d55)) +* **android:** better quick scroll/drag to scroll implementation ([2e2c44f](https://github.com/KRTirtho/spotube/commit/2e2c44f0afef69bf9bc485db97d45127a0847c8e)) +* **artist:** modularize page and add wikipedia section ([2a69886](https://github.com/KRTirtho/spotube/commit/2a698865567883271471ace9a44123bbfd8fcd2f)) +* discord RPC integration [#98](https://github.com/KRTirtho/spotube/issues/98) ([88b8785](https://github.com/KRTirtho/spotube/commit/88b8785cb86a19900f3a867b044c1ccb2fe400bb)) +* **mini_player:** show/hide lyrics [#851](https://github.com/KRTirtho/spotube/issues/851) ([dcbb156](https://github.com/KRTirtho/spotube/commit/dcbb1568337969841acc0abe0e7185ee5e4c3590)) +* paginated playlist and album page ([28a5d6b](https://github.com/KRTirtho/spotube/commit/28a5d6bb3820ab0bd4007664f73d685f6e1d2c90)) +* **translations:** add Turkish translations ([0c22469](https://github.com/KRTirtho/spotube/commit/0c22469503f32dbbf1a5d31419c1b76c699fa966)) + + +### Bug Fixes + +* "Add () to Playlist" option not showing in favorited playlists [#904](https://github.com/KRTirtho/spotube/issues/904) ([96021e1](https://github.com/KRTirtho/spotube/commit/96021e1a49d22bd25fd052c122f49f439c2bea43)) +* 0:00 media duration in queue after application restart [#782](https://github.com/KRTirtho/spotube/issues/782) ([83c0b49](https://github.com/KRTirtho/spotube/commit/83c0b49da962d9f3d40de9525f90f0b320e8f7b8)) +* Add to Playlist Dialog memory leak [#817](https://github.com/KRTirtho/spotube/issues/817) ([fed36ec](https://github.com/KRTirtho/spotube/commit/fed36ecdd81e8a0f8358693eff0a6233dea32e5d)) +* **album_card:** show loading state during adding track to queue/play ([5633367](https://github.com/KRTirtho/spotube/commit/5633367397812148f6d712d06e97a4f84033f968)) +* alternative track source safearea overflow [#876](https://github.com/KRTirtho/spotube/issues/876) ([7b72a90](https://github.com/KRTirtho/spotube/commit/7b72a90bc65b541cbe2e24ef2234524b522ad71d)) +* android invalid download location Download not starting or not explaining error [#720](https://github.com/KRTirtho/spotube/issues/720) ([d056dbf](https://github.com/KRTirtho/spotube/commit/d056dbf9eeef7033dbc012d0c05800063e820042)) +* changed settings are not persisting after force stop [#821](https://github.com/KRTirtho/spotube/issues/821) ([e29a38d](https://github.com/KRTirtho/spotube/commit/e29a38dfa43ddf7a38046d1d40424f01dbe62261)) +* check for unsynced lyrics and error handling for timed lyrics query ([1d77556](https://github.com/KRTirtho/spotube/commit/1d77556157d158600f29cf2ea5f26c567607dec7)) +* **genres:** lag while scrolling ([dc980b0](https://github.com/KRTirtho/spotube/commit/dc980b024edad3132e72cbb2f0087297a4b76469)) +* infinite list disappearing for a moment everytime new page is fetched ([1334a62](https://github.com/KRTirtho/spotube/commit/1334a62aaea31f97031b3ebf455e94c583f37314)) +* last track of queue keeps repeating [#718](https://github.com/KRTirtho/spotube/issues/718) ([58e5698](https://github.com/KRTirtho/spotube/commit/58e569864dddd74c3064624998dfc184046e97eb)) +* Navigating to settings, redirects to home page [#812](https://github.com/KRTirtho/spotube/issues/812) ([da04f06](https://github.com/KRTirtho/spotube/commit/da04f068f9b7effff8d50cb5714d93ea80c22b7f)) +* new releases section flickering on scroll glitch ([ee94b7c](https://github.com/KRTirtho/spotube/commit/ee94b7cbb24e0f0bc22a6d49c830d4055aa02895)) +* **playbutton_card:** annoying animation ([574406d](https://github.com/KRTirtho/spotube/commit/574406dd5fc410914b27e7fce374323696845012)) +* scrobbling not working for first track or single track ([0a6b54d](https://github.com/KRTirtho/spotube/commit/0a6b54da367345b73fe6e954f1d9368d9f9ead71)) +* settings page scrollbar position ([ee82290](https://github.com/KRTirtho/spotube/commit/ee8229020b3b03fc074b316db4b322af13b807bd)) +* shuffle doesn't move active track to top ([4956bf3](https://github.com/KRTirtho/spotube/commit/4956bf367baae39c88b5de7c6c136513a14f8ad2)) +* spotube doesn't exit properly, hangs in infinite loop [#768](https://github.com/KRTirtho/spotube/issues/768) ([353ca79](https://github.com/KRTirtho/spotube/commit/353ca79be334077c3ac27b4f64e8b4b15eca7175)) +* trim login field padding ([286ef83](https://github.com/KRTirtho/spotube/commit/286ef83e8ec516db70019398d9e3e724437a4172)) +* use CustomScrollView for personalized page ([7d05c40](https://github.com/KRTirtho/spotube/commit/7d05c40dc0d04208b059f2483c1e4de199c8b51d)) +* user_playlists layout, track tile index, ([487c2ed](https://github.com/KRTirtho/spotube/commit/487c2ed6bdc4af33006ba52532eb4eaaa261dceb)) +* **windows:** media control not working [#641](https://github.com/KRTirtho/spotube/issues/641) ([7818574](https://github.com/KRTirtho/spotube/commit/7818574356d0fb8ff567e1f6a83fd0b6f2ee7c8a)) + ## [3.2.0](https://github.com/KRTirtho/spotube/compare/v3.1.2...v3.2.0) (2023-10-16) diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index 11206e6d..b2823e62 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -119,7 +119,7 @@ Enhancement suggestions are tracked as [GitHub issues](https://github.com/KRTirt Do the following: -- Download the latest Flutter SDK (>=3.10.0) & enable desktop support +- Download the latest Flutter SDK (>=3.16.0) & enable desktop support - Install Development dependencies in linux - Debian (>=12/Bookworm)/Ubuntu ```bash diff --git a/README.md b/README.md index 71589794..498c45de 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Spotube Logo An open source, cross-platform Spotify client compatible across multiple platforms
-utilizing Spotify's data API and YouTube (or Piped.video) as an audio source,
+utilizing Spotify's data API and YouTube (or Piped.video or JioSaavn) as an audio source,
eliminating the need for Spotify Premium Btw it's not another Electron app😉 @@ -108,7 +108,7 @@ This handy table lists all methods you can use to install Spotube: Debian/Ubuntu Download -

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

+

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

@@ -184,19 +184,23 @@ If you are concerned, you can [read the reason of choosing this license](https:/
-

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

+

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

+### Services 1. [Flutter](https://flutter.dev) - Flutter transforms the app development process. Build, test, and deploy beautiful mobile, web, desktop, and embedded apps from a single codebase 1. [Spotify API](https://developer.spotify.com/documentation/web-api) - The Spotify Web API is a RESTful API that provides access to Spotify data 1. [Piped](https://piped-docs.kavin.rocks/) - Piped is a privacy friendly alternative YouTube frontend, which is efficient and scalable by design. 1. [YouTube](https://youtube.com/) - YouTube is an American online video-sharing platform headquartered in San Bruno, California. Three former PayPal employees—Chad Hurley, Steve Chen, and Jawed Karim—created the service in February 2005 +1. [JioSaavn](https://www.jiosaavn.com) - JioSaavn is an Indian online music streaming service and a digital distributor of Bollywood, English and other regional Indian music across the world. Since it was founded in 2007 as Saavn, the company has acquired rights to over 5 crore (50 million) music tracks in 15 languages 1. [Linux](https://www.linux.org) - Linux is a family of open-source Unix-like operating systems based on the Linux kernel, an operating system kernel first released on September 17, 1991, by Linus Torvalds. Linux is typically packaged in a Linux distribution 1. [AUR](https://aur.archlinux.org) - AUR stands for Arch User Repository. It is a community-driven repository for Arch-based Linux distributions users 1. [Flatpak](https://flatpak.org) - Flatpak is a utility for software deployment and package management for Linux 1. [SponsorBlock](https://sponsor.ajay.app) - SponsorBlock is an open-source crowdsourced browser extension and open API for skipping sponsor segments in YouTube videos. 1. [Inno Setup](https://jrsoftware.org/isinfo.php) - Inno Setup is a free installer for Windows programs by Jordan Russell and Martijn Laan 1. [F-Droid](https://f-droid.org) - F-Droid is an installable catalogue of FOSS (Free and Open Source Software) applications for the Android platform. The client makes it easy to browse, install, and keep track of updates on your device + +### Dependencies 1. [args](https://pub.dev/packages/args) - Library for defining parsers for parsing raw command-line arguments into a set of options and values using GNU and POSIX style options. 1. [async](https://pub.dev/packages/async) - Utility functions and classes related to the 'dart:async' library. 1. [audio_service](https://pub.dev/packages/audio_service) - Flutter plugin to play audio in the background while the screen is off. @@ -216,7 +220,10 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [duration](https://github.com/desktop-dart/duration) - Utilities to make working with 'Duration's easier. Formats duration in human readable form and also parses duration in human readable form to Dart's Duration. 1. [envied](https://github.com/petercinibulk/envied) - Explicitly reads environment variables into a dart file from a .env file for more security and faster start up times. 1. [file_selector](https://pub.dev/packages/file_selector) - Flutter plugin for opening and saving files, or selecting directories, using native file selection UI. -1. [fluentui_system_icons](https://github.com/microsoft/fluentui-system-icons/tree/main) - Fluent UI System Icons are a collection of familiar, friendly and modern icons from Microsoft. +1. [fl_query](https://fl-query.krtirtho.dev) - Asynchronous data caching, refetching & invalidation library for Flutter +1. [fl_query_hooks](https://fl-query.krtirtho.dev) - Elite flutter_hooks compatible library for fl_query, the Asynchronous data caching, refetching & invalidation library for Flutter +1. [fl_query_devtools](https://fl-query.krtirtho.dev) - Devtools support for Fl-Query +1. [fluentui_system_icons](https://github.com/microsoft/fluentui-system-icons/tree/main) - Fluent UI System Icons are a collection of familiar, friendly and modern icons from Microsoft. 1. [flutter_cache_manager](https://github.com/Baseflow/flutter_cache_manager/tree/develop/flutter_cache_manager) - Generic cache manager for flutter. Saves web files on the storages of the device and saves the cache info using sqflite. 1. [flutter_displaymode](https://github.com/ajinasokan/flutter_displaymode) - A Flutter plugin to set display mode (resolution, refresh rate) on Android platform. Allows to enable high refresh rate on supported devices. 1. [flutter_feather_icons](https://github.com/muj-programmer/flutter_feather_icons) - Feather is a collection of simply beautiful open source icons. Each icon is designed on a 24x24 grid with an emphasis on simplicity, consistency and usability. @@ -227,7 +234,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage) - Flutter Secure Storage provides API to store data in secure storage. Keychain is used in iOS, KeyStore based solution is used in Android. 1. [flutter_svg](https://pub.dev/packages/flutter_svg) - An SVG rendering and widget library for Flutter, which allows painting and displaying Scalable Vector Graphics 1.1 files. 1. [form_validator](https://github.com/TheMisir/form-validator) - Simplest form validation library for flutter's form field widgets -1. [fuzzywuzzy](https://github.com/sphericalkat/dart-fuzzywuzzy) - An implementation of the popular fuzzywuzzy package in Dart, to suit all your fuzzy string matching/searching needs! +1. [fuzzywuzzy](https://github.com/sphericalkat/dart-fuzzywuzzy) - An implementation of the popular fuzzywuzzy package in Dart, to suit all your fuzzy string matching/searching needs! 1. [google_fonts](https://pub.dev/packages/google_fonts) - A Flutter package to use fonts from fonts.google.com. Supports HTTP fetching, caching, and asset bundling. 1. [go_router](https://pub.dev/packages/go_router) - A declarative router for Flutter based on Navigation 2 supporting deep linking, data-driven routes and more 1. [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. @@ -244,10 +251,10 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [media_kit_libs_audio](https://github.com/media-kit/media-kit.git) - package:media_kit audio (only) playback native libraries for all platforms. 1. [metadata_god](https://github.com/KRTirtho/metadata_god) - Plugin for retrieving and writing audio tags/metadata from audio files 1. [mime](https://pub.dev/packages/mime) - Utilities for handling media (MIME) types, including determining a type from a file extension and file contents. -1. [package_info_plus](https://plus.fluttercommunity.dev/) - Flutter plugin for querying information about the application package, such as CFBundleVersion on iOS or versionCode on Android. +1. [package_info_plus](https://plus.fluttercommunity.dev/) - Flutter plugin for querying information about the application package, such as CFBundleVersion on iOS or versionCode on Android. 1. [palette_generator](https://pub.dev/packages/palette_generator) - Flutter package for generating palette colors from a source image. 1. [path](https://pub.dev/packages/path) - A string-based path manipulation library. All of the path operations you know and love, with solid support for Windows, POSIX (Linux and Mac OS X), and the web. -1. [path_provider](https://pub.dev/packages/path_provider) - Flutter plugin for getting commonly used locations on host platform file systems, such as the temp and app data directories. +1. [path_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. @@ -258,7 +265,6 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 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. [supabase](https://supabase.com) - A dart client for Supabase. This client makes it simple for developers to build secure and scalable products. 1. [system_theme](https://pub.dev/packages/system_theme) - A plugin to get the current system theme info. Supports Android, Web, Windows, Linux and macOS 1. [titlebar_buttons](https://github.com/gtk-flutter/titlebar_buttons) - A package which provides most of the titlebar buttons from windows, linux and macos. 1. [url_launcher](https://pub.dev/packages/url_launcher) - Flutter plugin for launching a URL. Supports web, phone, SMS, and email schemes. @@ -269,6 +275,13 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [youtube_explode_dart](https://github.com/Hexer10/youtube_explode_dart) - A port in dart of the youtube explode library. Supports several API functions without the need of Youtube API Key. 1. [simple_icons](https://jlnrrg.github.io/) - The Simple Icon pack available as Flutter Icons. Provides over 1500 Free SVG icons for popular brands. 1. [audio_service_mpris](https://github.com/bdrazhzhov/audio-service-mpris) - audio_service platform interface supporting Media Player Remote Interfacing Specification. +1. [file_picker](https://github.com/miguelpruivo/plugins_flutter_file_picker) - A package that allows you to use a native file explorer to pick single or multiple absolute file paths, with extension filtering support. +1. [jiosaavn](https://github.com/KRTirtho/jiosaavn) - Unofficial API client for jiosaavn.com +1. [very_good_infinite_list](https://github.com/VeryGoodOpenSource/very_good_infinite_list) - A library for easily displaying paginated data, created by Very Good Ventures. Great for activity feeds, news feeds, and more. +1. [gap](https://github.com/letsar/gap) - Flutter widgets for easily adding gaps inside Flex widgets such as Columns and Rows or scrolling views. +1. [sliver_tools](https://github.com/Kavantix) - A set of useful sliver tools that are missing from the flutter framework +1. [html_unescape](https://github.com/filiph/html_unescape) - A small library for un-escaping HTML. Supports all Named Character References, Decimal Character References and Hexadecimal Character References. +1. [wikipedia_api](https://github.com/KRTirtho/wikipedia_api) - Wikipedia API for dart and flutter 1. [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. @@ -279,12 +292,11 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [json_serializable](https://pub.dev/packages/json_serializable) - Automatically generate code for converting to and from JSON by annotating Dart classes. 1. [pub_api_client](https://github.com/leoafarias/pub_api_client) - An API Client for Pub to interact with public package information. 1. [pubspec_parse](https://pub.dev/packages/pubspec_parse) - Simple package for parsing pubspec.yaml files with a type-safe API and rich error reporting. -1. [fl_query](https://fl-query.vercel.app) - Asynchronous data caching, refetching & invalidation library for Flutter -1. [fl_query_hooks](https://fl-query.vercel.app) - Elite flutter_hooks compatible library for fl_query, the Asynchronous data caching, refetching & invalidation library for Flutter -1. [fl_query_devtools](https://fl-query.vercel.app) - Devtools support for Fl-Query 1. [flutter_desktop_tools](https://github.com/KRTirtho/flutter_desktop_tools) - Essential collection of tools for flutter desktop app development 1. [scrobblenaut](https://github.com/Nebulino/Scrobblenaut) - A deadly simple LastFM API Wrapper for Dart. So deadly simple that it's gonna hit the mark. 1. [window_size](https://github.com/google/flutter-desktop-embedding.git) - Allows resizing and repositioning the window containing Flutter. +1. [draggable_scrollbar](https://github.com/fluttercommunity/flutter-draggable-scrollbar) - A scrollbar that can be dragged for quickly navigation through a vertical list. Additional option is showing label next to scrollthumb with information about current item. +1. [dart_discord_rpc](https://github.com/alexmercerind/dart_discord_rpc) - Discord Rich Presence for Flutter & Dart apps & games.

© Copyright Spotube 2023

diff --git a/bin/gen-credits.dart b/bin/gen-credits.dart index 43e1e53d..f8975335 100644 --- a/bin/gen-credits.dart +++ b/bin/gen-credits.dart @@ -1,7 +1,7 @@ +import 'dart:developer'; import 'dart:io'; import 'package:collection/collection.dart'; -import 'package:path/path.dart'; import 'package:http/http.dart'; import 'package:html/parser.dart'; import 'package:pub_api_client/pub_api_client.dart'; @@ -33,15 +33,20 @@ void main() async { final gitDeps = gitDepsList.map( (d) { + final uri = Uri.parse( + d.value.url.toString().replaceAll('.git', ''), + ); return MapEntry( d.key, - join( - d.value.url.toString().replaceAll('.git', ''), - 'raw', - d.value.ref ?? 'main', - d.value.path ?? '', - 'pubspec.yaml', - ), + uri.replace( + pathSegments: [ + ...uri.pathSegments, + 'raw', + d.value.ref ?? 'main', + d.value.path ?? '', + 'pubspec.yaml', + ], + ).toString(), ); }, ).toList(); @@ -55,7 +60,10 @@ void main() async { } catch (e) { final document = parse(res.body); final pre = document.querySelector('pre'); - if (pre == null) rethrow; + if (pre == null) { + log(d.toString()); + rethrow; + } return Pubspec.parse(pre.text); } } diff --git a/lib/collections/env.dart b/lib/collections/env.dart index 1b9de3de..50fe1e6a 100644 --- a/lib/collections/env.dart +++ b/lib/collections/env.dart @@ -1,15 +1,10 @@ import 'package:envied/envied.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; part 'env.g.dart'; @Envied(obfuscate: true, requireEnvFile: true, path: ".env") abstract class Env { - @EnviedField(varName: 'SUPABASE_URL') - static final String? supabaseUrl = _Env.supabaseUrl; - - @EnviedField(varName: 'SUPABASE_API_KEY') - static final String? supabaseAnonKey = _Env.supabaseAnonKey; - @EnviedField(varName: 'SPOTIFY_SECRETS') static final String rawSpotifySecrets = _Env.rawSpotifySecrets; @@ -30,5 +25,8 @@ abstract class Env { @EnviedField(varName: 'ENABLE_UPDATE_CHECK', defaultValue: "1") static final String _enableUpdateChecker = _Env._enableUpdateChecker; - static bool get enableUpdateChecker => _enableUpdateChecker == "1"; + static bool get enableUpdateChecker => + DesktopTools.platform.isFlatpak || _enableUpdateChecker == "1"; + + static String discordAppId = "1176718791388975124"; } diff --git a/lib/collections/intents.dart b/lib/collections/intents.dart index 8c7ea73b..abccb3ad 100644 --- a/lib/collections/intents.dart +++ b/lib/collections/intents.dart @@ -1,6 +1,7 @@ +import 'dart:io'; + import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:spotube/components/player/player_controls.dart'; @@ -115,7 +116,7 @@ class CloseAppAction extends Action { @override invoke(intent) { if (kIsDesktop) { - DesktopTools.window.close(); + exit(0); } else { SystemNavigator.pop(); } diff --git a/lib/collections/language_codes.dart b/lib/collections/language_codes.dart index 2a742f46..d89e1a2a 100644 --- a/lib/collections/language_codes.dart +++ b/lib/collections/language_codes.dart @@ -660,10 +660,10 @@ abstract class LanguageLocals { // name: "Tonga (Tonga Islands)", // nativeName: "faka Tonga", // ), - // "tr": const ISOLanguageName( - // name: "Turkish", - // nativeName: "Türkçe", - // ), + "tr": const ISOLanguageName( + name: "Turkish", + nativeName: "Türkçe", + ), // "ts": const ISOLanguageName( // name: "Tsonga", // nativeName: "Xitsonga", diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 81ebb3e6..82597ddb 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -3,29 +3,29 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:spotify/spotify.dart' hide Search; +import 'package:spotube/pages/album/album.dart'; import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/lastfm_login/lastfm_login.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; +import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart'; import 'package:spotube/pages/lyrics/mini_lyrics.dart'; +import 'package:spotube/pages/playlist/liked_playlist.dart'; +import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/settings/blacklist.dart'; import 'package:spotube/pages/settings/about.dart'; import 'package:spotube/pages/settings/logs.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/components/shared/spotube_page_route.dart'; -import 'package:spotube/pages/album/album.dart'; import 'package:spotube/pages/artist/artist.dart'; import 'package:spotube/pages/library/library.dart'; import 'package:spotube/pages/desktop_login/login_tutorial.dart'; import 'package:spotube/pages/desktop_login/desktop_login.dart'; import 'package:spotube/pages/lyrics/lyrics.dart'; -import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/pages/root/root_app.dart'; import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/pages/mobile_login/mobile_login.dart'; -import '../pages/library/playlist_generate/playlist_generate_result.dart'; - final rootNavigatorKey = Catcher2.navigatorKey; final shellRouteNavigatorKey = GlobalKey(); final router = GoRouter( @@ -104,7 +104,9 @@ final router = GoRouter( path: "/album/:id", pageBuilder: (context, state) { assert(state.extra is AlbumSimple); - return SpotubePage(child: AlbumPage(state.extra as AlbumSimple)); + return SpotubePage( + child: AlbumPage(album: state.extra as AlbumSimple), + ); }, ), GoRoute( @@ -119,7 +121,9 @@ final router = GoRouter( pageBuilder: (context, state) { assert(state.extra is PlaylistSimple); return SpotubePage( - child: PlaylistView(state.extra as PlaylistSimple), + child: state.pathParameters["id"] == "user-liked-tracks" + ? LikedPlaylistPage(playlist: state.extra as PlaylistSimple) + : PlaylistPage(playlist: state.extra as PlaylistSimple), ); }, ), diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 5c769498..d00775c7 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -40,6 +40,7 @@ abstract class SpotubeIcons { static const trash = FeatherIcons.trash2; static const clock = FeatherIcons.clock; static const lyrics = Icons.lyrics_rounded; + static const lyricsOff = Icons.lyrics_outlined; static const logout = FeatherIcons.logOut; static const login = FeatherIcons.logIn; static const dashboard = FeatherIcons.grid; @@ -106,4 +107,5 @@ abstract class SpotubeIcons { static const eye = FeatherIcons.eye; static const noEye = FeatherIcons.eyeOff; static const normalize = FeatherIcons.barChart2; + static const wikipedia = SimpleIcons.wikipedia; } diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index d8f8d85b..c7ae2f9a 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -4,10 +4,12 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; -import 'package:spotube/hooks/use_breakpoint_value.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/infinite_query.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/queries/album.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -16,7 +18,7 @@ extension FormattedAlbumType on AlbumType { } class AlbumCard extends HookConsumerWidget { - final Album album; + final AlbumSimple album; const AlbumCard( this.album, { Key? key, @@ -28,30 +30,54 @@ class AlbumCard extends HookConsumerWidget { final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final queryClient = useQueryClient(); + bool isPlaylistPlaying = useMemoized( () => playlist.containsCollection(album.id!), [playlist, album.id], ); - final marginH = useBreakpointValue( - xs: 10, - sm: 10, - md: 15, - others: 20, - ); - final updating = useState(false); final spotify = ref.watch(spotifyProvider); + final scaffoldMessenger = ScaffoldMessenger.maybeOf(context); + + Future> fetchAllTrack() async { + if (album.tracks != null && album.tracks!.isNotEmpty) { + return album.tracks! + .map((track) => + TypeConversionUtils.simpleTrack_X_Track(track, album)) + .toList(); + } + final job = AlbumQueries.tracksOfJob(album.id!); + + final query = queryClient.createInfiniteQuery( + job.queryKey, + (page) => job.task(page, (spotify: spotify, album: album)), + initialPage: 0, + nextPage: job.nextPage, + ); + + return await query.fetchAllTracks( + getAllTracks: () async { + final res = await spotify.albums.tracks(album.id!).all(); + return res + .map((e) => TypeConversionUtils.simpleTrack_X_Track(e, album)) + .toList(); + }, + ); + } + return PlaybuttonCard( imageUrl: TypeConversionUtils.image_X_UrlString( album.images, placeholder: ImagePlaceholder.collection, ), - margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()), + margin: const EdgeInsets.symmetric(horizontal: 10), isPlaying: isPlaylistPlaying, - isLoading: isPlaylistPlaying && playlist.isFetching == true, + isLoading: (isPlaylistPlaying && playlist.isFetching == true) || + updating.value, title: album.name!, description: "${album.albumType?.formatted} • ${TypeConversionUtils.artists_X_String(album.artists ?? [])}", @@ -61,20 +87,15 @@ class AlbumCard extends HookConsumerWidget { onPlaybuttonPressed: () async { updating.value = true; try { - if (isPlaylistPlaying && playing) { - return audioPlayer.pause(); - } else if (isPlaylistPlaying && !playing) { - return audioPlayer.resume(); + if (isPlaylistPlaying) { + return playing ? audioPlayer.pause() : audioPlayer.resume(); } - await playlistNotifier.load( - album.tracks - ?.map((e) => - TypeConversionUtils.simpleTrack_X_Track(e, album)) - .toList() ?? - [], - autoPlay: true, - ); + final fetchedTracks = await fetchAllTrack(); + + if (fetchedTracks.isEmpty) return; + + await playlistNotifier.load(fetchedTracks, autoPlay: true); playlistNotifier.addCollection(album.id!); } finally { updating.value = false; @@ -87,28 +108,16 @@ class AlbumCard extends HookConsumerWidget { updating.value = true; try { - final fetchedTracks = - await queryClient.fetchQuery, SpotifyApi>( - "album-tracks/${album.id}", - () { - return spotify.albums - .getTracks(album.id!) - .all() - .then((value) => value.toList()); - }, - ).then( - (tracks) => tracks - ?.map( - (e) => TypeConversionUtils.simpleTrack_X_Track(e, album)) - .toList(), - ); + final fetchedTracks = await fetchAllTrack(); - if (fetchedTracks == null || fetchedTracks.isEmpty) return; + if (fetchedTracks.isEmpty) return; playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addCollection(album.id!); if (context.mounted) { final snackbar = SnackBar( - content: Text("Added ${album.tracks?.length} tracks to queue"), + content: Text( + context.l10n.added_to_queue(fetchedTracks.length), + ), action: SnackBarAction( label: "Undo", onPressed: () { @@ -117,7 +126,8 @@ class AlbumCard extends HookConsumerWidget { }, ), ); - ScaffoldMessenger.maybeOf(context)?.showSnackBar(snackbar); + + scaffoldMessenger?.showSnackBar(snackbar); } } finally { updating.value = false; diff --git a/lib/components/artist/artist_album_list.dart b/lib/components/artist/artist_album_list.dart index 8fa9be87..5114170c 100644 --- a/lib/components/artist/artist_album_list.dart +++ b/lib/components/artist/artist_album_list.dart @@ -1,11 +1,9 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/album/album_card.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; -import 'package:spotube/components/shared/waypoint.dart'; +import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/services/queries/queries.dart'; @@ -20,7 +18,6 @@ class ArtistAlbumList extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final scrollController = useScrollController(); final albumsQuery = useQueries.artist.albumsOf(ref, artistId); final albums = useMemoized(() { @@ -29,40 +26,17 @@ class ArtistAlbumList extends HookConsumerWidget { .toList(); }, [albumsQuery.pages]); - final hasNextPage = albumsQuery.pages.isEmpty - ? false - : (albumsQuery.pages.last.items?.length ?? 0) == 5; + final theme = Theme.of(context); - return Column( - children: [ - ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - interactive: false, - controller: scrollController, - child: Waypoint( - controller: scrollController, - onTouchEdge: albumsQuery.fetchNext, - child: SingleChildScrollView( - controller: scrollController, - scrollDirection: Axis.horizontal, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...albums.map((album) => AlbumCard(album)), - if (hasNextPage) const ShimmerPlaybuttonCard(count: 1), - ], - ), - ), - ), - ), - ), - ], + return HorizontalPlaybuttonCardView( + isLoadingNextPage: albumsQuery.isLoadingNextPage, + hasNextPage: albumsQuery.hasNextPage, + items: albums, + onFetchMore: albumsQuery.fetchNext, + title: Text( + context.l10n.albums, + style: theme.textTheme.headlineSmall, + ), ); } } diff --git a/lib/components/artist/artist_card.dart b/lib/components/artist/artist_card.dart index 993e9f6a..434b90ad 100644 --- a/lib/components/artist/artist_card.dart +++ b/lib/components/artist/artist_card.dart @@ -5,8 +5,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_breakpoint_value.dart'; -import 'package:spotube/hooks/use_brightness_value.dart'; +import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; +import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; diff --git a/lib/components/desktop_login/login_form.dart b/lib/components/desktop_login/login_form.dart index b9783f87..f2b183f4 100644 --- a/lib/components/desktop_login/login_form.dart +++ b/lib/components/desktop_login/login_form.dart @@ -63,7 +63,7 @@ class TokenLoginForm extends HookConsumerWidget { return; } final cookieHeader = - "sp_dc=${directCodeController.text}; sp_key=${keyCodeController.text}"; + "sp_dc=${directCodeController.text.trim()}; sp_key=${keyCodeController.text.trim()}"; authenticationNotifier.setCredentials( await AuthenticationCredentials.fromCookie( diff --git a/lib/components/genre/category_card.dart b/lib/components/genre/category_card.dart index 42654ed9..7f580157 100644 --- a/lib/components/genre/category_card.dart +++ b/lib/components/genre/category_card.dart @@ -1,13 +1,10 @@ -import 'dart:ui'; - +import 'package:collection/collection.dart'; import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/playlist/playlist_card.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; -import 'package:spotube/components/shared/waypoint.dart'; +import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/services/queries/queries.dart'; @@ -22,57 +19,33 @@ class CategoryCard extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final scrollController = useScrollController(); final playlistQuery = useQueries.category.playlistsOf( ref, category.id!, ); - if (playlistQuery.hasErrors && !playlistQuery.hasPageData) { + final playlists = useMemoized( + () => playlistQuery.pages.expand( + (page) { + return page.items?.whereNotNull() ?? + const Iterable.empty(); + }, + ).toList(), + [playlistQuery.pages], + ); + + if (playlistQuery.hasErrors && + !playlistQuery.hasPageData && + !playlistQuery.isLoadingNextPage) { return const SizedBox.shrink(); } - final playlists = playlistQuery.pages.expand( - (page) { - return page.items?.where((i) => i != null) ?? const Iterable.empty(); - }, - ).toList(); - return Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - category.name!, - style: Theme.of(context).textTheme.titleMedium, - ), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Waypoint( - controller: scrollController, - onTouchEdge: playlistQuery.fetchNext, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: scrollController, - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...playlists.map((playlist) => PlaylistCard(playlist)), - if (playlistQuery.hasNextPage) - const ShimmerPlaybuttonCard(count: 1), - ], - ), - ), - ), - ), - ], - ), + + return HorizontalPlaybuttonCardView( + title: Text(category.name!), + isLoadingNextPage: playlistQuery.isLoadingNextPage, + hasNextPage: playlistQuery.hasNextPage, + items: playlists, + onFetchMore: playlistQuery.fetchNext, ); } } diff --git a/lib/components/library/user_downloads/download_item.dart b/lib/components/library/user_downloads/download_item.dart index ae8a2513..10dec410 100644 --- a/lib/components/library/user_downloads/download_item.dart +++ b/lib/components/library/user_downloads/download_item.dart @@ -5,9 +5,9 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/services/download_manager/download_status.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class DownloadItem extends HookConsumerWidget { @@ -24,25 +24,25 @@ class DownloadItem extends HookConsumerWidget { final taskStatus = useState(null); useEffect(() { - if (track is! SpotubeTrack) return null; - final notifier = downloadManager.getStatusNotifier(track as SpotubeTrack); + if (track is! SourcedTrack) return null; + final notifier = downloadManager.getStatusNotifier(track as SourcedTrack); taskStatus.value = notifier?.value; - listener() { + + void listener() { taskStatus.value = notifier?.value; } - downloadManager - .getStatusNotifier(track as SpotubeTrack) - ?.addListener(listener); + notifier?.addListener(listener); return () { - downloadManager - .getStatusNotifier(track as SpotubeTrack) - ?.removeListener(listener); + notifier?.removeListener(listener); }; }, [track]); + final isQueryingSourceInfo = + taskStatus.value == null || track is! SourcedTrack; + return ListTile( leading: Padding( padding: const EdgeInsets.symmetric(horizontal: 5), @@ -63,7 +63,7 @@ class DownloadItem extends HookConsumerWidget { track.artists ?? [], mainAxisAlignment: WrapAlignment.start, ), - trailing: taskStatus.value == null || track is! SpotubeTrack + trailing: isQueryingSourceInfo ? Text( context.l10n.querying_info, style: Theme.of(context).textTheme.labelMedium, @@ -72,7 +72,7 @@ class DownloadItem extends HookConsumerWidget { DownloadStatus.downloading => HookBuilder(builder: (context) { final taskProgress = useListenable(useMemoized( () => downloadManager - .getProgressNotifier(track as SpotubeTrack), + .getProgressNotifier(track as SourcedTrack), [track], )); return SizedBox( @@ -86,13 +86,13 @@ class DownloadItem extends HookConsumerWidget { IconButton( icon: const Icon(SpotubeIcons.pause), onPressed: () { - downloadManager.pause(track as SpotubeTrack); + downloadManager.pause(track as SourcedTrack); }), const SizedBox(width: 10), IconButton( icon: const Icon(SpotubeIcons.close), onPressed: () { - downloadManager.cancel(track as SpotubeTrack); + downloadManager.cancel(track as SourcedTrack); }), ], ), @@ -104,13 +104,13 @@ class DownloadItem extends HookConsumerWidget { IconButton( icon: const Icon(SpotubeIcons.play), onPressed: () { - downloadManager.resume(track as SpotubeTrack); + downloadManager.resume(track as SourcedTrack); }), const SizedBox(width: 10), IconButton( icon: const Icon(SpotubeIcons.close), onPressed: () { - downloadManager.cancel(track as SpotubeTrack); + downloadManager.cancel(track as SourcedTrack); }) ], ), @@ -126,7 +126,7 @@ class DownloadItem extends HookConsumerWidget { IconButton( icon: const Icon(SpotubeIcons.refresh), onPressed: () { - downloadManager.retry(track as SpotubeTrack); + downloadManager.retry(track as SourcedTrack); }, ), ], @@ -137,7 +137,7 @@ class DownloadItem extends HookConsumerWidget { DownloadStatus.queued => IconButton( icon: const Icon(SpotubeIcons.close), onPressed: () { - downloadManager.removeFromQueue(track as SpotubeTrack); + downloadManager.removeFromQueue(track as SourcedTrack); }), }, ); diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index 50ae64be..cc8b10cf 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:catcher_2/catcher_2.dart'; -import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -12,7 +11,6 @@ import 'package:metadata_god/metadata_god.dart'; import 'package:mime/mime.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:permission_handler/permission_handler.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -20,17 +18,14 @@ import 'package:spotube/components/shared/expandable_search/expandable_search.da import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart'; import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; -import 'package:spotube/components/shared/track_table/track_tile.dart'; +import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_async_effect.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/utils/platform.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; -import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' - show FfiException; +import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; const supportedAudioTypes = [ "audio/webm", @@ -162,39 +157,13 @@ class UserLocalTracks extends HookConsumerWidget { final trackSnapshot = ref.watch(localTracksProvider); final isPlaylistPlaying = playlist.containsTracks(trackSnapshot.value ?? []); - final isMounted = useIsMounted(); final searchController = useTextEditingController(); useValueListenable(searchController); final searchFocus = useFocusNode(); final isFiltering = useState(false); - useAsyncEffect( - () async { - if (!kIsMobile) return; - - final androidInfo = await DeviceInfoPlugin().androidInfo; - - final hasNoStoragePerm = androidInfo.version.sdkInt < 33 && - !await Permission.storage.isGranted && - !await Permission.storage.isLimited; - - final hasNoAudioPerm = androidInfo.version.sdkInt >= 33 && - !await Permission.audio.isGranted && - !await Permission.audio.isLimited; - - if (hasNoStoragePerm) { - await Permission.storage.request(); - if (isMounted()) ref.refresh(localTracksProvider); - } - if (hasNoAudioPerm) { - await Permission.audio.request(); - if (isMounted()) ref.refresh(localTracksProvider); - } - }, - null, - [], - ); + final controller = useScrollController(); return Column( children: [ @@ -230,7 +199,8 @@ class UserLocalTracks extends HookConsumerWidget { ), const Spacer(), ExpandableSearchButton( - isFiltering: isFiltering, + isFiltering: isFiltering.value, + onPressed: (value) => isFiltering.value = value, searchFocus: searchFocus, ), const SizedBox(width: 10), @@ -253,7 +223,8 @@ class UserLocalTracks extends HookConsumerWidget { ExpandableSearchField( searchController: searchController, searchFocus: searchFocus, - isFiltering: isFiltering, + isFiltering: isFiltering.value, + onChangeFiltering: (value) => isFiltering.value = value, ), trackSnapshot.when( data: (tracks) { @@ -289,7 +260,9 @@ class UserLocalTracks extends HookConsumerWidget { ref.refresh(localTracksProvider); }, child: InterScrollbar( + controller: controller, child: ListView.builder( + controller: controller, physics: const AlwaysScrollableScrollPhysics(), itemCount: filteredTracks.length, itemBuilder: (context, index) { @@ -313,7 +286,7 @@ class UserLocalTracks extends HookConsumerWidget { ); }, loading: () => - const Expanded(child: ShimmerTrackTile(noSliver: true)), + const Expanded(child: ShimmerTrackTileGroup(noSliver: true)), error: (error, stackTrace) => Text(error.toString() + stackTrace.toString()), ) diff --git a/lib/components/library/user_playlists.dart b/lib/components/library/user_playlists.dart index 8ed3e73d..f7736ca7 100644 --- a/lib/components/library/user_playlists.dart +++ b/lib/components/library/user_playlists.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' hide Image; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:collection/collection.dart'; @@ -13,6 +14,7 @@ import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart' import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/playlist/playlist_card.dart'; 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'; @@ -80,64 +82,73 @@ class UserPlaylists extends HookConsumerWidget { return RefreshIndicator( onRefresh: playlistsQuery.refresh, - child: InterScrollbar( - controller: controller, - child: SingleChildScrollView( + child: SafeArea( + child: InterScrollbar( controller: controller, - physics: const AlwaysScrollableScrollPhysics(), - child: Waypoint( + child: CustomScrollView( controller: controller, - onTouchEdge: () { - if (playlistsQuery.hasNextPage) { - playlistsQuery.fetchNext(); - } - }, - child: SafeArea( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(10), - child: SearchBar( - onChanged: (value) => searchText.value = value, - hintText: context.l10n.filter_playlists, - leading: const Icon(SpotubeIcons.filter), + slivers: [ + SliverToBoxAdapter( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(10), + child: SearchBar( + onChanged: (value) => searchText.value = value, + hintText: context.l10n.filter_playlists, + leading: const Icon(SpotubeIcons.filter), + ), ), - ), - AnimatedCrossFade( - duration: const Duration(milliseconds: 300), - crossFadeState: !playlistsQuery.hasPageData && - !playlistsQuery.hasPageError && - !playlistsQuery.isLoadingNextPage - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - firstChild: - const Center(child: ShimmerPlaybuttonCard(count: 7)), - secondChild: Wrap( - runSpacing: 10, - alignment: WrapAlignment.center, + Row( children: [ - Row( - children: [ - const SizedBox(width: 10), - const PlaylistCreateDialogButton(), - const SizedBox(width: 10), - ElevatedButton.icon( - icon: const Icon(SpotubeIcons.magic), - label: Text(context.l10n.generate_playlist), - onPressed: () { - GoRouter.of(context).push("/library/generate"); - }, - ), - const SizedBox(width: 10), - ], + const SizedBox(width: 10), + const PlaylistCreateDialogButton(), + const SizedBox(width: 10), + ElevatedButton.icon( + icon: const Icon(SpotubeIcons.magic), + label: Text(context.l10n.generate_playlist), + onPressed: () { + GoRouter.of(context).push("/library/generate"); + }, ), - ...playlists.map((playlist) => PlaylistCard(playlist)) + const SizedBox(width: 10), ], ), - ), - ], + ], + ), ), - ), + const SliverToBoxAdapter( + child: SizedBox(height: 10), + ), + SliverLayoutBuilder(builder: (context, constrains) { + return SliverGrid.builder( + itemCount: playlists.length + 1, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: constrains.smAndDown ? 225 : 250, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) { + if (index == playlists.length) { + if (!playlistsQuery.hasNextPage) { + return const SizedBox.shrink(); + } + + return Waypoint( + controller: controller, + isGrid: true, + onTouchEdge: playlistsQuery.fetchNext, + child: const ShimmerPlaybuttonCard(count: 1), + ); + } + + return PlaylistCard(playlists[index]); + }, + ); + }) + ], ), ), ), diff --git a/lib/hooks/use_synced_lyrics.dart b/lib/components/lyrics/use_synced_lyrics.dart similarity index 100% rename from lib/hooks/use_synced_lyrics.dart rename to lib/components/lyrics/use_synced_lyrics.dart diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 811d24c5..889b7c5c 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -18,8 +18,8 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/panels/sliding_up_panel.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_custom_status_bar_color.dart'; -import 'package:spotube/hooks/use_palette_color.dart'; +import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart'; +import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/provider/authentication_provider.dart'; diff --git a/lib/components/player/player_actions.dart b/lib/components/player/player_actions.dart index b3a1e340..7a248aa5 100644 --- a/lib/components/player/player_actions.dart +++ b/lib/components/player/player_actions.dart @@ -5,10 +5,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart' hide Offset; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/player/player_queue.dart'; import 'package:spotube/components/player/sibling_tracks_sheet.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/heart_button.dart'; +import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/models/local_track.dart'; @@ -35,6 +35,7 @@ class PlayerActions extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { + final mediaQuery = MediaQuery.of(context); final playlist = ref.watch(ProxyPlaylistNotifier.provider); final isLocalTrack = playlist.activeTrack is LocalTrack; ref.watch(downloadManagerProvider); @@ -86,23 +87,7 @@ class PlayerActions extends HookConsumerWidget { tooltip: context.l10n.queue, onPressed: playlist.activeTrack != null ? () { - showModalBottomSheet( - context: context, - isDismissible: true, - enableDrag: true, - isScrollControlled: true, - backgroundColor: Colors.black12, - barrierColor: Colors.black12, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * .7, - ), - builder: (context) { - return PlayerQueue(floating: floatingQueue); - }, - ); + Scaffold.of(context).openEndDrawer(); } : null, ), @@ -119,6 +104,7 @@ class PlayerActions extends HookConsumerWidget { isScrollControlled: true, backgroundColor: Colors.black12, barrierColor: Colors.black12, + elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), diff --git a/lib/components/player/player_controls.dart b/lib/components/player/player_controls.dart index 07a6b7ba..1000af18 100644 --- a/lib/components/player/player_controls.dart +++ b/lib/components/player/player_controls.dart @@ -8,7 +8,7 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/intents.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; -import 'package:spotube/hooks/use_progress.dart'; +import 'package:spotube/components/player/use_progress.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; diff --git a/lib/components/player/player_overlay.dart b/lib/components/player/player_overlay.dart index 354d1a36..4869a0fa 100644 --- a/lib/components/player/player_overlay.dart +++ b/lib/components/player/player_overlay.dart @@ -9,7 +9,7 @@ import 'package:spotube/components/root/spotube_navigation_bar.dart'; import 'package:spotube/components/shared/panels/sliding_up_panel.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/intents.dart'; -import 'package:spotube/hooks/use_progress.dart'; +import 'package:spotube/components/player/use_progress.dart'; import 'package:spotube/components/player/player.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 725af22b..8142740c 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -11,10 +11,10 @@ import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/track_table/track_tile.dart'; +import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_auto_scroll_controller.dart'; +import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -36,12 +36,15 @@ class PlayerQueue extends HookConsumerWidget { final tracks = playlist.tracks; final borderRadius = floating - ? BorderRadius.circular(10) + ? const BorderRadius.only( + topLeft: Radius.circular(10), + ) : const BorderRadius.only( topLeft: Radius.circular(10), topRight: Radius.circular(10), ); final theme = Theme.of(context); + final mediaQuery = MediaQuery.of(context); final headlineColor = theme.textTheme.headlineSmall?.color; final filteredTracks = useMemoized( @@ -80,47 +83,49 @@ class PlayerQueue extends HookConsumerWidget { return const NotFound(vertical: true); } - return BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 12.0, - sigmaY: 12.0, - ), - child: Container( - margin: EdgeInsets.all(floating ? 8.0 : 0), - padding: const EdgeInsets.only( - top: 5.0, + return ClipRRect( + borderRadius: borderRadius, + clipBehavior: Clip.hardEdge, + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 15, + sigmaY: 15, ), - decoration: BoxDecoration( - color: theme.scaffoldBackgroundColor.withOpacity(0.5), - borderRadius: borderRadius, - ), - child: CallbackShortcuts( - bindings: { - LogicalKeySet(LogicalKeyboardKey.escape): () { - if (!isSearching.value) { - Navigator.of(context).pop(); + 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 = ''; } - isSearching.value = false; - searchText.value = ''; - } - }, - child: LayoutBuilder(builder: (context, constraints) { - return Column( + }, + child: Column( children: [ - Container( - height: 5, - width: 100, - margin: const EdgeInsets.only(bottom: 5, top: 2), - decoration: BoxDecoration( - color: headlineColor, - borderRadius: BorderRadius.circular(20), + 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 (constraints.mdAndUp || !isSearching.value) ...[ + if (mediaQuery.mdAndUp || !isSearching.value) ...[ const SizedBox(width: 10), Text( context.l10n.tracks_in_queue(tracks.length), @@ -132,7 +137,7 @@ class PlayerQueue extends HookConsumerWidget { ), const Spacer(), ], - if (constraints.mdAndUp || isSearching.value) + if (mediaQuery.mdAndUp || isSearching.value) TextField( onChanged: (value) { searchText.value = value; @@ -140,7 +145,7 @@ class PlayerQueue extends HookConsumerWidget { decoration: InputDecoration( hintText: context.l10n.search, isDense: true, - prefixIcon: constraints.smAndDown + prefixIcon: mediaQuery.smAndDown ? IconButton( icon: const Icon( Icons.arrow_back_ios_new_outlined, @@ -157,8 +162,8 @@ class PlayerQueue extends HookConsumerWidget { : const Icon(SpotubeIcons.filter), constraints: BoxConstraints( maxHeight: 40, - maxWidth: constraints.smAndDown - ? constraints.maxWidth - 20 + maxWidth: mediaQuery.smAndDown + ? mediaQuery.size.width - 40 : 300, ), ), @@ -170,7 +175,7 @@ class PlayerQueue extends HookConsumerWidget { isSearching.value = !isSearching.value; }, ), - if (constraints.mdAndUp || !isSearching.value) ...[ + if (mediaQuery.mdAndUp || !isSearching.value) ...[ const SizedBox(width: 10), FilledButton( style: FilledButton.styleFrom( @@ -197,51 +202,50 @@ class PlayerQueue extends HookConsumerWidget { const SizedBox(height: 10), if (!isSearching.value && searchText.value.isEmpty) Flexible( - child: InterScrollbar( - controller: controller, - child: ReorderableListView.builder( - onReorder: (oldIndex, newIndex) { - playlistNotifier.moveTrack(oldIndex, newIndex); - }, - scrollController: controller, - itemCount: tracks.length, - shrinkWrap: true, - buildDefaultDragHandles: false, - itemBuilder: (context, i) { - final track = tracks.elementAt(i); - return AutoScrollTag( - key: ValueKey(i), - controller: controller, - index: i, - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8.0), - child: TrackTile( - index: i, - track: track, - onTap: () async { - if (playlist.activeTrack?.id == track.id) { - return; - } - await playlistNotifier.jumpToTrack(track); - }, - leadingActions: [ - ReorderableDragStartListener( - index: i, - child: const Icon(SpotubeIcons.dragHandle), - ), - ], - ), + child: ReorderableListView.builder( + onReorder: (oldIndex, newIndex) { + playlistNotifier.moveTrack(oldIndex, newIndex); + }, + scrollController: controller, + itemCount: tracks.length, + shrinkWrap: true, + buildDefaultDragHandles: false, + itemBuilder: (context, i) { + final track = tracks.elementAt(i); + return AutoScrollTag( + key: ValueKey(i), + controller: controller, + index: i, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8.0), + child: TrackTile( + index: i, + track: track, + onTap: () async { + if (playlist.activeTrack?.id == track.id) { + return; + } + await playlistNotifier.jumpToTrack(track); + }, + leadingActions: [ + ReorderableDragStartListener( + index: i, + child: const Icon(SpotubeIcons.dragHandle), + ), + ], ), - ); - }, - ), + ), + ); + }, ), ) else Flexible( child: InterScrollbar( + controller: controller, child: ListView.builder( + controller: controller, itemCount: filteredTracks.length, itemBuilder: (context, i) { final track = filteredTracks.elementAt(i); @@ -264,8 +268,8 @@ class PlayerQueue extends HookConsumerWidget { ), ), ], - ); - }), + ), + ), ), ), ); diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index 6587b8b3..cf1429b9 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -1,5 +1,6 @@ import 'dart:ui'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -11,13 +12,14 @@ import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; -import 'package:spotube/hooks/use_debounce.dart'; -import 'package:spotube/models/matched_track.dart'; -import 'package:spotube/models/spotube_track.dart'; +import 'package:spotube/hooks/utils/use_debounce.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/provider/youtube_provider.dart'; -import 'package:spotube/services/youtube/youtube.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/services/sourced_track/models/source_info.dart'; +import 'package:spotube/services/sourced_track/models/video_info.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -34,7 +36,6 @@ class SiblingTracksSheet extends HookConsumerWidget { final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final preferences = ref.watch(userPreferencesProvider); - final youtube = ref.watch(youtubeProvider); final isSearching = useState(false); final searchMode = useState(preferences.searchMode); @@ -56,20 +57,35 @@ class SiblingTracksSheet extends HookConsumerWidget { useValueListenable(searchController).text, ); + final controller = useScrollController(); + final searchRequest = useMemoized(() async { if (searchTerm.trim().isEmpty) { - return []; + return []; } - return youtube.search(searchTerm.trim()); + final results = await youtubeClient.search.search(searchTerm.trim()); + + return await Future.wait( + results.map(YoutubeVideoInfo.fromVideo).mapIndexed((i, video) async { + final siblingType = await YoutubeSourcedTrack.toSiblingType(i, video); + return siblingType.info; + }), + ); }, [ searchTerm, searchMode.value, ]); - final siblings = playlist.isFetching == false - ? (playlist.activeTrack as SpotubeTrack).siblings - : []; + final siblings = useMemoized( + () => playlist.isFetching == false + ? [ + (playlist.activeTrack as SourcedTrack).sourceInfo, + ...(playlist.activeTrack as SourcedTrack).siblings, + ] + : [], + [playlist.isFetching, playlist.activeTrack], + ); final borderRadius = floating ? BorderRadius.circular(10) @@ -79,158 +95,166 @@ class SiblingTracksSheet extends HookConsumerWidget { ); useEffect(() { - if (playlist.activeTrack is SpotubeTrack && - (playlist.activeTrack as SpotubeTrack).siblings.isEmpty) { + if (playlist.activeTrack is SourcedTrack && + (playlist.activeTrack as SourcedTrack).siblings.isEmpty) { playlistNotifier.populateSibling(); } return null; }, [playlist.activeTrack]); - final itemBuilder = useCallback((YoutubeVideoInfo video) { - return ListTile( - title: Text(video.title), - leading: Padding( - padding: const EdgeInsets.all(8.0), - child: UniversalImage( - path: video.thumbnailUrl, - height: 60, - width: 60, + final itemBuilder = useCallback( + (SourceInfo sourceInfo) { + return ListTile( + title: Text(sourceInfo.title), + leading: Padding( + padding: const EdgeInsets.all(8.0), + child: UniversalImage( + path: sourceInfo.thumbnail, + height: 60, + width: 60, + ), ), - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - ), - trailing: Text(video.duration.toHumanReadableString()), - subtitle: Text(video.channelName), - enabled: playlist.isFetching != true, - selected: playlist.isFetching != true && - video.id == (playlist.activeTrack as SpotubeTrack).ytTrack.id, - selectedTileColor: theme.popupMenuTheme.color, - onTap: () { - if (playlist.isFetching == false && - video.id != (playlist.activeTrack as SpotubeTrack).ytTrack.id) { - playlistNotifier.swapSibling(video); - Navigator.of(context).pop(); - } - }, - ); - }, [ - playlist.isFetching, - playlist.activeTrack, - siblings, - ]); + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + trailing: Text(sourceInfo.duration.toHumanReadableString()), + subtitle: Text(sourceInfo.artist), + enabled: playlist.isFetching != true, + selected: playlist.isFetching != true && + sourceInfo.id == + (playlist.activeTrack as SourcedTrack).sourceInfo.id, + selectedTileColor: theme.popupMenuTheme.color, + onTap: () { + if (playlist.isFetching == false && + sourceInfo.id != + (playlist.activeTrack as SourcedTrack).sourceInfo.id) { + playlistNotifier.swapSibling(sourceInfo); + Navigator.of(context).pop(); + } + }, + ); + }, + [playlist.isFetching, playlist.activeTrack, siblings], + ); var mediaQuery = MediaQuery.of(context); return SafeArea( - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 12.0, - sigmaY: 12.0, - ), - child: AnimatedSize( - duration: const Duration(milliseconds: 300), - child: Container( - height: isSearching.value && mediaQuery.smAndDown - ? mediaQuery.size.height - : mediaQuery.size.height * .6, - margin: const EdgeInsets.all(8.0), - decoration: BoxDecoration( - borderRadius: borderRadius, - color: theme.scaffoldBackgroundColor.withOpacity(.3), - ), - child: Scaffold( - backgroundColor: Colors.transparent, - appBar: AppBar( - centerTitle: true, - title: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: !isSearching.value - ? Text( - context.l10n.alternative_track_sources, - style: theme.textTheme.headlineSmall, - ) - : TextField( - autofocus: true, - controller: searchController, - decoration: InputDecoration( - hintText: context.l10n.search, - hintStyle: theme.textTheme.headlineSmall, - border: InputBorder.none, - ), - style: theme.textTheme.headlineSmall, - ), - ), - automaticallyImplyLeading: false, + child: ClipRRect( + borderRadius: borderRadius, + clipBehavior: Clip.hardEdge, + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 12.0, + sigmaY: 12.0, + ), + child: AnimatedSize( + duration: const Duration(milliseconds: 300), + child: Container( + height: isSearching.value && mediaQuery.smAndDown + ? mediaQuery.size.height - 50 + : mediaQuery.size.height * .6, + decoration: BoxDecoration( + borderRadius: borderRadius, + color: theme.colorScheme.surfaceVariant.withOpacity(.5), + ), + child: Scaffold( backgroundColor: Colors.transparent, - actions: [ - if (!isSearching.value) - IconButton( - icon: const Icon(SpotubeIcons.search, size: 18), - onPressed: () { - isSearching.value = true; - }, - ) - else ...[ - if (preferences.youtubeApiType == YoutubeApiType.piped) - PopupMenuButton( - icon: const Icon(SpotubeIcons.filter, size: 18), - onSelected: (SearchMode mode) { - searchMode.value = mode; + appBar: AppBar( + centerTitle: true, + title: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: !isSearching.value + ? Text( + context.l10n.alternative_track_sources, + style: theme.textTheme.headlineSmall, + ) + : TextField( + autofocus: true, + controller: searchController, + decoration: InputDecoration( + hintText: context.l10n.search, + hintStyle: theme.textTheme.headlineSmall, + border: InputBorder.none, + ), + style: theme.textTheme.headlineSmall, + ), + ), + automaticallyImplyLeading: false, + backgroundColor: Colors.transparent, + actions: [ + if (!isSearching.value) + IconButton( + icon: const Icon(SpotubeIcons.search, size: 18), + onPressed: () { + isSearching.value = true; + }, + ) + else ...[ + if (preferences.audioSource == AudioSource.piped) + PopupMenuButton( + icon: const Icon(SpotubeIcons.filter, size: 18), + onSelected: (SearchMode mode) { + searchMode.value = mode; + }, + initialValue: searchMode.value, + itemBuilder: (context) => SearchMode.values + .map( + (e) => PopupMenuItem( + value: e, + child: Text(e.label), + ), + ) + .toList(), + ), + IconButton( + icon: const Icon(SpotubeIcons.close, size: 18), + onPressed: () { + isSearching.value = false; }, - initialValue: searchMode.value, - itemBuilder: (context) => SearchMode.values - .map( - (e) => PopupMenuItem( - value: e, - child: Text(e.label), - ), - ) - .toList(), ), - IconButton( - icon: const Icon(SpotubeIcons.close, size: 18), - onPressed: () { - isSearching.value = false; + ] + ], + ), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: InterScrollbar( + controller: controller, + child: switch (isSearching.value) { + false => ListView.builder( + controller: controller, + itemCount: siblings.length, + itemBuilder: (context, index) => + itemBuilder(siblings[index]), + ), + true => FutureBuilder( + future: searchRequest, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text(snapshot.error.toString()), + ); + } else if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator()); + } + + return InterScrollbar( + controller: controller, + child: ListView.builder( + controller: controller, + itemCount: snapshot.data!.length, + itemBuilder: (context, index) => + itemBuilder(snapshot.data![index]), + ), + ); + }, + ), }, ), - ] - ], - ), - body: Padding( - padding: const EdgeInsets.all(8.0), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - transitionBuilder: (child, animation) => - FadeTransition(opacity: animation, child: child), - child: InterScrollbar( - child: switch (isSearching.value) { - false => ListView.builder( - itemCount: siblings.length, - itemBuilder: (context, index) => - itemBuilder(siblings[index]), - ), - true => FutureBuilder( - future: searchRequest, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Center( - child: Text(snapshot.error.toString()), - ); - } else if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator()); - } - - return InterScrollbar( - child: ListView.builder( - itemCount: snapshot.data!.length, - itemBuilder: (context, index) => - itemBuilder(snapshot.data![index]), - ), - ); - }, - ), - }, ), ), ), diff --git a/lib/hooks/use_progress.dart b/lib/components/player/use_progress.dart similarity index 100% rename from lib/hooks/use_progress.dart rename to lib/components/player/use_progress.dart diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index 0438e559..f429a0ab 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; +import 'package:spotube/extensions/infinite_query.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -23,7 +24,7 @@ class PlaylistCard extends HookConsumerWidget { final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; - final queryBowl = QueryClient.of(context); + final queryClient = QueryClient.of(context); final tracks = useState?>(null); bool isPlaylistPlaying = useMemoized( () => playlistQueue.containsCollection(playlist.id!), @@ -34,6 +35,31 @@ class PlaylistCard extends HookConsumerWidget { final spotify = ref.watch(spotifyProvider); final me = useQueries.user.me(ref); + Future> fetchAllTracks() async { + if (playlist.id == 'user-liked-tracks') { + return await queryClient.fetchQuery( + "user-liked-tracks", + () => useQueries.playlist.likedTracks(spotify), + ) ?? + []; + } + + final query = queryClient.createInfiniteQuery, dynamic, int>( + "playlist-tracks/${playlist.id}", + (page) => useQueries.playlist.tracksOf(page, spotify, playlist.id!), + initialPage: 0, + nextPage: useQueries.playlist.tracksOfQueryNextPage, + ); + + return await query.fetchAllTracks( + getAllTracks: () async { + final res = + await spotify.playlists.getTracksByPlaylistId(playlist.id!).all(); + return res.toList(); + }, + ); + } + return PlaybuttonCard( margin: const EdgeInsets.symmetric(horizontal: 10), title: playlist.name!, @@ -62,18 +88,7 @@ class PlaylistCard extends HookConsumerWidget { return audioPlayer.resume(); } - List fetchedTracks = playlist.id == 'user-liked-tracks' - ? await queryBowl.fetchQuery( - "user-liked-tracks", - () => useQueries.playlist.likedTracks(spotify, ref), - ) ?? - [] - : await queryBowl.fetchQuery( - "playlist-tracks/${playlist.id}", - () => useQueries.playlist - .tracksOf(playlist.id!, spotify, ref), - ) ?? - []; + List fetchedTracks = await fetchAllTracks(); if (fetchedTracks.isEmpty) return; @@ -90,11 +105,8 @@ class PlaylistCard extends HookConsumerWidget { updating.value = true; try { if (isPlaylistPlaying) return; - List fetchedTracks = await queryBowl.fetchQuery( - "playlist-tracks/${playlist.id}", - () => useQueries.playlist.tracksOf(playlist.id!, spotify, ref), - ) ?? - []; + + final fetchedTracks = await fetchAllTracks(); if (fetchedTracks.isEmpty) return; diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index 6d2e8319..617e760b 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -14,12 +14,13 @@ import 'package:spotube/components/player/player_controls.dart'; import 'package:spotube/components/player/volume_slider.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_brightness_value.dart'; +import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/models/logger.dart'; import 'package:flutter/material.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index 0dc8b5b4..ac5233ed 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -11,18 +11,19 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_brightness_value.dart'; -import 'package:spotube/hooks/use_sidebarx_controller.dart'; +import 'package:spotube/hooks/utils/use_brightness_value.dart'; +import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class Sidebar extends HookConsumerWidget { - final int selectedIndex; + final int? selectedIndex; final void Function(int) onSelectedIndexChanged; final Widget child; @@ -57,7 +58,7 @@ class Sidebar extends HookConsumerWidget { ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); final controller = useSidebarXController( - selectedIndex: selectedIndex, + selectedIndex: selectedIndex ?? 0, extended: mediaQuery.lgAndUp, ); @@ -75,17 +76,21 @@ class Sidebar extends HookConsumerWidget { ); useEffect(() { - if (controller.selectedIndex != selectedIndex) { - controller.selectIndex(selectedIndex); + if (controller.selectedIndex != selectedIndex && selectedIndex != null) { + controller.selectIndex(selectedIndex!); } return null; }, [selectedIndex]); useEffect(() { - controller.addListener(() { + void listener() { onSelectedIndexChanged(controller.selectedIndex); - }); - return null; + } + + controller.addListener(listener); + return () { + controller.removeListener(listener); + }; }, [controller]); useEffect(() { diff --git a/lib/components/root/spotube_navigation_bar.dart b/lib/components/root/spotube_navigation_bar.dart index 9cea5603..0853c60c 100644 --- a/lib/components/root/spotube_navigation_bar.dart +++ b/lib/components/root/spotube_navigation_bar.dart @@ -9,14 +9,15 @@ import 'package:spotube/collections/side_bar_tiles.dart'; import 'package:spotube/components/root/sidebar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_brightness_value.dart'; +import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; final navigationPanelHeight = StateProvider((ref) => 50); class SpotubeNavigationBar extends HookConsumerWidget { - final int selectedIndex; + final int? selectedIndex; final void Function(int) onSelectedIndexChanged; const SpotubeNavigationBar({ @@ -33,7 +34,7 @@ class SpotubeNavigationBar extends HookConsumerWidget { final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); - final insideSelectedIndex = useState(selectedIndex); + final insideSelectedIndex = useState(selectedIndex ?? 0); final buttonColor = useBrightnessValue( theme.colorScheme.inversePrimary, @@ -46,7 +47,9 @@ class SpotubeNavigationBar extends HookConsumerWidget { final panelHeight = ref.watch(navigationPanelHeight); useEffect(() { - insideSelectedIndex.value = selectedIndex; + if (selectedIndex != null) { + insideSelectedIndex.value = selectedIndex!; + } return null; }, [selectedIndex]); diff --git a/lib/components/settings/color_scheme_picker_dialog.dart b/lib/components/settings/color_scheme_picker_dialog.dart index 170bae94..e0c3d618 100644 --- a/lib/components/settings/color_scheme_picker_dialog.dart +++ b/lib/components/settings/color_scheme_picker_dialog.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:system_theme/system_theme.dart'; class SpotubeColor extends Color { @@ -49,6 +49,7 @@ class ColorSchemePickerDialog extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final preferences = ref.watch(userPreferencesProvider); + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); final scheme = preferences.accentColorScheme; final active = useState(colorsMap.firstWhere( (element) { @@ -57,7 +58,7 @@ class ColorSchemePickerDialog extends HookConsumerWidget { ).name); onOk() { - preferences.setAccentColorScheme( + preferencesNotifier.setAccentColorScheme( colorsMap.firstWhere( (element) { return element.name == active.value; diff --git a/lib/components/shared/dialogs/piped_down_dialog.dart b/lib/components/shared/dialogs/piped_down_dialog.dart index 03362ed4..6220adeb 100644 --- a/lib/components/shared/dialogs/piped_down_dialog.dart +++ b/lib/components/shared/dialogs/piped_down_dialog.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; class PipedDownDialog extends HookConsumerWidget { const PipedDownDialog({Key? key}) : super(key: key); diff --git a/lib/components/shared/dialogs/playlist_add_track_dialog.dart b/lib/components/shared/dialogs/playlist_add_track_dialog.dart index 29f64268..1c8e5aaa 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:async/async.dart'; import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -12,41 +11,34 @@ import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistAddTrackDialog extends HookConsumerWidget { + /// The id of the playlist this dialog was opened from + final String? openFromPlaylist; final List tracks; const PlaylistAddTrackDialog({ required this.tracks, + required this.openFromPlaylist, Key? key, }) : super(key: key); @override Widget build(BuildContext context, ref) { final spotify = ref.watch(spotifyProvider); - final userPlaylists = useQueries.playlist.ofMine(ref); - - useEffect(() { - final op = CancelableOperation.fromFuture( - () async { - while (userPlaylists.hasNextPage) { - await userPlaylists.fetchNext(); - } - }(), - ); - - return () { - op.cancel(); - }; - }, [userPlaylists.hasNextPage]); + final userPlaylists = useQueries.playlist.ofMineAll(ref); final me = useQueries.user.me(ref); final filteredPlaylists = useMemoized( - () => userPlaylists.pages - .expand((page) => page.items?.toList() ?? []) - .where( - (playlist) => - playlist.owner?.id != null && playlist.owner!.id == me.data?.id, - ), - [userPlaylists.pages, me.data?.id], + () => + userPlaylists.data + ?.where( + (playlist) => + playlist.owner?.id != null && + playlist.owner!.id == me.data?.id && + playlist.id != openFromPlaylist, + ) + .toList() ?? + [], + [userPlaylists.data, me.data?.id, openFromPlaylist], ); final playlistsCheck = useState({}); @@ -93,7 +85,7 @@ class PlaylistAddTrackDialog extends HookConsumerWidget { content: SizedBox( height: 300, width: 300, - child: userPlaylists.hasNextPage + child: userPlaylists.isLoading ? const Center(child: CircularProgressIndicator()) : ListView.builder( shrinkWrap: true, diff --git a/lib/components/shared/dialogs/track_details_dialog.dart b/lib/components/shared/dialogs/track_details_dialog.dart index 9e29c32d..8634776f 100644 --- a/lib/components/shared/dialogs/track_details_dialog.dart +++ b/lib/components/shared/dialogs/track_details_dialog.dart @@ -6,8 +6,7 @@ import 'package:spotube/components/shared/links/hyper_link.dart'; import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/spotube_track.dart'; -import 'package:spotube/utils/primitive_utils.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/extensions/duration.dart'; @@ -37,8 +36,8 @@ class TrackDetailsDialog extends HookWidget { overflow: TextOverflow.ellipsis, style: const TextStyle(color: Colors.blue), ), - context.l10n.duration: (track is SpotubeTrack - ? (track as SpotubeTrack).ytTrack.duration + context.l10n.duration: (track is SourcedTrack + ? (track as SourcedTrack).sourceInfo.duration : track.duration!) .toHumanReadableString(), if (track.album!.releaseDate != null) @@ -46,33 +45,27 @@ class TrackDetailsDialog extends HookWidget { context.l10n.popularity: track.popularity?.toString() ?? "0", }; - final ytTrack = - track is SpotubeTrack ? (track as SpotubeTrack).ytTrack : null; + final sourceInfo = + track is SourcedTrack ? (track as SourcedTrack).sourceInfo : null; - final ytTracksDetailsMap = ytTrack == null + final ytTracksDetailsMap = sourceInfo == null ? {} : { context.l10n.youtube: Hyperlink( - "https://piped.video/watch?v=${ytTrack.id}", - "https://piped.video/watch?v=${ytTrack.id}", + "https://piped.video/watch?v=${sourceInfo.id}", + "https://piped.video/watch?v=${sourceInfo.id}", maxLines: 2, overflow: TextOverflow.ellipsis, ), context.l10n.channel: Hyperlink( - ytTrack.channelName, - "https://youtube.com${ytTrack.channelName}", + sourceInfo.artist, + sourceInfo.artistUrl, maxLines: 2, overflow: TextOverflow.ellipsis, ), - context.l10n.likes: - PrimitiveUtils.toReadableNumber(ytTrack.likes.toDouble()), - context.l10n.dislikes: - PrimitiveUtils.toReadableNumber(ytTrack.dislikes.toDouble()), - context.l10n.views: - PrimitiveUtils.toReadableNumber(ytTrack.views.toDouble()), context.l10n.streamUrl: Hyperlink( - (track as SpotubeTrack).ytUri, - (track as SpotubeTrack).ytUri, + (track as SourcedTrack).url, + (track as SourcedTrack).url, maxLines: 2, overflow: TextOverflow.ellipsis, ), diff --git a/lib/components/shared/expandable_search/expandable_search.dart b/lib/components/shared/expandable_search/expandable_search.dart index 684e373e..75ac6841 100644 --- a/lib/components/shared/expandable_search/expandable_search.dart +++ b/lib/components/shared/expandable_search/expandable_search.dart @@ -4,13 +4,15 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/extensions/context.dart'; class ExpandableSearchField extends StatelessWidget { - final ValueNotifier isFiltering; + final bool isFiltering; + final ValueChanged onChangeFiltering; final TextEditingController searchController; final FocusNode searchFocus; const ExpandableSearchField({ Key? key, required this.isFiltering, + required this.onChangeFiltering, required this.searchController, required this.searchFocus, }) : super(key: key); @@ -19,17 +21,17 @@ class ExpandableSearchField extends StatelessWidget { Widget build(BuildContext context) { return AnimatedOpacity( duration: const Duration(milliseconds: 200), - opacity: isFiltering.value ? 1 : 0, + opacity: isFiltering ? 1 : 0, child: AnimatedSize( duration: const Duration(milliseconds: 200), child: SizedBox( - height: isFiltering.value ? 50 : 0, + height: isFiltering ? 50 : 0, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: CallbackShortcuts( bindings: { LogicalKeySet(LogicalKeyboardKey.escape): () { - isFiltering.value = false; + onChangeFiltering(false); searchController.clear(); searchFocus.unfocus(); } @@ -52,7 +54,7 @@ class ExpandableSearchField extends StatelessWidget { } class ExpandableSearchButton extends StatelessWidget { - final ValueNotifier isFiltering; + final bool isFiltering; final FocusNode searchFocus; final Widget icon; final ValueChanged? onPressed; @@ -73,18 +75,17 @@ class ExpandableSearchButton extends StatelessWidget { icon: icon, style: IconButton.styleFrom( backgroundColor: - isFiltering.value ? theme.colorScheme.secondaryContainer : null, - foregroundColor: isFiltering.value ? theme.colorScheme.secondary : null, + isFiltering ? theme.colorScheme.secondaryContainer : null, + foregroundColor: isFiltering ? theme.colorScheme.secondary : null, minimumSize: const Size(25, 25), ), onPressed: () { - isFiltering.value = !isFiltering.value; - if (isFiltering.value) { + if (isFiltering) { searchFocus.requestFocus(); } else { searchFocus.unfocus(); } - onPressed?.call(isFiltering.value); + onPressed?.call(!isFiltering); }, ); } diff --git a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart new file mode 100644 index 00000000..dca77233 --- /dev/null +++ b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart @@ -0,0 +1,94 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/album/album_card.dart'; +import 'package:spotube/components/artist/artist_card.dart'; +import 'package:spotube/components/playlist/playlist_card.dart'; +import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; +import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; + +class HorizontalPlaybuttonCardView extends HookWidget { + final Widget title; + final List items; + final VoidCallback onFetchMore; + final bool isLoadingNextPage; + final bool hasNextPage; + + const HorizontalPlaybuttonCardView({ + required this.title, + required this.items, + required this.hasNextPage, + required this.onFetchMore, + required this.isLoadingNextPage, + Key? key, + }) : assert( + items is List || + items is List || + items is List, + ), + super(key: key); + + @override + Widget build(BuildContext context) { + final ThemeData(:textTheme) = Theme.of(context); + final scrollController = useScrollController(); + final height = useBreakpointValue( + xs: 226, + sm: 226, + md: 236, + others: 266, + ); + + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DefaultTextStyle( + style: textTheme.titleMedium!, + child: title, + ), + SizedBox( + height: height, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }, + ), + child: InfiniteList( + scrollController: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(vertical: 8.0), + itemCount: items.length, + onFetchData: onFetchMore, + loadingBuilder: (context) => const ShimmerPlaybuttonCard(), + emptyBuilder: (context) => + const ShimmerPlaybuttonCard(count: 5), + isLoading: isLoadingNextPage, + hasReachedMax: !hasNextPage, + itemBuilder: (context, index) { + final item = items[index]; + + return switch (item.runtimeType) { + PlaylistSimple => PlaylistCard(item as PlaylistSimple), + Album => AlbumCard(item as Album), + Artist => Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: ArtistCard(item as Artist), + ), + _ => const SizedBox.shrink(), + }; + }), + ), + ), + ], + ), + ); + } +} diff --git a/lib/components/shared/inter_scrollbar/inter_scrollbar.dart b/lib/components/shared/inter_scrollbar/inter_scrollbar.dart index 05eb174a..11f75829 100644 --- a/lib/components/shared/inter_scrollbar/inter_scrollbar.dart +++ b/lib/components/shared/inter_scrollbar/inter_scrollbar.dart @@ -1,29 +1,16 @@ +import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; class InterScrollbar extends HookWidget { final Widget child; - final ScrollController? controller; - final bool? thumbVisibility; - final bool? trackVisibility; - final double? thickness; - final Radius? radius; - final bool Function(ScrollNotification)? notificationPredicate; - final bool? interactive; - final ScrollbarOrientation? scrollbarOrientation; + final ScrollController controller; const InterScrollbar({ super.key, required this.child, - this.controller, - this.thumbVisibility, - this.trackVisibility, - this.thickness, - this.radius, - this.notificationPredicate, - this.interactive, - this.scrollbarOrientation, + required this.controller, }); @override @@ -32,38 +19,9 @@ class InterScrollbar extends HookWidget { if (DesktopTools.platform.isDesktop) return child; - return ScrollbarTheme( - data: theme.scrollbarTheme.copyWith( - crossAxisMargin: 10, - minThumbLength: 80, - thickness: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.hovered) || - states.contains(MaterialState.dragged) || - states.contains(MaterialState.pressed)) { - return 40; - } - return 20; - }), - radius: const Radius.circular(20), - thumbColor: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.hovered) || - states.contains(MaterialState.dragged)) { - return theme.colorScheme.onSurface.withOpacity(0.5); - } - return theme.colorScheme.onSurface.withOpacity(0.3); - }), - ), - child: Scrollbar( - controller: controller, - thumbVisibility: thumbVisibility, - trackVisibility: trackVisibility, - thickness: thickness, - radius: radius, - notificationPredicate: notificationPredicate, - interactive: interactive ?? true, - scrollbarOrientation: scrollbarOrientation, - child: child, - ), + return DraggableScrollbar.semicircle( + controller: controller, + child: child, ); } } diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart index b1086eed..43435f7d 100644 --- a/lib/components/shared/page_window_title_bar.dart +++ b/lib/components/shared/page_window_title_bar.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/utils/platform.dart'; import 'package:titlebar_buttons/titlebar_buttons.dart'; import 'dart:math'; import 'package:flutter/foundation.dart' show kIsWeb; -import 'dart:io' show Platform; +import 'dart:io' show Platform, exit; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:local_notifier/local_notifier.dart'; @@ -17,7 +18,7 @@ final closeNotification = DesktopTools.createNotification( LocalNotificationAction(text: 'Close The App'), ], )?..onClickAction = (value) { - DesktopTools.window.close(); + exit(0); }; class PageWindowTitleBar extends StatefulHookConsumerWidget @@ -113,7 +114,7 @@ class WindowTitleBarButtons extends HookConsumerWidget { Future onClose() async { if (preferences.closeBehavior == CloseBehavior.close) { - await DesktopTools.window.close(); + exit(0); } else { await DesktopTools.window.hide(); await closeNotification?.show(); diff --git a/lib/components/shared/playbutton_card.dart b/lib/components/shared/playbutton_card.dart index c9daa267..d9c48640 100644 --- a/lib/components/shared/playbutton_card.dart +++ b/lib/components/shared/playbutton_card.dart @@ -6,9 +6,8 @@ import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/hover_builder.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/hooks/use_breakpoint_value.dart'; -import 'package:spotube/hooks/use_brightness_value.dart'; -import 'package:spotube/utils/platform.dart'; +import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; +import 'package:spotube/hooks/utils/use_brightness_value.dart'; final htmlTagRegexp = RegExp(r"<[^>]*>", caseSensitive: true); @@ -59,53 +58,36 @@ class PlaybuttonCard extends HookWidget { ); final end = useBreakpointValue( - xs: 15, - sm: 15, - others: 20, - ); - - final textsHeight = useState( - (textsKey.currentContext?.findRenderObject() as RenderBox?) - ?.size - .height ?? - 110.00, + xs: 10, + sm: 10, + others: 15, ); final cleanDescription = useDescription(description); - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - textsHeight.value = - (textsKey.currentContext?.findRenderObject() as RenderBox?) - ?.size - .height ?? - textsHeight.value; - }); - return null; - }, [textsKey]); - - return Stack( - children: [ - Container( - constraints: BoxConstraints(maxWidth: size), - margin: margin, - child: Material( - color: Color.lerp( - theme.colorScheme.surfaceVariant, - theme.colorScheme.surface, - useBrightnessValue(.9, .7), - ), - borderRadius: radius, - shadowColor: theme.colorScheme.background, - elevation: 3, - child: InkWell( - mouseCursor: SystemMouseCursors.click, - onTap: onTap, - borderRadius: radius, - splashFactory: theme.splashFactory, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + return Container( + constraints: BoxConstraints(maxWidth: size), + margin: margin, + child: Material( + color: Color.lerp( + theme.colorScheme.surfaceVariant, + theme.colorScheme.surface, + useBrightnessValue(.9, .7), + ), + borderRadius: radius, + shadowColor: theme.colorScheme.background, + elevation: 3, + child: InkWell( + mouseCursor: SystemMouseCursors.click, + onTap: onTap, + borderRadius: radius, + splashFactory: theme.splashFactory, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + clipBehavior: Clip.none, children: [ Padding( padding: const EdgeInsets.only( @@ -121,115 +103,114 @@ class PlaybuttonCard extends HookWidget { ), ), ), - Column( - key: textsKey, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 15), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: AutoSizeText( - title, - maxLines: 1, - minFontSize: theme.textTheme.bodyMedium!.fontSize!, - overflow: TextOverflow.ellipsis, - ), - ), - if (cleanDescription != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: AutoSizeText( - cleanDescription, - maxLines: 2, - style: theme.textTheme.bodySmall?.copyWith( - color: - theme.colorScheme.onSurface.withOpacity(.5), + if (isOwner) + Positioned( + top: 15, + left: 15, + child: AnimatedSize( + duration: const Duration(milliseconds: 150), + alignment: Alignment.centerLeft, + curve: Curves.easeInExpo, + child: HoverBuilder(builder: (context, isHovered) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.blueAccent, + borderRadius: BorderRadius.circular(20), ), - overflow: TextOverflow.ellipsis, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + SpotubeIcons.user, + color: Colors.white, + size: 16, + ), + if (isHovered) + Text( + "Owned by you", + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.white, + ), + ), + ], + ), + ); + }), + ), + ), + Positioned( + right: end, + bottom: -15, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isPlaying) + IconButton( + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.background, + foregroundColor: theme.colorScheme.primary, + minimumSize: const Size.square(10), + ), + icon: const Icon(SpotubeIcons.queueAdd), + onPressed: isLoading ? null : onAddToQueuePressed, ), + const SizedBox(height: 5), + IconButton( + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.primaryContainer, + foregroundColor: theme.colorScheme.primary, + minimumSize: const Size.square(10), + ), + icon: isLoading + ? SizedBox.fromSize( + size: const Size.square(15), + child: const CircularProgressIndicator( + strokeWidth: 2), + ) + : isPlaying + ? const Icon(SpotubeIcons.pause) + : const Icon(SpotubeIcons.play), + onPressed: isLoading ? null : onPlaybuttonPressed, ), - const SizedBox(height: 10), - ], + ], + ), ), ], ), - ), - ), - ), - if (isOwner) - Positioned( - top: 15, - left: 25, - child: AnimatedSize( - duration: const Duration(milliseconds: 150), - alignment: Alignment.centerLeft, - curve: Curves.easeInExpo, - child: HoverBuilder(builder: (context, isHovered) { - return Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.blueAccent, - borderRadius: BorderRadius.circular(20), + Column( + key: textsKey, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 15), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: AutoSizeText( + title, + maxLines: 1, + minFontSize: theme.textTheme.bodyMedium!.fontSize!, + overflow: TextOverflow.ellipsis, + ), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - SpotubeIcons.user, - color: Colors.white, - size: 16, - ), - if (isHovered) - Text( - "Owned by you", - style: theme.textTheme.bodySmall?.copyWith( - color: Colors.white, - ), + if (cleanDescription != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: AutoSizeText( + cleanDescription, + maxLines: 2, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(.5), ), - ], - ), - ); - }), - ), - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 300), - right: end, - bottom: textsHeight.value - (kIsMobile ? 5 : 10), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!isPlaying) - IconButton( - style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.background, - foregroundColor: theme.colorScheme.primary, - minimumSize: const Size.square(10), - ), - icon: const Icon(SpotubeIcons.queueAdd), - onPressed: isLoading ? null : onAddToQueuePressed, - ), - const SizedBox(height: 5), - IconButton( - style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.primaryContainer, - foregroundColor: theme.colorScheme.primary, - minimumSize: const Size.square(10), - ), - icon: isLoading - ? SizedBox.fromSize( - size: const Size.square(15), - child: const CircularProgressIndicator(strokeWidth: 2), - ) - : isPlaying - ? const Icon(SpotubeIcons.pause) - : const Icon(SpotubeIcons.play), - onPressed: isLoading ? null : onPlaybuttonPressed, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(height: 10), + ], ), ], ), ), - ], + ), ); } } diff --git a/lib/components/shared/shimmers/shimmer_artist_profile.dart b/lib/components/shared/shimmers/shimmer_artist_profile.dart index 940c4e81..75e50cd0 100644 --- a/lib/components/shared/shimmers/shimmer_artist_profile.dart +++ b/lib/components/shared/shimmers/shimmer_artist_profile.dart @@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:skeleton_text/skeleton_text.dart'; import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart'; import 'package:spotube/extensions/theme.dart'; -import 'package:spotube/hooks/use_breakpoint_value.dart'; +import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; class ShimmerArtistProfile extends HookWidget { const ShimmerArtistProfile({Key? key}) : super(key: key); @@ -50,7 +50,7 @@ class ShimmerArtistProfile extends HookWidget { ), ), const SizedBox(width: 10), - const Flexible(child: ShimmerTrackTile(noSliver: true)), + const Flexible(child: ShimmerTrackTileGroup(noSliver: true)), ], ); } diff --git a/lib/components/shared/shimmers/shimmer_categories.dart b/lib/components/shared/shimmers/shimmer_categories.dart index e9f442d4..9bc773da 100644 --- a/lib/components/shared/shimmers/shimmer_categories.dart +++ b/lib/components/shared/shimmers/shimmer_categories.dart @@ -3,7 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; import 'package:spotube/extensions/theme.dart'; -import 'package:spotube/hooks/use_breakpoint_value.dart'; +import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; class ShimmerCategories extends HookWidget { const ShimmerCategories({Key? key}) : super(key: key); diff --git a/lib/components/shared/shimmers/shimmer_playbutton_card.dart b/lib/components/shared/shimmers/shimmer_playbutton_card.dart index 82da5bd9..2259c9b0 100644 --- a/lib/components/shared/shimmers/shimmer_playbutton_card.dart +++ b/lib/components/shared/shimmers/shimmer_playbutton_card.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:spotube/hooks/use_breakpoint_value.dart'; +import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; class ShimmerPlaybuttonCardPainter extends CustomPainter { final Color background; diff --git a/lib/components/shared/shimmers/shimmer_track_tile.dart b/lib/components/shared/shimmers/shimmer_track_tile.dart index 070b2f09..dcb634ed 100644 --- a/lib/components/shared/shimmers/shimmer_track_tile.dart +++ b/lib/components/shared/shimmers/shimmer_track_tile.dart @@ -70,8 +70,7 @@ class ShimmerTrackTilePainter extends CustomPainter { } class ShimmerTrackTile extends StatelessWidget { - final bool noSliver; - const ShimmerTrackTile({super.key, this.noSliver = false}); + const ShimmerTrackTile({super.key}); @override Widget build(BuildContext context) { @@ -82,39 +81,42 @@ class ShimmerTrackTile extends StatelessWidget { shimmerColor: isDark ? Colors.grey[800] : Colors.grey[300], ); + return Padding( + padding: const EdgeInsets.only(bottom: 8.0, left: 8, right: 8), + child: CustomPaint( + size: const Size(double.infinity, 60), + painter: ShimmerTrackTilePainter( + background: shimmerTheme.shimmerBackgroundColor ?? + theme.scaffoldBackgroundColor, + foreground: shimmerTheme.shimmerColor ?? theme.cardColor, + ), + ), + ); + } +} + +class ShimmerTrackTileGroup extends StatelessWidget { + final bool noSliver; + final int count; + const ShimmerTrackTileGroup({ + super.key, + this.noSliver = false, + this.count = 5, + }); + + @override + Widget build(BuildContext context) { if (noSliver) { return ListView.builder( itemCount: 5, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.only(bottom: 8.0, left: 8, right: 8), - child: CustomPaint( - size: const Size(double.infinity, 60), - painter: ShimmerTrackTilePainter( - background: shimmerTheme.shimmerBackgroundColor ?? - theme.scaffoldBackgroundColor, - foreground: shimmerTheme.shimmerColor ?? theme.cardColor, - ), - ), - ); - }, + itemBuilder: (context, index) => const ShimmerTrackTile(), ); } return SliverList( delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) => Padding( - padding: const EdgeInsets.only(bottom: 8.0, left: 8, right: 8), - child: CustomPaint( - size: const Size(double.infinity, 60), - painter: ShimmerTrackTilePainter( - background: shimmerTheme.shimmerBackgroundColor ?? - theme.scaffoldBackgroundColor, - foreground: shimmerTheme.shimmerColor ?? theme.cardColor, - ), - ), - ), - childCount: 5, + (BuildContext context, int index) => const ShimmerTrackTile(), + childCount: count, ), ); } diff --git a/lib/components/shared/themed_button_tab_bar.dart b/lib/components/shared/themed_button_tab_bar.dart index d38c3a19..079a4e8a 100644 --- a/lib/components/shared/themed_button_tab_bar.dart +++ b/lib/components/shared/themed_button_tab_bar.dart @@ -1,8 +1,8 @@ import 'package:buttons_tabbar/buttons_tabbar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:spotube/hooks/use_breakpoint_value.dart'; -import 'package:spotube/hooks/use_brightness_value.dart'; +import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; +import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/utils/platform.dart'; class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { diff --git a/lib/components/shared/track_table/track_collection_view/track_collection_heading.dart b/lib/components/shared/track_table/track_collection_view/track_collection_heading.dart deleted file mode 100644 index 6436f7cd..00000000 --- a/lib/components/shared/track_table/track_collection_view/track_collection_heading.dart +++ /dev/null @@ -1,229 +0,0 @@ -import 'dart:ui'; - -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:fl_query/fl_query.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:palette_generator/palette_generator.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/album/album_card.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/playbutton_card.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; - -enum PlayButtonState { - playing, - notPlaying, - loading, -} - -class TrackCollectionHeading extends HookConsumerWidget { - final String title; - final String? description; - final String titleImage; - final List buttons; - final AlbumSimple? album; - final Query, T> tracksSnapshot; - final PlayButtonState playingState; - final void Function([Track? currentTrack]) onPlay; - final void Function([Track? currentTrack]) onShuffledPlay; - final PaletteColor? color; - - const TrackCollectionHeading({ - Key? key, - required this.title, - required this.titleImage, - required this.buttons, - required this.tracksSnapshot, - required this.playingState, - required this.onPlay, - required this.onShuffledPlay, - required this.color, - this.description, - this.album, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - - final cleanDescription = useDescription(description); - - return LayoutBuilder( - builder: (context, constrains) { - return DecoratedBox( - decoration: BoxDecoration( - image: DecorationImage( - image: UniversalImage.imageProvider(titleImage), - fit: BoxFit.cover, - ), - ), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.black45, - theme.colorScheme.surface, - ], - begin: const FractionalOffset(0, 0), - end: const FractionalOffset(0, 1), - tileMode: TileMode.clamp, - ), - ), - child: Material( - type: MaterialType.transparency, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - ), - child: SafeArea( - child: Flex( - direction: constrains.mdAndDown - ? Axis.vertical - : Axis.horizontal, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 200), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: UniversalImage( - path: titleImage, - placeholder: Assets.albumPlaceholder.path, - ), - ), - ), - const SizedBox(width: 10, height: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: constrains.mdAndDown ? 400 : 300, - ), - child: AutoSizeText( - title, - style: theme.textTheme.titleLarge!.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - maxLines: 2, - minFontSize: 16, - overflow: TextOverflow.ellipsis, - ), - ), - if (album != null) - Text( - "${album?.albumType?.formatted} • ${context.l10n.released} • ${DateTime.tryParse( - album?.releaseDate ?? "", - )?.year}", - style: theme.textTheme.titleMedium!.copyWith( - color: Colors.white, - fontWeight: FontWeight.normal, - ), - ), - if (cleanDescription != null) - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: constrains.mdAndDown ? 400 : 300, - ), - child: AutoSizeText( - cleanDescription, - style: const TextStyle(color: Colors.white), - maxLines: 2, - overflow: TextOverflow.fade, - minFontSize: 14, - ), - ), - const SizedBox(height: 10), - IconTheme( - data: theme.iconTheme.copyWith( - color: Colors.white, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: buttons, - ), - ), - const SizedBox(height: 10), - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: constrains.mdAndDown ? 400 : 300, - ), - child: Row( - mainAxisSize: constrains.smAndUp - ? MainAxisSize.min - : MainAxisSize.min, - children: [ - Expanded( - child: FilledButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - ), - label: Text(context.l10n.shuffle), - icon: const Icon(SpotubeIcons.shuffle), - onPressed: tracksSnapshot.data == null || - playingState == - PlayButtonState.playing - ? null - : onShuffledPlay, - ), - ), - const SizedBox(width: 10), - Expanded( - child: FilledButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: color?.color, - foregroundColor: color?.bodyTextColor, - ), - onPressed: tracksSnapshot.data != null || - playingState == - PlayButtonState.loading - ? onPlay - : null, - icon: switch (playingState) { - PlayButtonState.playing => - const Icon(SpotubeIcons.pause), - PlayButtonState.notPlaying => - const Icon(SpotubeIcons.play), - PlayButtonState.loading => - const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: .7, - ), - ), - }, - label: Text( - playingState == PlayButtonState.playing - ? context.l10n.stop - : context.l10n.play, - ), - ), - ), - ], - ), - ), - ], - ) - ], - ), - ), - ), - ), - ), - ), - ); - }, - ); - } -} diff --git a/lib/components/shared/track_table/track_collection_view/track_collection_view.dart b/lib/components/shared/track_table/track_collection_view/track_collection_view.dart deleted file mode 100644 index dcf01dd2..00000000 --- a/lib/components/shared/track_table/track_collection_view/track_collection_view.dart +++ /dev/null @@ -1,274 +0,0 @@ -import 'dart:async'; - -import 'package:fl_query/fl_query.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/playlist/playlist_create_dialog.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart'; -import 'package:spotube/components/shared/track_table/tracks_table_view.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_custom_status_bar_color.dart'; -import 'package:spotube/hooks/use_palette_color.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:flutter/material.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class TrackCollectionView extends HookConsumerWidget { - final logger = getLogger(TrackCollectionView); - final String id; - final String title; - final String? description; - final Query, T> tracksSnapshot; - final String titleImage; - final PlayButtonState playingState; - final Future Function([Track? currentTrack]) onPlay; - final void Function([Track? currentTrack]) onShuffledPlay; - final void Function() onAddToQueue; - final void Function() onShare; - final Widget? heartBtn; - final AlbumSimple? album; - - final bool showShare; - final bool isOwned; - final bool bottomSpace; - - final String routePath; - TrackCollectionView({ - required this.title, - required this.id, - required this.tracksSnapshot, - required this.titleImage, - required this.playingState, - required this.onPlay, - required this.onShuffledPlay, - required this.onAddToQueue, - required this.onShare, - required this.routePath, - this.heartBtn, - this.album, - this.description, - this.showShare = true, - this.isOwned = false, - this.bottomSpace = false, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final auth = ref.watch(AuthenticationNotifier.provider); - - final color = usePaletteGenerator(titleImage).dominantColor; - - final List buttons = [ - if (showShare) - IconButton( - icon: const Icon(SpotubeIcons.share), - onPressed: onShare, - ), - if (isOwned) - IconButton( - icon: const Icon(SpotubeIcons.edit), - onPressed: () { - showDialog( - context: context, - builder: (context) { - return PlaylistCreateDialog(playlistId: id); - }, - ); - }, - ), - if (heartBtn != null && auth != null) heartBtn!, - IconButton( - onPressed: playingState == PlayButtonState.playing - ? null - : tracksSnapshot.data != null - ? onAddToQueue - : null, - icon: const Icon( - SpotubeIcons.queueAdd, - ), - ), - ]; - - final controller = useScrollController(); - - final collapsed = useState(false); - - useCustomStatusBarColor( - Colors.transparent, - GoRouterState.of(context).matchedLocation == routePath, - ); - - useEffect(() { - listener() { - if (controller.position.pixels >= 390 && !collapsed.value) { - collapsed.value = true; - } else if (controller.position.pixels < 390 && collapsed.value) { - collapsed.value = false; - } - } - - controller.addListener(listener); - - return () => controller.removeListener(listener); - }, [collapsed.value]); - - return Scaffold( - appBar: kIsDesktop - ? const PageWindowTitleBar( - backgroundColor: Colors.transparent, - foregroundColor: Colors.white, - leadingWidth: 400, - leading: Align( - alignment: Alignment.centerLeft, - child: BackButton(color: Colors.white), - ), - ) - : null, - extendBodyBehindAppBar: kIsDesktop, - body: RefreshIndicator( - onRefresh: () async { - await tracksSnapshot.refresh(); - }, - child: InterScrollbar( - controller: controller, - child: CustomScrollView( - controller: controller, - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - SliverAppBar( - actions: [ - AnimatedScale( - duration: const Duration(milliseconds: 200), - scale: collapsed.value ? 1 : 0, - child: Row( - mainAxisSize: MainAxisSize.min, - children: buttons, - ), - ), - AnimatedScale( - duration: const Duration(milliseconds: 200), - scale: collapsed.value ? 1 : 0, - child: IconButton( - tooltip: context.l10n.shuffle, - icon: const Icon(SpotubeIcons.shuffle), - onPressed: playingState == PlayButtonState.playing - ? null - : onShuffledPlay, - ), - ), - AnimatedScale( - duration: const Duration(milliseconds: 200), - scale: collapsed.value ? 1 : 0, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - shape: const CircleBorder(), - backgroundColor: theme.colorScheme.inversePrimary, - ), - onPressed: tracksSnapshot.data != null ? onPlay : null, - child: switch (playingState) { - PlayButtonState.playing => - const Icon(SpotubeIcons.pause), - PlayButtonState.notPlaying => - const Icon(SpotubeIcons.play), - PlayButtonState.loading => const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: .7, - ), - ), - }, - ), - ), - ], - floating: false, - pinned: true, - expandedHeight: 400, - automaticallyImplyLeading: kIsMobile, - leading: - kIsMobile ? const BackButton(color: Colors.white) : null, - iconTheme: IconThemeData(color: color?.titleTextColor), - primary: true, - backgroundColor: color?.color.withOpacity(.8), - title: collapsed.value - ? Text( - title, - style: theme.textTheme.titleMedium!.copyWith( - color: color?.titleTextColor, - fontWeight: FontWeight.w600, - ), - ) - : null, - centerTitle: true, - flexibleSpace: FlexibleSpaceBar( - background: TrackCollectionHeading( - color: color, - title: title, - description: description, - titleImage: titleImage, - playingState: playingState, - onPlay: onPlay, - onShuffledPlay: onShuffledPlay, - tracksSnapshot: tracksSnapshot, - buttons: buttons, - album: album, - ), - ), - ), - HookBuilder( - builder: (context) { - if (tracksSnapshot.isLoading || !tracksSnapshot.hasData) { - return const ShimmerTrackTile(); - } else if (tracksSnapshot.hasError) { - return SliverToBoxAdapter( - child: Text( - context.l10n.error(tracksSnapshot.error ?? ""), - ), - ); - } - - return TracksTableView( - (tracksSnapshot.data ?? []).map( - (track) { - if (track is Track) { - return track; - } else { - return TypeConversionUtils.simpleTrack_X_Track( - track, - album!, - ); - } - }, - ).toList(), - onTrackPlayButtonPressed: onPlay, - playlistId: id, - userPlaylist: isOwned, - onFiltering: () { - // scroll the flexible space - // to allow more space for search results - controller.animateTo( - 330, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - ); - }, - ); - }, - ) - ], - ), - ), - )); - } -} diff --git a/lib/components/shared/track_table/tracks_table_view.dart b/lib/components/shared/track_table/tracks_table_view.dart deleted file mode 100644 index d03e92d7..00000000 --- a/lib/components/shared/track_table/tracks_table_view.dart +++ /dev/null @@ -1,367 +0,0 @@ -import 'dart:async'; - -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:fuzzywuzzy/fuzzywuzzy.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; -import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart'; -import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; -import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; -import 'package:spotube/components/shared/fallbacks/not_found.dart'; -import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; -import 'package:spotube/components/shared/track_table/track_tile.dart'; -import 'package:spotube/components/library/user_local_tracks.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; - -import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/utils/service_utils.dart'; - -final trackCollectionSortState = - StateProvider.family((ref, _) => SortBy.none); - -class TracksTableView extends HookConsumerWidget { - final Future Function(Track currentTrack)? onTrackPlayButtonPressed; - final List tracks; - final bool userPlaylist; - final String? playlistId; - final bool isSliver; - - final Widget? heading; - - final VoidCallback? onFiltering; - - const TracksTableView( - this.tracks, { - Key? key, - this.onTrackPlayButtonPressed, - this.onFiltering, - this.userPlaylist = false, - this.playlistId, - this.heading, - this.isSliver = true, - }) : super(key: key); - - @override - Widget build(context, ref) { - final mediaQuery = MediaQuery.of(context); - - ref.watch(ProxyPlaylistNotifier.provider); - final playback = ref.watch(ProxyPlaylistNotifier.notifier); - ref.watch(downloadManagerProvider); - final downloader = ref.watch(downloadManagerProvider.notifier); - final apiType = - ref.watch(userPreferencesProvider.select((s) => s.youtubeApiType)); - const tableHeadStyle = TextStyle(fontWeight: FontWeight.bold, fontSize: 16); - - final selected = useState>([]); - final showCheck = useState(false); - final sortBy = ref.watch(trackCollectionSortState(playlistId ?? '')); - - final isFiltering = useState(false); - - final searchController = useTextEditingController(); - final searchFocus = useFocusNode(); - - final controller = useScrollController(); - - // this will trigger update on each change in searchController - useValueListenable(searchController); - - final filteredTracks = useMemoized(() { - if (searchController.text.isEmpty) { - return tracks; - } - return tracks - .map((e) => (weightedRatio(e.name!, searchController.text), e)) - .sorted((a, b) => b.$1.compareTo(a.$1)) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList(); - }, [tracks, searchController.text]); - - final sortedTracks = useMemoized( - () { - return ServiceUtils.sortTracks(filteredTracks, sortBy); - }, - [filteredTracks, sortBy], - ); - - final selectedTracks = useMemoized( - () => sortedTracks.where( - (track) => selected.value.contains(track.id), - ), - [sortedTracks], - ); - - final children = tracks.isEmpty - ? [const NotFound(vertical: true)] - : [ - if (heading != null) heading!, - LayoutBuilder(builder: (context, constrains) { - return Row( - children: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: ScaleTransition( - scale: animation, - child: child, - ), - ); - }, - child: showCheck.value - ? Checkbox( - value: selected.value.length == sortedTracks.length, - onChanged: (checked) { - if (!showCheck.value) showCheck.value = true; - if (checked == true) { - selected.value = - sortedTracks.map((s) => s.id!).toList(); - } else { - selected.value = []; - showCheck.value = false; - } - }, - ) - : constrains.mdAndUp - ? const SizedBox(width: 32) - : const SizedBox(width: 16), - ), - Expanded( - flex: 7, - child: Row( - children: [ - Text( - context.l10n.title, - style: tableHeadStyle, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - // used alignment of this table-head - if (constrains.mdAndUp) - Expanded( - flex: 3, - child: Row( - children: [ - Text( - context.l10n.album, - overflow: TextOverflow.ellipsis, - style: tableHeadStyle, - ), - ], - ), - ), - SortTracksDropdown( - value: sortBy, - onChanged: (value) { - ref - .read(trackCollectionSortState(playlistId ?? '') - .notifier) - .state = value; - }, - ), - ExpandableSearchButton( - isFiltering: isFiltering, - searchFocus: searchFocus, - onPressed: (value) { - if (isFiltering.value) { - onFiltering?.call(); - } - }, - ), - AdaptivePopSheetList( - tooltip: context.l10n.more_actions, - headings: [ - Text( - context.l10n.more_actions, - style: tableHeadStyle, - ), - ], - onSelected: (action) async { - switch (action) { - case "download": - { - final confirmed = apiType == YoutubeApiType.piped || - await showDialog( - context: context, - builder: (context) { - return const ConfirmDownloadDialog(); - }, - ); - if (confirmed != true) return; - await downloader - .batchAddToQueue(selectedTracks.toList()); - if (context.mounted) { - selected.value = []; - showCheck.value = false; - } - break; - } - case "add-to-playlist": - { - if (context.mounted) { - await showDialog( - context: context, - builder: (context) { - return PlaylistAddTrackDialog( - tracks: selectedTracks.toList(), - ); - }, - ); - } - break; - } - case "play-next": - { - playback.addTracksAtFirst(selectedTracks); - if (playlistId != null) { - playback.addCollection(playlistId!); - } - selected.value = []; - showCheck.value = false; - break; - } - case "add-to-queue": - { - playback.addTracks(selectedTracks); - if (playlistId != null) { - playback.addCollection(playlistId!); - } - selected.value = []; - showCheck.value = false; - break; - } - default: - } - }, - icon: const Icon(SpotubeIcons.moreVertical), - children: [ - PopSheetEntry( - value: "download", - leading: const Icon(SpotubeIcons.download), - enabled: selectedTracks.isNotEmpty, - title: Text( - context.l10n.download_count(selectedTracks.length), - ), - ), - if (!userPlaylist) - PopSheetEntry( - value: "add-to-playlist", - leading: const Icon(SpotubeIcons.playlistAdd), - enabled: selectedTracks.isNotEmpty, - title: Text( - context.l10n - .add_count_to_playlist(selectedTracks.length), - ), - ), - PopSheetEntry( - enabled: selectedTracks.isNotEmpty, - value: "add-to-queue", - leading: const Icon(SpotubeIcons.queueAdd), - title: Text( - context.l10n - .add_count_to_queue(selectedTracks.length), - ), - ), - PopSheetEntry( - enabled: selectedTracks.isNotEmpty, - value: "play-next", - leading: const Icon(SpotubeIcons.lightning), - title: Text( - context.l10n.play_count_next(selectedTracks.length), - ), - ), - ], - ), - const SizedBox(width: 10), - ], - ); - }), - ExpandableSearchField( - isFiltering: isFiltering, - searchController: searchController, - searchFocus: searchFocus, - ), - ...sortedTracks.mapIndexed((i, track) { - return TrackTile( - index: i, - track: track, - selected: selected.value.contains(track.id), - userPlaylist: userPlaylist, - playlistId: playlistId, - onTap: () async { - if (showCheck.value) { - final alreadyChecked = selected.value.contains(track.id); - if (alreadyChecked) { - selected.value = - selected.value.where((id) => id != track.id).toList(); - } else { - selected.value = [...selected.value, track.id!]; - } - } else { - final isBlackListed = ref.read( - BlackListNotifier.provider.select( - (blacklist) => blacklist.contains( - BlacklistedElement.track(track.id!, track.name!), - ), - ), - ); - if (isBlackListed) return; - await onTrackPlayButtonPressed?.call(track); - } - }, - onLongPress: () { - if (showCheck.value) return; - showCheck.value = true; - selected.value = [...selected.value, track.id!]; - }, - onChanged: !showCheck.value - ? null - : (value) { - if (value == null) return; - if (value) { - selected.value = [...selected.value, track.id!]; - } else { - selected.value = selected.value - .where((id) => id != track.id) - .toList(); - } - }, - ); - }), - // extra space for mobile devices where keyboard takes half of the screen - if (isFiltering.value) - SizedBox( - height: mediaQuery.size.height * .75, //75% of the screen - ), - ]; - - if (isSliver) { - return SliverSafeArea( - top: false, - sliver: SliverList( - delegate: SliverChildListDelegate(children), - ), - ); - } - return SafeArea( - child: ListView( - controller: controller, - children: children, - ), - ); - } -} diff --git a/lib/components/shared/track_table/track_options.dart b/lib/components/shared/track_tile/track_options.dart similarity index 98% rename from lib/components/shared/track_table/track_options.dart rename to lib/components/shared/track_tile/track_options.dart index 96bd8b60..9a587be6 100644 --- a/lib/components/shared/track_table/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -64,11 +64,15 @@ class TrackOptions extends HookConsumerWidget { }); } - void actionAddToPlaylist(BuildContext context, Track track) { + void actionAddToPlaylist( + BuildContext context, + Track track, + ) { showDialog( context: context, builder: (context) => PlaylistAddTrackDialog( tracks: [track], + openFromPlaylist: playlistId, ), ); } @@ -110,7 +114,7 @@ class TrackOptions extends HookConsumerWidget { ]); final progressNotifier = useMemoized(() { - final spotubeTrack = downloadManager.mapToSpotubeTrack(track); + final spotubeTrack = downloadManager.mapToSourcedTrack(track); if (spotubeTrack == null) return null; return downloadManager.getProgressNotifier(spotubeTrack); }); diff --git a/lib/components/shared/track_table/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart similarity index 97% rename from lib/components/shared/track_table/track_tile.dart rename to lib/components/shared/track_tile/track_tile.dart index 0666b7f9..6d4e236a 100644 --- a/lib/components/shared/track_table/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -9,7 +9,7 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/hover_builder.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/links/link_text.dart'; -import 'package:spotube/components/shared/track_table/track_options.dart'; +import 'package:spotube/components/shared/track_tile/track_options.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/models/local_track.dart'; @@ -91,7 +91,9 @@ class TrackTile extends HookConsumerWidget { isLoading.value = true; await onTap?.call(); } finally { - isLoading.value = false; + if (context.mounted) { + isLoading.value = false; + } } }, onLongPress: onLongPress, @@ -111,7 +113,7 @@ class TrackTile extends HookConsumerWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 6), child: Text( - '$index', + '${(index ?? 0) + 1}', maxLines: 1, style: theme.textTheme.bodySmall, textAlign: TextAlign.center, diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body.dart b/lib/components/shared/tracks_view/sections/body/track_view_body.dart new file mode 100644 index 00000000..d77a3e6f --- /dev/null +++ b/lib/components/shared/tracks_view/sections/body/track_view_body.dart @@ -0,0 +1,129 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.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:spotify/spotify.dart'; +import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; +import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart'; +import 'package:spotube/components/shared/track_tile/track_tile.dart'; +import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart'; +import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/utils/service_utils.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; + +class TrackViewBodySection extends HookConsumerWidget { + const TrackViewBodySection({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final props = InheritedTrackView.of(context); + final trackViewState = ref.watch(trackViewProvider(props.tracks)); + + final searchController = useTextEditingController(); + final searchFocus = useFocusNode(); + + useValueListenable(searchController); + final searchQuery = searchController.text; + + final isFiltering = useState(false); + + final uniqTracks = useMemoized(() { + final trackIds = props.tracks.map((e) => e.id).toSet(); + return props.tracks.where((e) => trackIds.remove(e.id)).toList(); + }, [props.tracks]); + + final tracks = useMemoized(() { + List filteredTracks; + if (searchQuery.isEmpty) { + filteredTracks = uniqTracks; + } else { + filteredTracks = uniqTracks + .map((e) => (weightedRatio(e.name!, searchQuery), e)) + .sorted((a, b) => b.$1.compareTo(a.$1)) + .where((e) => e.$1 > 50) + .map((e) => e.$2) + .toList(); + } + return ServiceUtils.sortTracks(filteredTracks, trackViewState.sortBy); + }, [trackViewState.sortBy, searchQuery, uniqTracks]); + + final isUserPlaylist = useIsUserPlaylist(ref, props.collectionId); + + final isActive = playlist.collections.contains(props.collectionId); + + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: TrackViewBodyHeaders( + isFiltering: isFiltering, + searchFocus: searchFocus, + ), + ), + const SliverGap(8), + SliverToBoxAdapter( + child: ExpandableSearchField( + isFiltering: isFiltering.value, + onChangeFiltering: (value) { + isFiltering.value = value; + }, + searchController: searchController, + searchFocus: searchFocus, + ), + ), + SliverSafeArea( + top: false, + sliver: SliverInfiniteList( + itemCount: tracks.length, + onFetchData: props.pagination.onFetchMore, + isLoading: props.pagination.isLoading, + hasReachedMax: !props.pagination.hasNextPage, + loadingBuilder: (context) => const ShimmerTrackTile(), + itemBuilder: (context, index) { + final track = tracks[index]; + return TrackTile( + track: track, + index: index, + selected: trackViewState.selectedTrackIds.contains(track.id!), + playlistId: props.collectionId, + userPlaylist: isUserPlaylist, + onChanged: !trackViewState.isSelecting + ? null + : (value) { + trackViewState.toggleTrackSelection(track.id!); + }, + onLongPress: () { + trackViewState.selectTrack(track.id!); + }, + onTap: () async { + if (trackViewState.isSelecting) { + trackViewState.toggleTrackSelection(track.id!); + return; + } + + if (isActive || playlist.tracks.contains(track)) { + await playlistNotifier.jumpToTrack(track); + } else { + final tracks = await props.pagination.onFetchAll(); + await playlistNotifier.load( + tracks, + initialIndex: index, + autoPlay: true, + ); + playlistNotifier.addCollection(props.collectionId); + } + }, + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart b/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart new file mode 100644 index 00000000..7e4522a0 --- /dev/null +++ b/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; +import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; +import 'package:spotube/components/shared/tracks_view/sections/body/track_view_options.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; + +class TrackViewBodyHeaders extends HookConsumerWidget { + final ValueNotifier isFiltering; + final FocusNode searchFocus; + + const TrackViewBodyHeaders({ + Key? key, + required this.isFiltering, + required this.searchFocus, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:textTheme) = Theme.of(context); + final props = InheritedTrackView.of(context); + final trackViewState = ref.watch(trackViewProvider(props.tracks)); + return LayoutBuilder( + builder: (context, constrains) { + return Row( + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: animation, + child: child, + ), + ); + }, + child: Checkbox( + value: trackViewState.hasSelectedAll, + onChanged: (checked) { + if (checked == true) { + trackViewState.selectAll(); + } else { + trackViewState.deselectAll(); + } + }, + ), + ), + Expanded( + flex: 7, + child: Row( + children: [ + Text( + context.l10n.title, + style: textTheme.bodyLarge, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // used alignment of this table-head + if (constrains.mdAndUp) + Expanded( + flex: 3, + child: Row( + children: [ + Text( + context.l10n.album, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyLarge, + ), + ], + ), + ), + SortTracksDropdown( + value: trackViewState.sortBy, + onChanged: (value) { + trackViewState.sort(value); + }, + ), + ExpandableSearchButton( + isFiltering: isFiltering.value, + searchFocus: searchFocus, + onPressed: (value) { + isFiltering.value = value; + if (value) { + searchFocus.requestFocus(); + } else { + searchFocus.unfocus(); + } + }, + ), + const TrackViewBodyOptions(), + ], + ); + }, + ); + } +} diff --git a/lib/components/shared/tracks_view/sections/body/track_view_options.dart b/lib/components/shared/tracks_view/sections/body/track_view_options.dart new file mode 100644 index 00000000..583c9107 --- /dev/null +++ b/lib/components/shared/tracks_view/sections/body/track_view_options.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; +import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart'; +import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + +class TrackViewBodyOptions extends HookConsumerWidget { + const TrackViewBodyOptions({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final props = InheritedTrackView.of(context); + final ThemeData(:textTheme) = Theme.of(context); + + ref.watch(downloadManagerProvider); + final downloader = ref.watch(downloadManagerProvider.notifier); + final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final audioSource = + ref.watch(userPreferencesProvider.select((s) => s.audioSource)); + + final trackViewState = ref.watch(trackViewProvider(props.tracks)); + final selectedTracks = trackViewState.selectedTracks; + + return AdaptivePopSheetList( + tooltip: context.l10n.more_actions, + headings: [ + Text( + context.l10n.more_actions, + style: textTheme.bodyLarge, + ), + ], + onSelected: (action) async { + switch (action) { + case "download": + { + final confirmed = audioSource == AudioSource.piped || + await showDialog( + context: context, + builder: (context) { + return const ConfirmDownloadDialog(); + }, + ); + if (confirmed != true) return; + await downloader.batchAddToQueue(selectedTracks); + trackViewState.deselectAll(); + break; + } + case "add-to-playlist": + { + if (context.mounted) { + await showDialog( + context: context, + builder: (context) { + return PlaylistAddTrackDialog( + openFromPlaylist: props.collectionId, + tracks: selectedTracks.toList(), + ); + }, + ); + } + break; + } + case "play-next": + { + playlistNotifier.addTracksAtFirst(selectedTracks); + playlistNotifier.addCollection(props.collectionId); + trackViewState.deselectAll(); + break; + } + case "add-to-queue": + { + playlistNotifier.addTracks(selectedTracks); + playlistNotifier.addCollection(props.collectionId); + trackViewState.deselectAll(); + break; + } + default: + } + }, + icon: const Icon(SpotubeIcons.moreVertical), + children: [ + PopSheetEntry( + value: "download", + leading: const Icon(SpotubeIcons.download), + enabled: selectedTracks.isNotEmpty, + title: Text( + context.l10n.download_count(selectedTracks.length), + ), + ), + PopSheetEntry( + value: "add-to-playlist", + leading: const Icon(SpotubeIcons.playlistAdd), + enabled: selectedTracks.isNotEmpty, + title: Text( + context.l10n.add_count_to_playlist(selectedTracks.length), + ), + ), + PopSheetEntry( + enabled: selectedTracks.isNotEmpty, + value: "add-to-queue", + leading: const Icon(SpotubeIcons.queueAdd), + title: Text( + context.l10n.add_count_to_queue(selectedTracks.length), + ), + ), + PopSheetEntry( + enabled: selectedTracks.isNotEmpty, + value: "play-next", + leading: const Icon(SpotubeIcons.lightning), + title: Text( + context.l10n.play_count_next(selectedTracks.length), + ), + ), + ], + ); + } +} diff --git a/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart b/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart new file mode 100644 index 00000000..ca3c6706 --- /dev/null +++ b/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart @@ -0,0 +1,18 @@ +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/services/queries/queries.dart'; + +bool useIsUserPlaylist(WidgetRef ref, String playlistId) { + final userPlaylistsQuery = useQueries.playlist.ofMineAll(ref); + final me = useQueries.user.me(ref); + + return useMemoized( + () => + userPlaylistsQuery.data?.any((e) => + e.id == playlistId && + me.data != null && + e.owner?.id == me.data?.id) ?? + false, + [userPlaylistsQuery.data, playlistId, me.data], + ); +} diff --git a/lib/components/shared/tracks_view/sections/header/flexible_header.dart b/lib/components/shared/tracks_view/sections/header/flexible_header.dart new file mode 100644 index 00000000..7c469654 --- /dev/null +++ b/lib/components/shared/tracks_view/sections/header/flexible_header.dart @@ -0,0 +1,149 @@ +import 'dart:ui'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/playbutton_card.dart'; +import 'package:spotube/components/shared/tracks_view/sections/header/header_actions.dart'; +import 'package:spotube/components/shared/tracks_view/sections/header/header_buttons.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:gap/gap.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/hooks/utils/use_palette_color.dart'; + +class TrackViewFlexHeader extends HookConsumerWidget { + const TrackViewFlexHeader({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final props = InheritedTrackView.of(context); + final ThemeData(:colorScheme, :textTheme, :iconTheme) = Theme.of(context); + final defaultTextStyle = DefaultTextStyle.of(context); + final mediaQuery = MediaQuery.of(context); + + final description = useDescription(props.description); + + final palette = usePaletteColor(props.image, ref); + + return IconTheme( + data: iconTheme.copyWith(color: palette.bodyTextColor), + child: SliverLayoutBuilder( + builder: (context, constrains) { + final isExpanded = constrains.scrollOffset < 350; + + final headingStyle = (mediaQuery.mdAndDown + ? textTheme.headlineSmall + : textTheme.headlineMedium) + ?.copyWith( + color: palette.bodyTextColor, + ); + return SliverAppBar( + iconTheme: iconTheme.copyWith( + color: palette.bodyTextColor, + size: 16, + ), + actions: isExpanded + ? [] + : [ + const TrackViewHeaderActions(), + TrackViewHeaderButtons(compact: true, color: palette), + ], + floating: false, + pinned: true, + expandedHeight: 450, + automaticallyImplyLeading: DesktopTools.platform.isMobile, + backgroundColor: palette.color, + title: isExpanded ? null : Text(props.title, style: headingStyle), + flexibleSpace: FlexibleSpaceBar( + background: Container( + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + image: DecorationImage( + image: CachedNetworkImageProvider(props.image), + fit: BoxFit.cover, + ), + ), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.black45, + colorScheme.surface, + ], + begin: const FractionalOffset(0, 0), + end: const FractionalOffset(0, 1), + tileMode: TileMode.clamp, + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flex( + direction: mediaQuery.mdAndDown + ? Axis.vertical + : Axis.horizontal, + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: UniversalImage( + path: props.image, + width: 200, + height: 200, + placeholder: Assets.albumPlaceholder.path, + ), + ), + const Gap(20), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: mediaQuery.mdAndDown + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + Text(props.title, style: headingStyle), + const SizedBox(height: 10), + if (description != null && + description.isNotEmpty) + Text( + description, + style: defaultTextStyle.style.copyWith( + color: palette.bodyTextColor, + ), + textAlign: mediaQuery.mdAndDown + ? TextAlign.center + : TextAlign.start, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const Gap(10), + const TrackViewHeaderActions(), + const Gap(10), + TrackViewHeaderButtons(color: palette), + ], + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/components/shared/tracks_view/sections/header/header_actions.dart b/lib/components/shared/tracks_view/sections/header/header_actions.dart new file mode 100644 index 00000000..b050c199 --- /dev/null +++ b/lib/components/shared/tracks_view/sections/header/header_actions.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/playlist/playlist_create_dialog.dart'; +import 'package:spotube/components/shared/heart_button.dart'; +import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; + +class TrackViewHeaderActions extends HookConsumerWidget { + const TrackViewHeaderActions({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final props = InheritedTrackView.of(context); + + final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + + final isActive = playlist.collections.contains(props.collectionId); + + final isUserPlaylist = useIsUserPlaylist(ref, props.collectionId); + + final scaffoldMessenger = ScaffoldMessenger.of(context); + + final auth = ref.watch(AuthenticationNotifier.provider); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: context.l10n.share, + icon: const Icon(SpotubeIcons.share), + onPressed: () async { + await Clipboard.setData( + ClipboardData(text: props.shareUrl), + ); + + scaffoldMessenger.showSnackBar( + SnackBar( + width: 300, + behavior: SnackBarBehavior.floating, + content: Text( + "Copied ${props.shareUrl} to clipboard", + textAlign: TextAlign.center, + ), + ), + ); + }, + ), + IconButton( + icon: const Icon(SpotubeIcons.queueAdd), + tooltip: context.l10n.add_to_queue, + onPressed: isActive || props.tracks.isEmpty + ? null + : () async { + final tracks = await props.pagination.onFetchAll(); + await playlistNotifier.addTracks(tracks); + playlistNotifier.addCollection(props.collectionId); + }, + ), + if (props.onHeart != null && auth != null) + HeartButton( + isLiked: props.isLiked, + icon: isUserPlaylist ? SpotubeIcons.trash : null, + tooltip: props.isLiked + ? context.l10n.remove_from_favorites + : context.l10n.save_as_favorite, + onPressed: () { + props.onHeart?.call(); + if (isUserPlaylist) { + context.pop(); + } + }, + ), + if (isUserPlaylist) + IconButton( + icon: const Icon(SpotubeIcons.edit), + onPressed: () { + showDialog( + context: context, + builder: (context) { + return PlaylistCreateDialog( + playlistId: props.collectionId, + trackIds: props.tracks.map((e) => e.id!).toList(), + ); + }, + ); + }, + ), + ], + ); + } +} diff --git a/lib/components/shared/tracks_view/sections/header/header_buttons.dart b/lib/components/shared/tracks_view/sections/header/header_buttons.dart new file mode 100644 index 00000000..bae47f12 --- /dev/null +++ b/lib/components/shared/tracks_view/sections/header/header_buttons.dart @@ -0,0 +1,137 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:palette_generator/palette_generator.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; + +class TrackViewHeaderButtons extends HookConsumerWidget { + final PaletteColor color; + final bool compact; + const TrackViewHeaderButtons({ + Key? key, + required this.color, + this.compact = false, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final props = InheritedTrackView.of(context); + final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + + final isActive = playlist.collections.contains(props.collectionId); + + final isLoading = useState(false); + + const progressIndicator = Center( + child: SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: .8), + ), + ); + + void onShuffle() async { + try { + isLoading.value = true; + + final allTracks = await props.pagination.onFetchAll(); + + await playlistNotifier.load( + allTracks, + autoPlay: true, + initialIndex: Random().nextInt(allTracks.length), + ); + await audioPlayer.setShuffle(true); + playlistNotifier.addCollection(props.collectionId); + } finally { + isLoading.value = false; + } + } + + void onPlay() async { + try { + isLoading.value = true; + + final allTracks = await props.pagination.onFetchAll(); + + await playlistNotifier.load(allTracks, autoPlay: true); + playlistNotifier.addCollection(props.collectionId); + } finally { + isLoading.value = false; + } + } + + if (compact) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isActive && !isLoading.value) + IconButton( + icon: const Icon(SpotubeIcons.shuffle), + onPressed: props.tracks.isEmpty ? null : onShuffle, + ), + const Gap(10), + IconButton.filledTonal( + icon: isActive + ? const Icon(SpotubeIcons.pause) + : isLoading.value + ? progressIndicator + : const Icon(SpotubeIcons.play), + onPressed: isActive || props.tracks.isEmpty || isLoading.value + ? null + : onPlay, + ), + const Gap(10), + ], + ); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: isActive || isLoading.value ? 0 : 1, + child: AnimatedSize( + duration: const Duration(milliseconds: 300), + child: SizedBox.square( + dimension: isActive || isLoading.value ? 0 : null, + child: FilledButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + minimumSize: const Size(150, 40)), + label: Text(context.l10n.shuffle), + icon: const Icon(SpotubeIcons.shuffle), + onPressed: props.tracks.isEmpty ? null : onShuffle, + ), + ), + ), + ), + const Gap(10), + FilledButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: color.color, + foregroundColor: color.bodyTextColor, + minimumSize: const Size(150, 40)), + onPressed: isActive || props.tracks.isEmpty || isLoading.value + ? null + : onPlay, + icon: isActive + ? const Icon(SpotubeIcons.pause) + : isLoading.value + ? progressIndicator + : const Icon(SpotubeIcons.play), + label: Text(context.l10n.play), + ), + ], + ); + } +} diff --git a/lib/components/shared/tracks_view/track_view.dart b/lib/components/shared/tracks_view/track_view.dart new file mode 100644 index 00000000..a65bcff1 --- /dev/null +++ b/lib/components/shared/tracks_view/track_view.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sliver_tools/sliver_tools.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart'; +import 'package:spotube/components/shared/tracks_view/sections/header/flexible_header.dart'; +import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; + +class TrackView extends HookConsumerWidget { + const TrackView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final props = InheritedTrackView.of(context); + + return Scaffold( + appBar: DesktopTools.platform.isDesktop + ? const PageWindowTitleBar( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + leadingWidth: 400, + leading: Align( + alignment: Alignment.centerLeft, + child: BackButton(color: Colors.white), + ), + ) + : null, + extendBodyBehindAppBar: true, + body: CustomScrollView( + slivers: [ + const TrackViewFlexHeader(), + SliverAnimatedSwitcher( + duration: const Duration(milliseconds: 500), + child: props.tracks.isEmpty + ? const ShimmerTrackTileGroup() + : const TrackViewBodySection(), + ), + ], + ), + ); + } +} diff --git a/lib/components/shared/tracks_view/track_view_props.dart b/lib/components/shared/tracks_view/track_view_props.dart new file mode 100644 index 00000000..59c05db2 --- /dev/null +++ b/lib/components/shared/tracks_view/track_view_props.dart @@ -0,0 +1,102 @@ +import 'package:fl_query/fl_query.dart'; +import 'package:flutter/material.dart' hide Page; +import 'package:spotify/spotify.dart'; + +class PaginationProps { + final bool hasNextPage; + final bool isLoading; + final VoidCallback onFetchMore; + final Future> Function() onFetchAll; + + const PaginationProps({ + required this.hasNextPage, + required this.isLoading, + required this.onFetchMore, + required this.onFetchAll, + }); + + factory PaginationProps.fromQuery( + InfiniteQuery, dynamic, int> query, { + required Future> Function() onFetchAll, + }) { + return PaginationProps( + hasNextPage: query.hasNextPage, + isLoading: query.isLoadingNextPage, + onFetchMore: query.fetchNext, + onFetchAll: onFetchAll, + ); + } + + @override + operator ==(Object other) { + return other is PaginationProps && + other.hasNextPage == hasNextPage && + other.isLoading == isLoading && + other.onFetchMore == onFetchMore && + other.onFetchAll == onFetchAll; + } + + @override + int get hashCode => + super.hashCode ^ + hasNextPage.hashCode ^ + isLoading.hashCode ^ + onFetchMore.hashCode ^ + onFetchAll.hashCode; +} + +class InheritedTrackView extends InheritedWidget { + final String collectionId; + final String title; + final String? description; + final String image; + final String routePath; + final List tracks; + final PaginationProps pagination; + final bool isLiked; + final String shareUrl; + + // events + final VoidCallback? onHeart; // if null heart button will hidden + + const InheritedTrackView({ + super.key, + required super.child, + required this.collectionId, + required this.title, + this.description, + required this.image, + required this.tracks, + required this.pagination, + required this.routePath, + required this.shareUrl, + this.isLiked = false, + this.onHeart, + }); + + @override + bool updateShouldNotify(InheritedTrackView oldWidget) { + return oldWidget.title != title || + oldWidget.description != description || + oldWidget.image != image || + oldWidget.tracks != tracks || + oldWidget.pagination != pagination || + oldWidget.isLiked != isLiked || + oldWidget.onHeart != onHeart || + oldWidget.shareUrl != shareUrl || + oldWidget.routePath != routePath || + oldWidget.collectionId != collectionId || + oldWidget.child != child; + } + + static InheritedTrackView of(BuildContext context) { + final widget = + context.dependOnInheritedWidgetOfExactType(); + if (widget == null) { + throw Exception( + 'InheritedTrackView not found. Make sure to wrap [TrackView] with [InheritedTrackView]', + ); + } + return widget; + } +} diff --git a/lib/components/shared/tracks_view/track_view_provider.dart b/lib/components/shared/tracks_view/track_view_provider.dart new file mode 100644 index 00000000..14dc1136 --- /dev/null +++ b/lib/components/shared/tracks_view/track_view_provider.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/library/user_local_tracks.dart'; + +class TrackViewNotifier extends ChangeNotifier { + List tracks; + List selectedTrackIds; + SortBy sortBy; + String? searchQuery; + + TrackViewNotifier( + this.tracks, { + this.selectedTrackIds = const [], + this.sortBy = SortBy.none, + this.searchQuery, + }); + + bool get isSelecting => selectedTrackIds.isNotEmpty; + + bool get hasSelectedAll => + selectedTrackIds.length == tracks.length && tracks.isNotEmpty; + + List get selectedTracks => + tracks.where((e) => selectedTrackIds.contains(e.id)).toList(); + + void selectTrack(String trackId) { + selectedTrackIds = [...selectedTrackIds, trackId]; + notifyListeners(); + } + + void unselectTrack(String trackId) { + selectedTrackIds = selectedTrackIds.where((e) => e != trackId).toList(); + notifyListeners(); + } + + void toggleTrackSelection(String trackId) { + if (selectedTrackIds.contains(trackId)) { + unselectTrack(trackId); + } else { + selectTrack(trackId); + } + } + + void selectAll() { + selectedTrackIds = tracks.map((e) => e.id!).toList(); + notifyListeners(); + } + + void deselectAll() { + selectedTrackIds = []; + notifyListeners(); + } + + void sort(SortBy sortBy) { + this.sortBy = sortBy; + notifyListeners(); + } +} + +final trackViewProvider = ChangeNotifierProvider.autoDispose + .family>((ref, tracks) { + return TrackViewNotifier(tracks); +}); diff --git a/lib/extensions/color.dart b/lib/extensions/color.dart new file mode 100644 index 00000000..68cd8ef7 --- /dev/null +++ b/lib/extensions/color.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +extension ColorAlterer on Color { + Color darken(double amount) { + assert(amount >= 0 && amount <= 1); + final hsl = HSLColor.fromColor(this); + final hslDark = hsl.withLightness((hsl.lightness - amount).clamp(0.0, 1.0)); + return hslDark.toColor(); + } + + Color lighten(double amount) { + assert(amount >= 0 && amount <= 1); + final hsl = HSLColor.fromColor(this); + final hslLight = + hsl.withLightness((hsl.lightness + amount).clamp(0.0, 1.0)); + return hslLight.toColor(); + } + + bool isLight() { + final luminance = computeLuminance(); + return luminance > 0.5; + } + + bool isDark() { + final luminance = computeLuminance(); + return luminance <= 0.5; + } +} diff --git a/lib/extensions/constrains.dart b/lib/extensions/constrains.dart index 85c84ca9..1177f5ac 100644 --- a/lib/extensions/constrains.dart +++ b/lib/extensions/constrains.dart @@ -1,3 +1,4 @@ +import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; // ignore: constant_identifier_names @@ -9,6 +10,29 @@ const Breakpoints = ( xl: 1280.0, ); +extension SliverBreakpoints on SliverConstraints { + bool get isXs => crossAxisExtent <= Breakpoints.xs; + bool get isSm => + crossAxisExtent > Breakpoints.xs && crossAxisExtent <= Breakpoints.sm; + bool get isMd => + crossAxisExtent > Breakpoints.sm && crossAxisExtent <= Breakpoints.md; + bool get isLg => + crossAxisExtent > Breakpoints.md && crossAxisExtent <= Breakpoints.lg; + bool get isXl => + crossAxisExtent > Breakpoints.lg && crossAxisExtent <= Breakpoints.xl; + bool get is2Xl => crossAxisExtent > Breakpoints.xl; + + bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl; + bool get mdAndUp => isMd || isLg || isXl || is2Xl; + bool get lgAndUp => isLg || isXl || is2Xl; + bool get xlAndUp => isXl || is2Xl; + + bool get smAndDown => isXs || isSm; + bool get mdAndDown => isXs || isSm || isMd; + bool get lgAndDown => isXs || isSm || isMd || isLg; + bool get xlAndDown => isXs || isSm || isMd || isLg || isXl; +} + extension ContainerBreakpoints on BoxConstraints { bool get isXs => biggest.width <= Breakpoints.xs; bool get isSm => diff --git a/lib/extensions/infinite_query.dart b/lib/extensions/infinite_query.dart new file mode 100644 index 00000000..2181ab3c --- /dev/null +++ b/lib/extensions/infinite_query.dart @@ -0,0 +1,34 @@ +import 'package:fl_query/fl_query.dart'; +import 'package:spotify/spotify.dart'; + +extension FetchAllTracks on InfiniteQuery, dynamic, int> { + Future> fetchAllTracks({ + required Future> Function() getAllTracks, + }) async { + if (pages.isNotEmpty && !hasNextPage) { + return pages.expand((page) => page).toList(); + } + final tracks = await getAllTracks(); + + final numOfPages = (tracks.length / 20).round(); + + final Map> pagedTracks = {}; + + for (var i = 0; i < numOfPages; i++) { + if (i == numOfPages - 1) { + final pageTracks = tracks.sublist(i * 20); + pagedTracks[i] = pageTracks; + break; + } + + final pageTracks = tracks.sublist(i * 20, (i + 1) * 20); + pagedTracks[i] = pageTracks; + } + + for (final group in pagedTracks.entries) { + setPageData(group.key, group.value); + } + + return tracks.toList(); + } +} diff --git a/lib/extensions/string.dart b/lib/extensions/string.dart new file mode 100644 index 00000000..b7ab7514 --- /dev/null +++ b/lib/extensions/string.dart @@ -0,0 +1,11 @@ +import 'package:html_unescape/html_unescape.dart'; + +final htmlEscape = HtmlUnescape(); + +extension UnescapeHtml on String { + String unescapeHtml() => htmlEscape.convert(this); +} + +extension NullableUnescapeHtml on String? { + String? unescapeHtml() => this == null ? null : htmlEscape.convert(this!); +} diff --git a/lib/extensions/track.dart b/lib/extensions/track.dart index e17e851e..51498b33 100644 --- a/lib/extensions/track.dart +++ b/lib/extensions/track.dart @@ -4,26 +4,29 @@ import 'package:spotube/extensions/artist_simple.dart'; extension TrackJson on Track { Map toJson() { + return TrackJson.trackToJson(this); + } + + static Map trackToJson(Track track) { return { - "album": album?.toJson(), - "artists": artists?.map((artist) => artist.toJson()).toList(), - "availableMarkets": availableMarkets?.map((e) => e.name).toList(), - "discNumber": discNumber, - "duration": duration.toString(), - "durationMs": durationMs, - "explicit": explicit, - // "externalIds": externalIds, - // "externalUrls": externalUrls, - "href": href, - "id": id, - "isPlayable": isPlayable, - // "linkedFrom": linkedFrom, - "name": name, - "popularity": popularity, - "previewUrl": previewUrl, - "trackNumber": trackNumber, - "type": type, - "uri": uri, + "album": track.album?.toJson(), + "artists": track.artists?.map((artist) => artist.toJson()).toList(), + "available_markets": track.availableMarkets?.map((e) => e.name).toList(), + "disc_number": track.discNumber, + "duration_ms": track.durationMs, + "explicit": track.explicit, + // "external_ids"track.: externalIds, + // "external_urls"track.: externalUrls, + "href": track.href, + "id": track.id, + "is_playable": track.isPlayable, + // "linked_from"track.: linkedFrom, + "name": track.name, + "popularity": track.popularity, + "preview_rrl": track.previewUrl, + "track_number": track.trackNumber, + "type": track.type, + "uri": track.uri, }; } } diff --git a/lib/hooks/use_disable_battery_optimizations.dart b/lib/hooks/configurators/use_disable_battery_optimizations.dart similarity index 96% rename from lib/hooks/use_disable_battery_optimizations.dart rename to lib/hooks/configurators/use_disable_battery_optimizations.dart index 267655b6..c1155d19 100644 --- a/lib/hooks/use_disable_battery_optimizations.dart +++ b/lib/hooks/configurators/use_disable_battery_optimizations.dart @@ -1,7 +1,7 @@ import 'package:disable_battery_optimization/disable_battery_optimization.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:spotube/hooks/use_async_effect.dart'; +import 'package:spotube/hooks/utils/use_async_effect.dart'; bool _asked = false; void useDisableBatteryOptimizations() { diff --git a/lib/hooks/configurators/use_get_storage_perms.dart b/lib/hooks/configurators/use_get_storage_perms.dart new file mode 100644 index 00000000..3fcb369b --- /dev/null +++ b/lib/hooks/configurators/use_get_storage_perms.dart @@ -0,0 +1,38 @@ +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:spotube/components/library/user_local_tracks.dart'; +import 'package:spotube/hooks/utils/use_async_effect.dart'; + +void useGetStoragePermissions(WidgetRef ref) { + final isMounted = useIsMounted(); + + useAsyncEffect( + () async { + if (!DesktopTools.platform.isMobile) return; + + final androidInfo = await DeviceInfoPlugin().androidInfo; + + final hasNoStoragePerm = androidInfo.version.sdkInt < 33 && + !await Permission.storage.isGranted && + !await Permission.storage.isLimited; + + final hasNoAudioPerm = androidInfo.version.sdkInt >= 33 && + !await Permission.audio.isGranted && + !await Permission.audio.isLimited; + + if (hasNoStoragePerm) { + await Permission.storage.request(); + if (isMounted()) ref.refresh(localTracksProvider); + } + if (hasNoAudioPerm) { + await Permission.audio.request(); + if (isMounted()) ref.refresh(localTracksProvider); + } + }, + null, + [], + ); +} diff --git a/lib/hooks/use_init_sys_tray.dart b/lib/hooks/configurators/use_init_sys_tray.dart similarity index 96% rename from lib/hooks/use_init_sys_tray.dart rename to lib/hooks/configurators/use_init_sys_tray.dart index e9aa05b6..db4964ce 100644 --- a/lib/hooks/use_init_sys_tray.dart +++ b/lib/hooks/configurators/use_init_sys_tray.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -5,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/intents.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; void useInitSysTray(WidgetRef ref) { final context = useContext(); @@ -70,7 +72,7 @@ void useInitSysTray(WidgetRef ref) { label: "Quit", name: "quit", onClicked: (item) async { - await DesktopTools.window.close(); + exit(0); }, ), ], diff --git a/lib/hooks/use_update_checker.dart b/lib/hooks/configurators/use_update_checker.dart similarity index 95% rename from lib/hooks/use_update_checker.dart rename to lib/hooks/configurators/use_update_checker.dart index 33df5397..1a6a5be5 100644 --- a/lib/hooks/use_update_checker.dart +++ b/lib/hooks/configurators/use_update_checker.dart @@ -8,8 +8,8 @@ import 'package:http/http.dart' as http; import 'package:spotube/collections/env.dart'; import 'package:spotube/components/shared/links/anchor_button.dart'; -import 'package:spotube/hooks/use_package_info.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/hooks/controllers/use_package_info.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:version/version.dart'; diff --git a/lib/hooks/use_auto_scroll_controller.dart b/lib/hooks/controllers/use_auto_scroll_controller.dart similarity index 100% rename from lib/hooks/use_auto_scroll_controller.dart rename to lib/hooks/controllers/use_auto_scroll_controller.dart diff --git a/lib/hooks/use_package_info.dart b/lib/hooks/controllers/use_package_info.dart similarity index 100% rename from lib/hooks/use_package_info.dart rename to lib/hooks/controllers/use_package_info.dart diff --git a/lib/hooks/use_sidebarx_controller.dart b/lib/hooks/controllers/use_sidebarx_controller.dart similarity index 100% rename from lib/hooks/use_sidebarx_controller.dart rename to lib/hooks/controllers/use_sidebarx_controller.dart diff --git a/lib/hooks/use_spotify_infinite_query.dart b/lib/hooks/spotify/use_spotify_infinite_query.dart similarity index 100% rename from lib/hooks/use_spotify_infinite_query.dart rename to lib/hooks/spotify/use_spotify_infinite_query.dart diff --git a/lib/hooks/use_spotify_mutation.dart b/lib/hooks/spotify/use_spotify_mutation.dart similarity index 100% rename from lib/hooks/use_spotify_mutation.dart rename to lib/hooks/spotify/use_spotify_mutation.dart diff --git a/lib/hooks/use_spotify_query.dart b/lib/hooks/spotify/use_spotify_query.dart similarity index 100% rename from lib/hooks/use_spotify_query.dart rename to lib/hooks/spotify/use_spotify_query.dart diff --git a/lib/hooks/use_is_current_route.dart b/lib/hooks/use_is_current_route.dart deleted file mode 100644 index b7b6490a..00000000 --- a/lib/hooks/use_is_current_route.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; - -bool? useIsCurrentRoute([String matcher = "/"]) { - final isCurrentRoute = useState(null); - final context = useContext(); - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((timer) { - final isCurrent = GoRouterState.of(context).matchedLocation == matcher; - if (isCurrent != isCurrentRoute.value) { - isCurrentRoute.value = isCurrent; - } - }); - return null; - }); - return isCurrentRoute.value; -} diff --git a/lib/hooks/use_shared_preferences.dart b/lib/hooks/use_shared_preferences.dart deleted file mode 100644 index 922beaa6..00000000 --- a/lib/hooks/use_shared_preferences.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -SharedPreferences? useSharedPreferences() { - final future = useMemoized(SharedPreferences.getInstance); - final snapshot = useFuture(future, initialData: null); - - return snapshot.data; -} diff --git a/lib/hooks/use_async_effect.dart b/lib/hooks/utils/use_async_effect.dart similarity index 100% rename from lib/hooks/use_async_effect.dart rename to lib/hooks/utils/use_async_effect.dart diff --git a/lib/hooks/use_breakpoint_value.dart b/lib/hooks/utils/use_breakpoint_value.dart similarity index 100% rename from lib/hooks/use_breakpoint_value.dart rename to lib/hooks/utils/use_breakpoint_value.dart diff --git a/lib/hooks/use_brightness_value.dart b/lib/hooks/utils/use_brightness_value.dart similarity index 100% rename from lib/hooks/use_brightness_value.dart rename to lib/hooks/utils/use_brightness_value.dart diff --git a/lib/hooks/use_custom_status_bar_color.dart b/lib/hooks/utils/use_custom_status_bar_color.dart similarity index 100% rename from lib/hooks/use_custom_status_bar_color.dart rename to lib/hooks/utils/use_custom_status_bar_color.dart diff --git a/lib/hooks/use_debounce.dart b/lib/hooks/utils/use_debounce.dart similarity index 100% rename from lib/hooks/use_debounce.dart rename to lib/hooks/utils/use_debounce.dart diff --git a/lib/hooks/use_force_update.dart b/lib/hooks/utils/use_force_update.dart similarity index 100% rename from lib/hooks/use_force_update.dart rename to lib/hooks/utils/use_force_update.dart diff --git a/lib/hooks/use_palette_color.dart b/lib/hooks/utils/use_palette_color.dart similarity index 100% rename from lib/hooks/use_palette_color.dart rename to lib/hooks/utils/use_palette_color.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9d5be6bb..730f51ea 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -263,8 +263,8 @@ "use_system_title_bar": "Use system title bar", "crunching_results": "Crunching results...", "search_to_get_results": "Search to get results", - "use_amoled_mode": "Use AMOLED mode", - "pitch_dark_theme": "Pitch black dart theme", + "use_amoled_mode": "Pitch black dark theme", + "pitch_dark_theme": "AMOLED Mode", "normalize_audio": "Normalize audio", "change_cover": "Change cover", "add_cover": "Add cover", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb new file mode 100644 index 00000000..1d72ec85 --- /dev/null +++ b/lib/l10n/app_tr.arb @@ -0,0 +1,283 @@ +{ + "guest": "Misafir", + "browse": "Gözat", + "search": "Ara", + "library": "Kütüphane", + "lyrics": "Sözler", + "settings": "Ayarlar", + "genre_categories_filter": "Kategorileri veya türleri filtreleyin...", + "genre": "Tür", + "personalized": "Kişiselleştirilmiş", + "featured": "Öne Çıkanlar", + "new_releases": "Yeni Çıkanlar", + "songs": "Şarkılar", + "playing_track": "Oynatılıyor {track}", + "queue_clear_alert": "Bu, mevcut kuyruğu temizleyecektir. {track_length} parçaları kaldırılacaktır\nDevam etmek istiyor musunuz?", + "load_more": "Daha fazlasını yükle", + "playlists": "Çalma Listeleri", + "artists": "Sanatçılar", + "albums": "Albümler", + "tracks": "Parçalar", + "downloads": "İndirmeler", + "filter_playlists": "Çalma listelerinizi filtreleyin...", + "liked_tracks": "Beğenilen Parçalar", + "liked_tracks_description": "Beğendiğiniz tüm parçalar", + "create_playlist": "Çalma Listesi Oluştur", + "create_a_playlist": "Bir çalma listesi oluştur", + "update_playlist": "Çalma listesini güncelle", + "create": "Oluştur", + "cancel": "İptal", + "update": "Güncelle", + "playlist_name": "Çalma Listesi Adı", + "name_of_playlist": "Çalma listesi adı", + "description": "Açıklama", + "public": "Halka açık", + "collaborative": "İşbirliği", + "search_local_tracks": "Yerel parçaları arayın...", + "play": "Oynat", + "delete": "Sil", + "none": "Hiçbiri", + "sort_a_z": "A'dan Z'ye sırala", + "sort_z_a": "Z'dan A'ye sırala", + "sort_artist": "Sanatçıya Göre Sırala", + "sort_album": "Albüme Göre Sırala", + "sort_tracks": "Parçaları Sırala", + "currently_downloading": "Şu Anda İndiriliyor ({tracks_length})", + "cancel_all": "Tümünü İptal Et", + "filter_artist": "Sanatçıları filtrele...", + "followers": "{followers} Takipçiler", + "add_artist_to_blacklist": "Sanatçıyı kara listeye ekle", + "top_tracks": "En İyi Parçalar", + "fans_also_like": "Hayranlar ayrıca şunları beğendi", + "loading": "Yükleniyor...", + "artist": "Sanatçı", + "blacklisted": "Kara Listede", + "following": "Takip Ediliyor", + "follow": "Takip Et", + "artist_url_copied": "Sanatçı bağlantısı panoya kopyalandı", + "added_to_queue": "Kuyruğa {tracks} parçaları eklendi", + "filter_albums": "Albümleri filtrele...", + "synced": "Eşitlendi", + "plain": "Sade", + "shuffle": "Karıştır", + "search_tracks": "Parça ara...", + "released": "Yayınlandı", + "error": "Hata {error}", + "title": "Başlık", + "time": "Zaman", + "more_actions": "Daha fazla işlem", + "download_count": "İndir ({count})", + "add_count_to_playlist": "Çalma Listesine ({count}) Ekle", + "add_count_to_queue": "Sıraya ({count}) ekle", + "play_count_next": "Oynat ({count}) sonraki", + "album": "Albüm", + "copied_to_clipboard": "Panoya {data} kopyalandı", + "add_to_following_playlists": "Aşağıdaki Çalma Listelerine {track} ekle", + "add": "Ekle", + "added_track_to_queue": "Sıraya {track} eklendi", + "add_to_queue": "Kuyruğa ekle", + "track_will_play_next": "{track} sonraki çalacak", + "play_next": "Sıradaki", + "removed_track_from_queue": "Sıradan {track} kaldırıldı", + "remove_from_queue": "Kuyruktan çıkar", + "remove_from_favorites": "Favorilerden kaldır", + "save_as_favorite": "Favori olarak kaydet", + "add_to_playlist": "Çalma listesine ekle", + "remove_from_playlist": "Çalma listesinden kaldır", + "add_to_blacklist": "Kara listeye ekle", + "remove_from_blacklist": "Kara listeden çıkar", + "share": "Paylaş", + "mini_player": "Mini Oynatıcı", + "slide_to_seek": "İleri veya geri arama yapmak için kaydırın", + "shuffle_playlist": "Çalma listesini karıştır", + "unshuffle_playlist": "Karışık çalma listesi", + "previous_track": "Önceki parça", + "next_track": "Sonraki parça", + "pause_playback": "Çalmayı Duraklat", + "resume_playback": "Çalmaya Devam Et", + "loop_track": "Döngü parçası", + "repeat_playlist": "Çalma listesini tekrarla", + "queue": "Sıra", + "alternative_track_sources": "Alternatif parça kaynakları", + "download_track": "Parçayı indir", + "tracks_in_queue": "{tracks} sıradaki parçalar", + "clear_all": "Tümünü temizle", + "show_hide_ui_on_hover": "Üzerine gelindiğinde kullanıcı arayüzünü göster/gizle", + "always_on_top": "Her zaman en üstte", + "exit_mini_player": "Mini oynatıcıdan çık", + "download_location": "İndirme konumu", + "account": "Hesap", + "login_with_spotify": "Spotify hesabınız ile giriş yapın", + "connect_with_spotify": "Spotify ile bağlantı kurun", + "logout": "Çıkış Yap", + "logout_of_this_account": "Bu hesaptan çıkış yap", + "language_region": "Dil & Bölge", + "language": "Dil", + "system_default": "Sistem Varsayılanı", + "market_place_region": "Mevcut Bölge", + "recommendation_country": "Tavsiye Edilen Ülke", + "appearance": "Görünüm", + "layout_mode": "Düzen Modu", + "override_layout_settings": "Duyarlı düzen modu ayarlarını geçersiz kıl", + "adaptive": "Uyarlanabilir", + "compact": "Sıkıştırılmış", + "extended": "Genişletilmiş", + "theme": "Tema", + "dark": "Karanlık", + "light": "Aydınlık", + "system": "Sistem", + "accent_color": "Vurgu Rengi", + "sync_album_color": "Albüm rengini eşitle", + "sync_album_color_description": "Albüm resminin baskın rengini vurgu rengi olarak kullanır", + "playback": "Çalma", + "audio_quality": "Ses Kalitesi", + "high": "Yüksek", + "low": "Düşük", + "pre_download_play": "Önceden indir ve oynat", + "pre_download_play_description": "Ses akışı yerine, baytları indirin ve oynatın (Daha yüksek bant genişliği kullanıcıları için önerilir)", + "skip_non_music": "Müzik olmayan bölümleri atla (SponsorBlock)", + "blacklist_description": "Kara listeye alınan parçalar ve sanatçılar", + "wait_for_download_to_finish": "Lütfen mevcut indirme işleminin bitmesini bekleyin", + "desktop": "Masaüstü", + "close_behavior": "Yakın Davranış", + "close": "Kapat", + "minimize_to_tray": "Tepsiye küçült", + "show_tray_icon": "Sistem tepsisi simgesini göster", + "about": "Hakkında", + "u_love_spotube": "Spotube'u sevdiğinizi biliyoruz", + "check_for_updates": "Güncellemeleri kontrol et", + "about_spotube": "Spotube Hakkında", + "blacklist": "Kara Liste", + "please_sponsor": "Lütfen Sponsor Olun/Bağış Yapın", + "spotube_description": "Spotube, hafif, platformlar arası, herkesin kullanabileceği ücretsiz bir Spotify istemcisidir.", + "version": "Sürüm", + "build_number": "Derleme Numarası", + "founder": "Kurucu", + "repository": "Depo", + "bug_issues": "Hata + Sorunlar", + "made_with": "❤️ ile Bangladesh🇧🇩 adresinde yapılmıştır.", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "Lisans", + "add_spotify_credentials": "Başlamak için spotify bilgilerinizi ekleyin", + "credentials_will_not_be_shared_disclaimer": "Endişelenmeyin, bilgileriniz toplanmayacak veya kimseyle paylaşılmayacak", + "know_how_to_login": "Nasıl yapılacağını bilmiyor musunuz?", + "follow_step_by_step_guide": "Adım Adım kılavuzu takip edin", + "spotify_cookie": "Spotify {name} Çerez", + "cookie_name_cookie": "{name} Çerez", + "fill_in_all_fields": "Lütfen tüm alanları doldurun", + "submit": "Gönder", + "exit": "Çık", + "previous": "Önceki", + "next": "Sonraki", + "done": "Bitti", + "step_1": "1. Adım", + "first_go_to": "İlk önce şu adrese gidin", + "login_if_not_logged_in": "ve oturum açmadıysanız Giriş Yapın/Kaydolun", + "step_2": "2. Adım", + "step_2_steps": "1. Giriş yaptıktan sonra, Tarayıcı devtools.\n2'yi açmak için F12'ye basın veya Fare Sağ Tıklaması > İncele'ye basın. Ardından \"Uygulama\" Sekmesine (Chrome, Edge, Brave vb.) veya \"Depolama\" Sekmesine (Firefox, Palemoon vb.) gidin\n3. \"Çerezler\" bölümüne ve ardından \"https://accounts.spotify.com\" alt bölümüne gidin", + "step_3": "3. Adım", + "step_3_steps": "\"sp_dc\" ve \"sp_key\" (veya sp_gaid) Çerezlerinin değerlerini 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!", + "step_4": "4. Adım", + "step_4_steps": "Kopyalanan \"sp_dc\" ve \"sp_key\" (veya sp_gaid) değerlerini ilgili alanlara yapıştırın", + "something_went_wrong": "Bir şeyler ters gitti", + "piped_instance": "Piped Sunucu Örneği", + "piped_description": "Parça eşleştirme için kullanılacak Piped sunucu örneği", + "piped_warning": "Bazıları iyi çalışmayabilir. Bu yüzden riski size ait olmak üzere kullanın", + "generate_playlist": "Çalma Listesi Oluştur", + "track_exists": "Track {track} zaten mevcut", + "replace_downloaded_tracks": "İndirilen tüm parçaları değiştir", + "skip_download_tracks": "İndirilen tüm parçaları indirmeyi atla", + "do_you_want_to_replace": "Mevcut parçayı değiştirmek mi istiyorsunuz?", + "replace": "Değiştir", + "skip": "Atla", + "select_up_to_count_type": "En fazla {count} {type} seçin", + "select_genres": "Tür Seç", + "add_genres": "Tür Ekle", + "country": "Ülke", + "number_of_tracks_generate": "Oluşturulacak parça sayısı", + "acousticness": "Akustiklik", + "danceability": "Dansedilebilirlik", + "energy": "Enerji", + "instrumentalness": "Enstrümansallık", + "liveness": "Canlılık", + "loudness": "Yükseklik", + "speechiness": "Konuşkanlık", + "valence": "Değerlilik", + "popularity": "Popülerlik", + "key": "Anahtar", + "duration": "Süre (sn)", + "tempo": "Tempo (BPM)", + "mode": "Mod", + "time_signature": "Zaman İmzası", + "short": "Kısa", + "medium": "Orta", + "long": "Uzun", + "min": "Min", + "max": "Maks", + "target": "Hedef", + "moderate": "Orta", + "deselect_all": "Tüm Seçimleri Kaldır", + "select_all": "Tümünü Seç", + "are_you_sure": "Emin misiniz?", + "generating_playlist": "Özel çalma listenizi oluşturun...", + "selected_count_tracks": "Seçilen {count} parçalar", + "download_warning": "Tüm Parçaları toplu olarak indirirseniz, açıkça Müzik korsanlığı yapmış ve yaratıcı Müzik toplumuna zarar vermiş olursunuz. Umarım bunun farkındasınızdır. Her zaman, Sanatçıların sıkı çalışmalarına saygı duymayı ve desteklemeyi deneyin", + "download_ip_ban_warning": "Bu arada, normalden fazla indirme isteği nedeniyle IP adresiniz YouTube'da engellenebilir. IP engeli, o IP cihazından en az 2-3 ay boyunca YouTube'u (giriş yapmış olsanız bile) kullanamayacağınız anlamına gelir. Ve Spotube böyle bir durumda herhangi bir sorumluluk kabul etmez", + "by_clicking_accept_terms": "'Kabul et' seçeneğine tıklayarak aşağıdaki şartları kabul etmiş olursunuz:", + "download_agreement_1": "Müzik korsanlığı yaptığımı biliyorum. Ben malım.", + "download_agreement_2": "Sanatçıları elimden geldiğince destekleyeceğim ve bunu sadece sanatlarını satın alacak param olmadığı için yapıyorum", + "download_agreement_3": "IP adresimin YouTube'da engellenebileceğinin tamamen farkındayım ve mevcut eylemimin neden olduğu herhangi bir kazadan Spotube'u veya sahiplerini/dağıtıcılarını sorumlu tutmuyorum", + "decline": "Reddet", + "accept": "Kabul et", + "details": "Detaylar", + "youtube": "YouTube", + "channel": "Kanal", + "likes": "Beğeniler", + "dislikes": "Beğenmemeler", + "views": "İzlenmeler", + "streamUrl": "Yayın Bağlantısı", + "stop": "Dur", + "sort_newest": "En yeni eklenene göre sırala", + "sort_oldest": "En eski eklenene göre sırala", + "sleep_timer": "Uyku Zamanlayıcısı", + "mins": "{minutes} Dakikalar", + "hours": "{hours} Saat", + "hour": "{hours} Saatler", + "custom_hours": "Özel Saatler", + "logs": "Günlükler", + "developers": "Geliştiriciler", + "not_logged_in": "Giriş yapmadınız", + "search_mode": "Arama Modu", + "youtube_api_type": "API Türü", + "ok": "Tamam", + "failed_to_encrypt": "Şifreleme başarısız oldu", + "encryption_failed_warning": "Spotube, verilerinizi güvenli bir şekilde depolamak için şifreleme kullanır. Ancak bunu başaramadı. Bu nedenle güvensiz bir depolamaya geri dönecektir. Linux kullanıyorsanız, lütfen gnome-keyring, kde-wallet, keepassxc vb. gibi bir güvenlik hizmetinizin kurulu olduğundan emin olun.", + "querying_info": "Bilgi sorgulanıyor...", + "piped_api_down": "Piped API kapalı", + "piped_down_error_instructions": "Piped örneği {pipedInstance} şu anda kapalı\n\nYa örneği değiştirin ya da 'API türünü' resmi YouTube API'si olarak değiştirin\n\nDeğişiklikten sonra uygulamayı yeniden başlattığınızdan emin olun", + "you_are_offline": "Şu anda çevrimdışısınız", + "connection_restored": "İnternet bağlantınız yeniden kuruldu", + "use_system_title_bar": "Sistem başlık çubuğunu kullan", + "crunching_results": "Sonuçlar kırılıyor...", + "search_to_get_results": "Sonuç almak için arama yap", + "use_amoled_mode": "AMOLED modunu kullan", + "pitch_dark_theme": "Zifiri siyah dart teması", + "normalize_audio": "Sesi normalleştir", + "change_cover": "Kapağı değiştir", + "add_cover": "Kapak ekle", + "restore_defaults": "Varsayılanları geri yükle", + "download_music_codec": "Müzik codec bileşenini indirin", + "streaming_music_codec": "Müzik akışı codec bileşeni", + "login_with_lastfm": "Last.fm ile giriş yap", + "connect": "Bağlan", + "disconnect_lastfm": "Last.fm bağlantısını kes", + "disconnect": "Bağlantıyı Kes", + "username": "Kullanıcı Adı", + "password": "Şifre", + "login": "Giriş Yap", + "login_with_your_lastfm": "Last.fm hesabınız ile giriş yapın", + "scrobble_to_lastfm": "Last.fm için Scrobble" +} \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index e1e79422..d6cf3e37 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -7,25 +7,27 @@ /// TexturedPolak@github => Polish /// yuri-val@github => Ukrainian /// energywave@github, ncvescera@github, OpenCode@github => Italian +/// mdksec@github => Turkish import 'package:flutter/material.dart'; class L10n { static final all = [ const Locale('en'), + const Locale('ar', 'SA'), const Locale('bn', 'BD'), - const Locale('de', 'GE'), const Locale('ca', 'AD'), + const Locale('de', 'GE'), const Locale('es', 'ES'), const Locale("fa", "IR"), const Locale('fr', 'FR'), const Locale('hi', 'IN'), const Locale('it', 'IT'), const Locale('ja', 'JP'), - const Locale('zh', 'CN'), const Locale('pl', 'PL'), - const Locale('ru', 'RU'), const Locale('pt', 'PT'), + const Locale('ru', 'RU'), const Locale('uk', 'UA'), - const Locale('ar', 'SA'), + const Locale('tr', 'TR'), + const Locale('zh', 'CN'), ]; } diff --git a/lib/main.dart b/lib/main.dart index f8c3aa8c..c68b6bc6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ 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'; @@ -14,13 +15,14 @@ import 'package:metadata_god/metadata_god.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotube/collections/routes.dart'; import 'package:spotube/collections/intents.dart'; -import 'package:spotube/hooks/use_disable_battery_optimizations.dart'; +import 'package:spotube/hooks/configurators/use_disable_battery_optimizations.dart'; +import 'package:spotube/hooks/configurators/use_get_storage_perms.dart'; import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/models/logger.dart'; -import 'package:spotube/models/matched_track.dart'; import 'package:spotube/models/skip_segment.dart'; +import 'package:spotube/models/source_match.dart'; import 'package:spotube/provider/palette_provider.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/cli/cli.dart'; import 'package:spotube/services/connectivity_adapter.dart'; @@ -28,7 +30,7 @@ import 'package:spotube/themes/theme.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:system_theme/system_theme.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:spotube/hooks/use_init_sys_tray.dart'; +import 'package:spotube/hooks/configurators/use_init_sys_tray.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; @@ -62,6 +64,10 @@ Future main(List rawArgs) async { MetadataGod.initialize(); } + if (DesktopTools.platform.isWindows || DesktopTools.platform.isLinux) { + DiscordRPC.initialize(); + } + final hiveCacheDir = kIsWeb ? null : (await getApplicationSupportDirectory()).path; @@ -70,16 +76,18 @@ Future main(List rawArgs) async { cacheDir: hiveCacheDir, connectivity: FlQueryInternetConnectionCheckerAdapter(), ); - Hive.registerAdapter(MatchedTrackAdapter()); + Hive.registerAdapter(SkipSegmentAdapter()); - Hive.registerAdapter(SearchModeAdapter()); + + Hive.registerAdapter(SourceMatchAdapter()); + Hive.registerAdapter(SourceTypeAdapter()); // Cache versioning entities with Adapter - MatchedTrack.version = 'v1'; + SourceMatch.version = 'v1'; SkipSegment.version = 'v1'; - await Hive.openLazyBox( - MatchedTrack.boxName, + await Hive.openLazyBox( + SourceMatch.boxName, path: hiveCacheDir, ); await Hive.openLazyBox( @@ -181,6 +189,7 @@ class SpotubeState extends ConsumerState { }, []); useDisableBatteryOptimizations(); + useGetStoragePermissions(ref); final lightTheme = useMemoized( () => theme(paletteColor ?? accentMaterialColor, Brightness.light, false), @@ -192,7 +201,7 @@ class SpotubeState extends ConsumerState { Brightness.dark, isAmoledTheme, ), - [paletteColor, accentMaterialColor, isAmoledTheme], + ); return MaterialApp.router( diff --git a/lib/models/current_playlist.dart b/lib/models/current_playlist.dart index 1c3f8e16..53ea2799 100644 --- a/lib/models/current_playlist.dart +++ b/lib/models/current_playlist.dart @@ -1,6 +1,7 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/extensions/track.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; class CurrentPlaylist { List? _tempTrack; @@ -18,13 +19,13 @@ class CurrentPlaylist { this.isLocal = false, }); - static CurrentPlaylist fromJson(Map map) { + static CurrentPlaylist fromJson(Map map, Ref ref) { return CurrentPlaylist( id: map["id"], tracks: List.castFrom(map["tracks"] .map( (track) => map["isLocal"] == true - ? SpotubeTrack.fromJson(track) + ? SourcedTrack.fromJson(track, ref: ref) : Track.fromJson(track), ) .toList()), @@ -66,7 +67,7 @@ class CurrentPlaylist { "name": name, "tracks": tracks .map((track) => - track is SpotubeTrack ? track.toJson() : track.toJson()) + track is SourcedTrack ? track.toJson() : track.toJson()) .toList(), "thumbnail": thumbnail, "isLocal": isLocal, diff --git a/lib/models/local_track.dart b/lib/models/local_track.dart index e297e974..134cd327 100644 --- a/lib/models/local_track.dart +++ b/lib/models/local_track.dart @@ -1,6 +1,5 @@ import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/album_simple.dart'; -import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/extensions/track.dart'; class LocalTrack extends Track { final String path; @@ -38,22 +37,7 @@ class LocalTrack extends Track { Map toJson() { return { - "album": album?.toJson(), - "artists": artists?.map((artist) => artist.toJson()).toList(), - "availableMarkets": availableMarkets?.map((m) => m.name), - "discNumber": discNumber, - "duration": duration.toString(), - "durationMs": durationMs, - "explicit": explicit, - "href": href, - "id": id, - "isPlayable": isPlayable, - "name": name, - "popularity": popularity, - "previewUrl": previewUrl, - "trackNumber": trackNumber, - "type": type, - "uri": uri, + ...TrackJson.trackToJson(this), 'path': path, }; } diff --git a/lib/models/matched_track.dart b/lib/models/matched_track.dart deleted file mode 100644 index b7cc0a3f..00000000 --- a/lib/models/matched_track.dart +++ /dev/null @@ -1,69 +0,0 @@ -import "package:hive/hive.dart"; -part "matched_track.g.dart"; - -@HiveType(typeId: 1) -class MatchedTrack { - @HiveField(0) - String youtubeId; - @HiveField(1) - String spotifyId; - @HiveField(2) - SearchMode searchMode; - - String? id; - DateTime? createdAt; - - bool get isSynced => id != null; - - static String version = 'v1'; - static final boxName = "oss.krtirtho.spotube.matched_tracks.$version"; - - static LazyBox get box => Hive.lazyBox(boxName); - - MatchedTrack({ - required this.youtubeId, - required this.spotifyId, - required this.searchMode, - this.id, - this.createdAt, - }); - - factory MatchedTrack.fromJson(Map json) { - return MatchedTrack( - searchMode: SearchMode.fromString(json["searchMode"]), - youtubeId: json["youtube_id"], - spotifyId: json["spotify_id"], - id: json["id"], - createdAt: DateTime.parse(json["created_at"]), - ); - } - - Map toJson() { - return { - "youtube_id": youtubeId, - "spotify_id": spotifyId, - "id": id, - "searchMode": searchMode.name, - "created_at": createdAt?.toString() - }..removeWhere((key, value) => value == null); - } -} - -@HiveType(typeId: 4) -enum SearchMode { - @HiveField(0) - youtube._internal('YouTube'), - @HiveField(1) - youtubeMusic._internal('YouTube Music'); - - final String label; - - const SearchMode._internal(this.label); - - factory SearchMode.fromString(String value) { - return SearchMode.values.firstWhere( - (element) => element.name == value, - orElse: () => SearchMode.youtube, - ); - } -} diff --git a/lib/models/matched_track.g.dart b/lib/models/matched_track.g.dart deleted file mode 100644 index dd166e77..00000000 --- a/lib/models/matched_track.g.dart +++ /dev/null @@ -1,86 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'matched_track.dart'; - -// ************************************************************************** -// TypeAdapterGenerator -// ************************************************************************** - -class MatchedTrackAdapter extends TypeAdapter { - @override - final int typeId = 1; - - @override - MatchedTrack read(BinaryReader reader) { - final numOfFields = reader.readByte(); - final fields = { - for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), - }; - return MatchedTrack( - youtubeId: fields[0] as String, - spotifyId: fields[1] as String, - searchMode: fields[2] as SearchMode, - ); - } - - @override - void write(BinaryWriter writer, MatchedTrack obj) { - writer - ..writeByte(3) - ..writeByte(0) - ..write(obj.youtubeId) - ..writeByte(1) - ..write(obj.spotifyId) - ..writeByte(2) - ..write(obj.searchMode); - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is MatchedTrackAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} - -class SearchModeAdapter extends TypeAdapter { - @override - final int typeId = 4; - - @override - SearchMode read(BinaryReader reader) { - switch (reader.readByte()) { - case 0: - return SearchMode.youtube; - case 1: - return SearchMode.youtubeMusic; - default: - return SearchMode.youtube; - } - } - - @override - void write(BinaryWriter writer, SearchMode obj) { - switch (obj) { - case SearchMode.youtube: - writer.writeByte(0); - break; - case SearchMode.youtubeMusic: - writer.writeByte(1); - break; - } - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SearchModeAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} diff --git a/lib/models/source_match.dart b/lib/models/source_match.dart new file mode 100644 index 00000000..57a9f963 --- /dev/null +++ b/lib/models/source_match.dart @@ -0,0 +1,54 @@ +import 'package:hive/hive.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'source_match.g.dart'; + +@JsonEnum() +@HiveType(typeId: 5) +enum SourceType { + @HiveField(0) + youtube._("YouTube"), + + @HiveField(1) + youtubeMusic._("YouTube Music"), + + @HiveField(2) + jiosaavn._("JioSaavn"); + + final String label; + + const SourceType._(this.label); +} + +@JsonSerializable() +@HiveType(typeId: 6) +class SourceMatch { + @HiveField(0) + String id; + + @HiveField(1) + String sourceId; + + @HiveField(2) + SourceType sourceType; + + @HiveField(3) + DateTime createdAt; + + SourceMatch({ + required this.id, + required this.sourceId, + required this.sourceType, + required this.createdAt, + }); + + factory SourceMatch.fromJson(Map json) => + _$SourceMatchFromJson(json); + + Map toJson() => _$SourceMatchToJson(this); + + static String version = 'v1'; + static final boxName = "oss.krtirtho.spotube.source_matches.$version"; + + static LazyBox get box => Hive.lazyBox(boxName); +} diff --git a/lib/models/source_match.g.dart b/lib/models/source_match.g.dart new file mode 100644 index 00000000..11f34bf3 --- /dev/null +++ b/lib/models/source_match.g.dart @@ -0,0 +1,119 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'source_match.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class SourceMatchAdapter extends TypeAdapter { + @override + final int typeId = 6; + + @override + SourceMatch read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return SourceMatch( + id: fields[0] as String, + sourceId: fields[1] as String, + sourceType: fields[2] as SourceType, + createdAt: fields[3] as DateTime, + ); + } + + @override + void write(BinaryWriter writer, SourceMatch obj) { + writer + ..writeByte(4) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.sourceId) + ..writeByte(2) + ..write(obj.sourceType) + ..writeByte(3) + ..write(obj.createdAt); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SourceMatchAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class SourceTypeAdapter extends TypeAdapter { + @override + final int typeId = 5; + + @override + SourceType read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return SourceType.youtube; + case 1: + return SourceType.youtubeMusic; + case 2: + return SourceType.jiosaavn; + default: + return SourceType.youtube; + } + } + + @override + void write(BinaryWriter writer, SourceType obj) { + switch (obj) { + case SourceType.youtube: + writer.writeByte(0); + break; + case SourceType.youtubeMusic: + writer.writeByte(1); + break; + case SourceType.jiosaavn: + writer.writeByte(2); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SourceTypeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SourceMatch _$SourceMatchFromJson(Map json) => SourceMatch( + id: json['id'] as String, + sourceId: json['sourceId'] as String, + sourceType: $enumDecode(_$SourceTypeEnumMap, json['sourceType']), + createdAt: DateTime.parse(json['createdAt'] as String), + ); + +Map _$SourceMatchToJson(SourceMatch instance) => + { + 'id': instance.id, + 'sourceId': instance.sourceId, + 'sourceType': _$SourceTypeEnumMap[instance.sourceType]!, + 'createdAt': instance.createdAt.toIso8601String(), + }; + +const _$SourceTypeEnumMap = { + SourceType.youtube: 'youtube', + SourceType.youtubeMusic: 'youtubeMusic', + SourceType.jiosaavn: 'jiosaavn', +}; diff --git a/lib/models/spotube_track.dart b/lib/models/spotube_track.dart deleted file mode 100644 index a8b94ef5..00000000 --- a/lib/models/spotube_track.dart +++ /dev/null @@ -1,290 +0,0 @@ -import 'dart:async'; - -import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/album_simple.dart'; -import 'package:spotube/extensions/artist_simple.dart'; -import 'package:spotube/models/matched_track.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/services/youtube/youtube.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:collection/collection.dart'; - -final officialMusicRegex = RegExp( - r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)", - caseSensitive: false, -); - -class TrackNotFoundException implements Exception { - factory TrackNotFoundException(Track track) { - throw Exception("Failed to find any results for ${track.name}"); - } -} - -class SpotubeTrack extends Track { - final YoutubeVideoInfo ytTrack; - final String ytUri; - final MusicCodec codec; - - final List siblings; - - SpotubeTrack( - this.ytTrack, - this.ytUri, - this.siblings, - this.codec, - ) : super(); - - SpotubeTrack.fromTrack({ - required Track track, - required this.ytTrack, - required this.ytUri, - required this.siblings, - required this.codec, - }) : super() { - album = track.album; - artists = track.artists; - availableMarkets = track.availableMarkets; - discNumber = track.discNumber; - durationMs = track.durationMs; - explicit = track.explicit; - externalIds = track.externalIds; - externalUrls = track.externalUrls; - href = track.href; - id = track.id; - isPlayable = track.isPlayable; - linkedFrom = track.linkedFrom; - name = track.name; - popularity = track.popularity; - previewUrl = track.previewUrl; - trackNumber = track.trackNumber; - type = track.type; - uri = track.uri; - } - - static Future> fetchSiblings( - Track track, - YoutubeEndpoints client, - ) async { - final artists = (track.artists ?? []) - .map((ar) => ar.name) - .toList() - .whereNotNull() - .toList(); - - final title = ServiceUtils.getTitle( - track.name!, - artists: artists, - onlyCleanArtist: true, - ).trim(); - - final query = "$title - ${artists.join(", ")}"; - final List siblings = await client.search(query).then( - (res) { - final isYoutubeApi = - client.preferences.youtubeApiType == YoutubeApiType.youtube; - final siblings = isYoutubeApi || - client.preferences.searchMode == SearchMode.youtube - ? ServiceUtils.onlyContainsEnglish(query) - ? res - : res - .sorted((a, b) => b.views.compareTo(a.views)) - .map((sibling) { - int score = 0; - - for (final artist in artists) { - final isSameChannelArtist = - sibling.channelName.toLowerCase() == - artist.toLowerCase(); - final channelContainsArtist = sibling.channelName - .toLowerCase() - .contains(artist.toLowerCase()); - - if (isSameChannelArtist || channelContainsArtist) { - score += 1; - } - - final titleContainsArtist = sibling.title - .toLowerCase() - .contains(artist.toLowerCase()); - - if (titleContainsArtist) { - score += 1; - } - } - - final titleContainsTrackName = sibling.title - .toLowerCase() - .contains(track.name!.toLowerCase()); - - final hasOfficialFlag = officialMusicRegex - .hasMatch(sibling.title.toLowerCase()); - - if (titleContainsTrackName) { - score += 3; - } - - if (hasOfficialFlag) { - score += 1; - } - - if (hasOfficialFlag && titleContainsTrackName) { - score += 2; - } - - return (sibling: sibling, score: score); - }) - .sorted((a, b) => b.score.compareTo(a.score)) - .map((e) => e.sibling) - : res.sorted((a, b) => b.views.compareTo(a.views)).where((item) { - return artists.any( - (artist) => - artist.toLowerCase() == item.channelName.toLowerCase(), - ); - }); - - return siblings.take(10).toList(); - }, - ); - - return siblings; - } - - static Future fetchFromTrack( - Track track, - YoutubeEndpoints client, - MusicCodec codec, - ) async { - final matchedCachedTrack = await MatchedTrack.box.get(track.id!); - var siblings = []; - YoutubeVideoInfo ytVideo; - String ytStreamUrl; - if (matchedCachedTrack != null && - matchedCachedTrack.searchMode == client.preferences.searchMode) { - (ytVideo, ytStreamUrl) = await client.video( - matchedCachedTrack.youtubeId, matchedCachedTrack.searchMode, codec); - } else { - siblings = await fetchSiblings(track, client); - if (siblings.isEmpty) { - throw TrackNotFoundException(track); - } - (ytVideo, ytStreamUrl) = await client.video( - siblings.first.id, - siblings.first.searchMode, - codec, - ); - - await MatchedTrack.box.put( - track.id!, - MatchedTrack( - youtubeId: ytVideo.id, - spotifyId: track.id!, - searchMode: siblings.first.searchMode, - ), - ); - } - - return SpotubeTrack.fromTrack( - track: track, - ytTrack: ytVideo, - ytUri: ytStreamUrl, - siblings: siblings, - codec: codec, - ); - } - - Future swappedCopy( - YoutubeVideoInfo video, - YoutubeEndpoints client, - ) async { - // sibling tracks that were manually searched and swapped - final isStepSibling = siblings.none((element) => element.id == video.id); - - final (ytVideo, ytStreamUrl) = await client.video( - video.id, - siblings.first.searchMode, - // siblings are always swapped when streaming - client.preferences.streamMusicCodec, - ); - - if (!isStepSibling) { - await MatchedTrack.box.put( - id!, - MatchedTrack( - youtubeId: video.id, - spotifyId: id!, - searchMode: siblings.first.searchMode, - ), - ); - } - - return SpotubeTrack.fromTrack( - track: this, - ytTrack: ytVideo, - ytUri: ytStreamUrl, - siblings: [ - video, - ...siblings.where((element) => element.id != video.id), - ], - codec: client.preferences.streamMusicCodec, - ); - } - - static SpotubeTrack fromJson(Map map) { - return SpotubeTrack.fromTrack( - track: Track.fromJson(map), - ytTrack: YoutubeVideoInfo.fromJson(map["ytTrack"]), - ytUri: map["ytUri"], - siblings: List.castFrom>(map["siblings"]) - .map((sibling) => YoutubeVideoInfo.fromJson(sibling)) - .toList(), - codec: MusicCodec.values.firstWhere( - (element) => element.name == map["codec"], - orElse: () => MusicCodec.m4a, - ), - ); - } - - Future populatedCopy(YoutubeEndpoints client) async { - if (this.siblings.isNotEmpty) return this; - - final siblings = await fetchSiblings( - this, - client, - ); - - return SpotubeTrack.fromTrack( - track: this, - ytTrack: ytTrack, - ytUri: ytUri, - siblings: siblings, - codec: codec, - ); - } - - Map toJson() { - return { - // super values - "album": album?.toJson(), - "artists": artists?.map((artist) => artist.toJson()).toList(), - "availableMarkets": availableMarkets?.map((m) => m.name), - "discNumber": discNumber, - "duration": duration.toString(), - "durationMs": durationMs, - "explicit": explicit, - "href": href, - "id": id, - "isPlayable": isPlayable, - "name": name, - "popularity": popularity, - "previewUrl": previewUrl, - "trackNumber": trackNumber, - "type": type, - "uri": uri, - // this values - "ytTrack": ytTrack.toJson(), - "ytUri": ytUri, - "siblings": siblings.map((sibling) => sibling.toJson()).toList(), - "codec": codec.name, - }; - } -} diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index a585c9e5..72f9a9af 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -1,157 +1,79 @@ +import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/heart_button.dart'; -import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart'; -import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.dart'; -import 'package:spotube/components/shared/track_table/tracks_table_view.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/models/spotube_track.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/components/shared/tracks_view/track_view.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/infinite_query.dart'; +import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/services/mutations/mutations.dart'; import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class AlbumPage extends HookConsumerWidget { final AlbumSimple album; - const AlbumPage(this.album, {Key? key}) : super(key: key); - - Future playPlaylist( - List tracks, - WidgetRef ref, { - Track? currentTrack, - }) async { - final playlist = ref.read(ProxyPlaylistNotifier.provider); - final playback = ref.read(ProxyPlaylistNotifier.notifier); - final sortBy = ref.read(trackCollectionSortState(album.id!)); - final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy); - currentTrack ??= sortedTracks.first; - final isAlbumPlaying = playlist.containsTracks(tracks); - if (!isAlbumPlaying) { - playback.addCollection(album.id!); // for enabling loading indicator - await playback.load( - sortedTracks, - initialIndex: sortedTracks.indexWhere((s) => s.id == currentTrack?.id), - ); - playback.addCollection(album.id!); - } else if (isAlbumPlaying && - currentTrack.id != null && - currentTrack.id != playlist.activeTrack?.id) { - await playback.jumpToTrack(currentTrack); - } - } + const AlbumPage({ + Key? key, + required this.album, + }) : super(key: key); @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playback = ref.watch(ProxyPlaylistNotifier.notifier); + final spotify = ref.watch(spotifyProvider); + final tracksQuery = useQueries.album.tracksOf(ref, album); - final tracksSnapshot = useQueries.album.tracksOf(ref, album.id!); + final tracks = useMemoized(() { + return tracksQuery.pages.expand((element) => element).toList(); + }, [tracksQuery.pages]); - final albumArt = useMemoized( - () => TypeConversionUtils.image_X_UrlString( - album.images, - placeholder: ImagePlaceholder.albumArt, - ), - [album.images]); + final client = useQueryClient(); - final mediaQuery = MediaQuery.of(context); + final albumIsSaved = useQueries.album.isSavedForMe(ref, album.id!); + final isLiked = albumIsSaved.data ?? false; - final isAlbumPlaying = useMemoized( - () => playlist.collections.contains(album.id!), - [playlist, album], - ); - - final albumTrackPlaying = useMemoized( - () => - tracksSnapshot.data?.any((s) => s.id! == playlist.activeTrack?.id!) == - true && - playlist.activeTrack is SpotubeTrack, - [playlist.activeTrack, tracksSnapshot.data], - ); - - return TrackCollectionView( - id: album.id!, - playingState: isAlbumPlaying && albumTrackPlaying - ? PlayButtonState.playing - : isAlbumPlaying && !albumTrackPlaying - ? PlayButtonState.loading - : PlayButtonState.notPlaying, - title: album.name!, - titleImage: albumArt, - tracksSnapshot: tracksSnapshot, - album: album, - routePath: "/album/${album.id}", - bottomSpace: mediaQuery.mdAndDown, - onPlay: ([track]) async { - if (tracksSnapshot.hasData) { - if (!isAlbumPlaying) { - await playPlaylist( - tracksSnapshot.data! - .map((track) => - TypeConversionUtils.simpleTrack_X_Track(track, album)) - .toList(), - ref, - ); - } else if (isAlbumPlaying && track != null) { - await playPlaylist( - tracksSnapshot.data! - .map((track) => - TypeConversionUtils.simpleTrack_X_Track(track, album)) - .toList(), - currentTrack: track, - ref, - ); - } else { - await playback - .removeTracks(tracksSnapshot.data!.map((track) => track.id!)); - } - } + final toggleAlbumLike = useMutations.album.toggleFavorite( + ref, + album.id!, + refreshQueries: [albumIsSaved.key], + onData: (_, __) async { + await client.refreshInfiniteQueryAllPages("current-user-albums"); }, - onAddToQueue: () { - if (tracksSnapshot.hasData && !isAlbumPlaying) { - playback.addTracks( - tracksSnapshot.data! + ); + + return InheritedTrackView( + collectionId: album.id!, + image: TypeConversionUtils.image_X_UrlString( + album.images, + placeholder: ImagePlaceholder.albumArt, + ), + title: album.name!, + description: + "${context.l10n.released} • ${album.releaseDate} • ${album.artists!.first.name}", + tracks: tracks, + pagination: PaginationProps.fromQuery( + tracksQuery, + onFetchAll: () { + return tracksQuery.fetchAllTracks(getAllTracks: () async { + final res = await spotify.albums.tracks(album.id!).all(); + + return res .map((track) => TypeConversionUtils.simpleTrack_X_Track(track, album)) - .toList(), - ); - playback.addCollection(album.id!); - } - }, - onShare: () { - Clipboard.setData( - ClipboardData(text: "https://open.spotify.com/album/${album.id}"), - ); - }, - heartBtn: AlbumHeartButton(album: album), - onShuffledPlay: ([track]) { - // Shuffle the tracks (create a copy of playlist) - if (tracksSnapshot.hasData) { - final tracks = tracksSnapshot.data! - .map((track) => - TypeConversionUtils.simpleTrack_X_Track(track, album)) - .toList() - ..shuffle(); - if (!isAlbumPlaying) { - playPlaylist( - tracks, - ref, - ); - } else if (isAlbumPlaying && track != null) { - playPlaylist( - tracks, - ref, - currentTrack: track, - ); - } else { - // TODO: Disable ability to stop playback from playlist/album - // playback.stop(); - } - } - }, + .toList(); + }); + }, + ), + routePath: "/album/${album.id}", + shareUrl: album.externalUrls!.spotify!, + isLiked: isLiked, + onHeart: albumIsSaved.hasData + ? () { + toggleAlbumLike.mutate(isLiked); + } + : null, + child: const TrackView(), ); } } diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index 67a99d86..693e825b 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -1,32 +1,19 @@ -import 'package:collection/collection.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_artist_profile.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/shared/track_table/track_tile.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/artist/artist_album_list.dart'; -import 'package:spotube/components/artist/artist_card.dart'; -import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/components/shared/shimmers/shimmer_artist_profile.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_breakpoint_value.dart'; import 'package:spotube/models/logger.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/pages/artist/section/footer.dart'; +import 'package:spotube/pages/artist/section/header.dart'; +import 'package:spotube/pages/artist/section/related_artists.dart'; +import 'package:spotube/pages/artist/section/top_tracks.dart'; import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/primitive_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - class ArtistPage extends HookConsumerWidget { final String artistId; final logger = getLogger(ArtistPage); @@ -34,434 +21,61 @@ class ArtistPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - SpotifyApi spotify = ref.watch(spotifyProvider); - final parentScrollController = useScrollController(); + final scrollController = useScrollController(); final theme = Theme.of(context); - final scaffoldMessenger = ScaffoldMessenger.of(context); - final textTheme = theme.textTheme; - final chipTextVariant = useBreakpointValue( - xs: textTheme.bodySmall, - sm: textTheme.bodySmall, - md: textTheme.bodyMedium, - lg: textTheme.bodyLarge, - xl: textTheme.titleSmall, - xxl: textTheme.titleMedium, - ); - final mediaQuery = MediaQuery.of(context); - - final avatarWidth = useBreakpointValue( - xs: mediaQuery.size.width * 0.50, - sm: mediaQuery.size.width * 0.50, - md: mediaQuery.size.width * 0.40, - lg: mediaQuery.size.width * 0.18, - xl: mediaQuery.size.width * 0.18, - xxl: mediaQuery.size.width * 0.18, - ); - - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - - final auth = ref.watch(AuthenticationNotifier.provider); - - final queryClient = useQueryClient(); + final artistQuery = useQueries.artist.get(ref, artistId); return SafeArea( bottom: false, child: Scaffold( appBar: const PageWindowTitleBar( leading: BackButton(), + backgroundColor: Colors.transparent, ), - body: HookBuilder( - builder: (context) { - final artistsQuery = useQueries.artist.get(ref, artistId); - - if (artistsQuery.isLoading || !artistsQuery.hasData) { - return const ShimmerArtistProfile(); - } else if (artistsQuery.hasError) { - return Center( - child: Text(artistsQuery.error.toString()), - ); - } - - final data = artistsQuery.data!; - - final blacklist = ref.watch(BlackListNotifier.provider); - final isBlackListed = blacklist.contains( - BlacklistedElement.artist(artistId, data.name!), - ); - - return InterScrollbar( - controller: parentScrollController, - child: SingleChildScrollView( - controller: parentScrollController, + extendBodyBehindAppBar: true, + body: Builder(builder: (context) { + if (artistQuery.isLoading || !artistQuery.hasData) { + const ShimmerArtistProfile(); + } else if (artistQuery.hasError) { + return Center(child: Text(artistQuery.error.toString())); + } + return CustomScrollView( + controller: scrollController, + slivers: [ + SliverToBoxAdapter( child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - runAlignment: WrapAlignment.center, - children: [ - const SizedBox(width: 50), - Padding( - padding: const EdgeInsets.all(16), - child: CircleAvatar( - radius: avatarWidth, - backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - data.images, - placeholder: ImagePlaceholder.artist, - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.blue, - borderRadius: - BorderRadius.circular(50)), - child: Text( - data.type!.toUpperCase(), - style: chipTextVariant.copyWith( - color: Colors.white, - ), - ), - ), - if (isBlackListed) ...[ - const SizedBox(width: 5), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.red[400], - borderRadius: - BorderRadius.circular(50)), - child: Text( - context.l10n.blacklisted, - style: chipTextVariant.copyWith( - color: Colors.white, - ), - ), - ), - ] - ], - ), - Text( - data.name!, - style: mediaQuery.smAndDown - ? textTheme.headlineSmall - : textTheme.headlineMedium, - ), - Text( - context.l10n.followers( - PrimitiveUtils.toReadableNumber( - data.followers!.total!.toDouble(), - ), - ), - style: textTheme.bodyMedium?.copyWith( - fontWeight: mediaQuery.mdAndUp - ? FontWeight.bold - : null, - ), - ), - const SizedBox(height: 20), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (auth != null) - HookBuilder( - builder: (context) { - final isFollowingQuery = useQueries - .artist - .doIFollow(ref, artistId); - - final followUnfollow = - useCallback(() async { - try { - isFollowingQuery.data! - ? await spotify.me.unfollow( - FollowingType.artist, - [artistId], - ) - : await spotify.me.follow( - FollowingType.artist, - [artistId], - ); - await isFollowingQuery.refresh(); - - queryClient - .refreshInfiniteQueryAllPages( - "user-following-artists"); - } finally { - queryClient.refreshQuery( - "user-follows-artists-query/$artistId", - ); - } - }, [isFollowingQuery]); - - if (isFollowingQuery.isLoading || - !isFollowingQuery.hasData) { - return const SizedBox( - height: 20, - width: 20, - child: - CircularProgressIndicator(), - ); - } - - if (isFollowingQuery.data!) { - return OutlinedButton( - onPressed: followUnfollow, - child: - Text(context.l10n.following), - ); - } - - return FilledButton( - onPressed: followUnfollow, - child: Text(context.l10n.follow), - ); - }, - ), - const SizedBox(width: 5), - IconButton( - tooltip: - context.l10n.add_artist_to_blacklist, - icon: Icon( - SpotubeIcons.userRemove, - color: !isBlackListed - ? Colors.red[400] - : Colors.white, - ), - style: IconButton.styleFrom( - backgroundColor: isBlackListed - ? Colors.red[400] - : null, - ), - onPressed: () async { - if (isBlackListed) { - ref - .read(BlackListNotifier - .provider.notifier) - .remove( - BlacklistedElement.artist( - data.id!, data.name!), - ); - } else { - ref - .read(BlackListNotifier - .provider.notifier) - .add( - BlacklistedElement.artist( - data.id!, data.name!), - ); - } - }, - ), - IconButton( - icon: const Icon(SpotubeIcons.share), - onPressed: () async { - if (data.externalUrls?.spotify != - null) { - await Clipboard.setData( - ClipboardData( - text: data.externalUrls!.spotify!, - ), - ); - } - - if (!context.mounted) return; - - scaffoldMessenger.showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - context.l10n.artist_url_copied, - textAlign: TextAlign.center, - ), - ), - ); - }, - ) - ], - ) - ], - ), - ), - ], - ), - const SizedBox(height: 50), - HookBuilder( - builder: (context) { - final topTracksQuery = useQueries.artist.topTracksOf( - ref, - artistId, - ); - - final isPlaylistPlaying = playlist.containsTracks( - topTracksQuery.data ?? [], - ); - - if (topTracksQuery.isLoading || - !topTracksQuery.hasData) { - return const CircularProgressIndicator(); - } else if (topTracksQuery.hasError) { - return Center( - child: Text(topTracksQuery.error.toString()), - ); - } - - final topTracks = topTracksQuery.data!; - - void playPlaylist(List tracks, - {Track? currentTrack}) async { - currentTrack ??= tracks.first; - if (!isPlaylistPlaying) { - playlistNotifier.load( - tracks, - initialIndex: tracks.indexWhere( - (s) => s.id == currentTrack?.id), - autoPlay: true, - ); - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != playlist.activeTrack?.id) { - await playlistNotifier.jumpToTrack(currentTrack); - } - } - - return Column( - children: [ - Row( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.l10n.top_tracks, - style: theme.textTheme.headlineSmall, - ), - ), - if (!isPlaylistPlaying) - IconButton( - icon: const Icon( - SpotubeIcons.queueAdd, - ), - onPressed: () { - playlistNotifier - .addTracks(topTracks.toList()); - scaffoldMessenger.showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - context.l10n.added_to_queue( - topTracks.length, - ), - textAlign: TextAlign.center, - ), - ), - ); - }, - ), - const SizedBox(width: 5), - IconButton( - icon: Icon( - isPlaylistPlaying - ? SpotubeIcons.stop - : SpotubeIcons.play, - color: Colors.white, - ), - style: IconButton.styleFrom( - backgroundColor: - theme.colorScheme.primary, - ), - onPressed: () => - playPlaylist(topTracks.toList()), - ) - ], - ), - ...topTracks.mapIndexed((i, track) { - return TrackTile( - index: i, - track: track, - onTap: () async { - playPlaylist( - topTracks.toList(), - currentTrack: track, - ); - }, - ); - }), - ], - ); - }, - ), - const SizedBox(height: 50), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.l10n.albums, - style: theme.textTheme.headlineSmall, - ), - ), - ArtistAlbumList(artistId), - const SizedBox(height: 20), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.l10n.fans_also_like, - style: theme.textTheme.headlineSmall, - ), - ), - const SizedBox(height: 10), - HookBuilder( - builder: (context) { - final relatedArtists = - useQueries.artist.relatedArtistsOf( - ref, - artistId, - ); - - if (relatedArtists.isLoading || - !relatedArtists.hasData) { - return const CircularProgressIndicator(); - } else if (relatedArtists.hasError) { - return Center( - child: Text(relatedArtists.error.toString()), - ); - } - - return Center( - child: Wrap( - spacing: 20, - runSpacing: 20, - children: relatedArtists.data! - .map((artist) => ArtistCard(artist)) - .toList(), - ), - ); - }, - ), - ], + bottom: false, + child: ArtistPageHeader(artistId: artistId), + ), + ), + const SliverGap(50), + ArtistPageTopTracks(artistId: artistId), + const SliverGap(50), + SliverToBoxAdapter(child: ArtistAlbumList(artistId)), + const SliverGap(20), + SliverPadding( + padding: const EdgeInsets.all(8.0), + sliver: SliverToBoxAdapter( + child: Text( + context.l10n.fans_also_like, + style: theme.textTheme.headlineSmall, ), ), ), - ); - }, - ), + SliverSafeArea( + sliver: ArtistPageRelatedArtists(artistId: artistId), + ), + if (artistQuery.data != null) + SliverSafeArea( + top: false, + sliver: SliverToBoxAdapter( + child: ArtistPageFooter(artist: artistQuery.data!), + ), + ), + ], + ); + }), ), ); } diff --git a/lib/pages/artist/section/footer.dart b/lib/pages/artist/section/footer.dart new file mode 100644 index 00000000..b01ef705 --- /dev/null +++ b/lib/pages/artist/section/footer.dart @@ -0,0 +1,93 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class ArtistPageFooter extends HookConsumerWidget { + final Artist artist; + const ArtistPageFooter({Key? key, required this.artist}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:textTheme) = Theme.of(context); + final mediaQuery = MediaQuery.of(context); + + final artistImage = TypeConversionUtils.image_X_UrlString( + artist.images, + placeholder: ImagePlaceholder.artist, + ); + final summary = useQueries.artist.wikipediaSummary(artist); + if (summary.hasError || !summary.hasData) return const SizedBox.shrink(); + return Container( + margin: const EdgeInsets.all(16), + padding: mediaQuery.smAndDown + ? const EdgeInsets.all(20) + : const EdgeInsets.all(30), + constraints: const BoxConstraints(minHeight: 300), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + image: DecorationImage( + colorFilter: ColorFilter.mode( + Colors.black.withOpacity(0.5), + BlendMode.darken, + ), + image: UniversalImage.imageProvider( + summary.data!.thumbnail?.source_ ?? artistImage, + height: summary.data!.thumbnail?.height.toDouble(), + width: summary.data!.thumbnail?.width.toDouble(), + ), + fit: BoxFit.cover, + alignment: Alignment.center, + ), + ), + alignment: Alignment.center, + child: RichText( + text: TextSpan( + style: textTheme.bodyLarge?.copyWith( + color: Colors.white, + ), + children: [ + // icon + const WidgetSpan( + child: Icon( + SpotubeIcons.wikipedia, + color: Colors.white, + size: 30, + ), + ), + TextSpan( + text: " Wikipedia", + style: textTheme.titleLarge?.copyWith( + color: Colors.white, + ), + ), + const TextSpan(text: '\n\n'), + TextSpan( + text: summary.data!.extract, + ), + TextSpan( + text: '\n...read more at wikipedia', + style: textTheme.bodyLarge?.copyWith( + color: Colors.lightBlue[300], + decoration: TextDecoration.underline, + decorationColor: Colors.lightBlue[300], + ), + recognizer: TapGestureRecognizer() + ..onTap = () async { + await launchUrlString( + "http://en.wikipedia.org/wiki?curid=${summary.data?.pageid}", + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart new file mode 100644 index 00000000..9fc9d78e --- /dev/null +++ b/lib/pages/artist/section/header.dart @@ -0,0 +1,257 @@ +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:spotify/spotify.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; +import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/blacklist_provider.dart'; +import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/utils/primitive_utils.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; + +class ArtistPageHeader extends HookConsumerWidget { + final String artistId; + const ArtistPageHeader({Key? key, required this.artistId}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final queryClient = useQueryClient(); + final artistQuery = useQueries.artist.get(ref, artistId); + final artist = artistQuery.data; + + final scaffoldMessenger = ScaffoldMessenger.of(context); + final mediaQuery = MediaQuery.of(context); + final theme = Theme.of(context); + final ThemeData(:textTheme) = theme; + + final chipTextVariant = useBreakpointValue( + xs: textTheme.bodySmall, + sm: textTheme.bodySmall, + md: textTheme.bodyMedium, + lg: textTheme.bodyLarge, + xl: textTheme.titleSmall, + xxl: textTheme.titleMedium, + ); + + if (artist == null) { + return const SizedBox.shrink(); + } + + final spotify = ref.read(spotifyProvider); + final auth = ref.watch(AuthenticationNotifier.provider); + final blacklist = ref.watch(BlackListNotifier.provider); + final isBlackListed = blacklist.contains( + BlacklistedElement.artist(artistId, artist.name!), + ); + + final image = TypeConversionUtils.image_X_UrlString( + artist.images, + placeholder: ImagePlaceholder.artist, + ); + + return LayoutBuilder( + builder: (context, constrains) { + return Center( + child: Flex( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: constrains.smAndDown + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + direction: constrains.smAndDown ? Axis.vertical : Axis.horizontal, + children: [ + DecoratedBox( + decoration: BoxDecoration( + boxShadow: kElevationToShadow[2], + borderRadius: BorderRadius.circular(35), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(35), + child: UniversalImage( + path: image, + width: 250, + height: 250, + fit: BoxFit.cover, + ), + ), + ), + const Gap(20), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.blue, + borderRadius: BorderRadius.circular(50)), + child: Text( + artist.type!.toUpperCase(), + style: chipTextVariant.copyWith( + color: Colors.white, + ), + ), + ), + if (isBlackListed) ...[ + const SizedBox(width: 5), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.red[400], + borderRadius: BorderRadius.circular(50)), + child: Text( + context.l10n.blacklisted, + style: chipTextVariant.copyWith( + color: Colors.white, + ), + ), + ), + ] + ], + ), + Text( + artist.name!, + style: mediaQuery.smAndDown + ? textTheme.headlineSmall + : textTheme.headlineMedium, + ), + Text( + context.l10n.followers( + PrimitiveUtils.toReadableNumber( + artist.followers!.total!.toDouble(), + ), + ), + style: textTheme.bodyMedium?.copyWith( + fontWeight: mediaQuery.mdAndUp ? FontWeight.bold : null, + ), + ), + const Gap(20), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (auth != null) + HookBuilder( + builder: (context) { + final isFollowingQuery = + useQueries.artist.doIFollow(ref, artistId); + + final followUnfollow = useCallback(() async { + try { + isFollowingQuery.data! + ? await spotify.me.unfollow( + FollowingType.artist, + [artistId], + ) + : await spotify.me.follow( + FollowingType.artist, + [artistId], + ); + await isFollowingQuery.refresh(); + + queryClient.refreshInfiniteQueryAllPages( + "user-following-artists"); + } finally { + queryClient.refreshQuery( + "user-follows-artists-query/$artistId", + ); + } + }, [isFollowingQuery]); + + if (isFollowingQuery.isLoading || + !isFollowingQuery.hasData) { + return const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(), + ); + } + + if (isFollowingQuery.data!) { + return OutlinedButton( + onPressed: followUnfollow, + child: Text(context.l10n.following), + ); + } + + return FilledButton( + onPressed: followUnfollow, + child: Text(context.l10n.follow), + ); + }, + ), + const SizedBox(width: 5), + IconButton( + tooltip: context.l10n.add_artist_to_blacklist, + icon: Icon( + SpotubeIcons.userRemove, + color: + !isBlackListed ? Colors.red[400] : Colors.white, + ), + style: IconButton.styleFrom( + backgroundColor: + isBlackListed ? Colors.red[400] : null, + ), + onPressed: () async { + if (isBlackListed) { + ref + .read(BlackListNotifier.provider.notifier) + .remove( + BlacklistedElement.artist( + artist.id!, artist.name!), + ); + } else { + ref.read(BlackListNotifier.provider.notifier).add( + BlacklistedElement.artist( + artist.id!, artist.name!), + ); + } + }, + ), + IconButton( + icon: const Icon(SpotubeIcons.share), + onPressed: () async { + if (artist.externalUrls?.spotify != null) { + await Clipboard.setData( + ClipboardData( + text: artist.externalUrls!.spotify!, + ), + ); + } + + if (!context.mounted) return; + + scaffoldMessenger.showSnackBar( + SnackBar( + width: 300, + behavior: SnackBarBehavior.floating, + content: Text( + context.l10n.artist_url_copied, + textAlign: TextAlign.center, + ), + ), + ); + }, + ) + ], + ) + ], + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/pages/artist/section/related_artists.dart b/lib/pages/artist/section/related_artists.dart new file mode 100644 index 00000000..2938c084 --- /dev/null +++ b/lib/pages/artist/section/related_artists.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/artist/artist_card.dart'; +import 'package:spotube/services/queries/queries.dart'; + +class ArtistPageRelatedArtists extends HookConsumerWidget { + final String artistId; + const ArtistPageRelatedArtists({ + Key? key, + required this.artistId, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final relatedArtists = useQueries.artist.relatedArtistsOf( + ref, + artistId, + ); + + if (relatedArtists.isLoading || !relatedArtists.hasData) { + return const SliverToBoxAdapter( + child: Center(child: CircularProgressIndicator())); + } else if (relatedArtists.hasError) { + return SliverToBoxAdapter( + child: Center( + child: Text(relatedArtists.error.toString()), + ), + ); + } + + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + sliver: SliverGrid.builder( + itemCount: relatedArtists.data!.length, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: 250, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 0.8, + ), + itemBuilder: (context, index) { + final artist = relatedArtists.data!.elementAt(index); + return ArtistCard(artist); + }, + ), + ); + } +} diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart new file mode 100644 index 00000000..9e3e4054 --- /dev/null +++ b/lib/pages/artist/section/top_tracks.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/track_tile/track_tile.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/services/queries/queries.dart'; + +class ArtistPageTopTracks extends HookConsumerWidget { + final String artistId; + const ArtistPageTopTracks({Key? key, required this.artistId}) + : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final theme = Theme.of(context); + final scaffoldMessenger = ScaffoldMessenger.of(context); + + final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final topTracksQuery = useQueries.artist.topTracksOf( + ref, + artistId, + ); + + final isPlaylistPlaying = playlist.containsTracks( + topTracksQuery.data ?? [], + ); + + if (topTracksQuery.isLoading || !topTracksQuery.hasData) { + return const SliverToBoxAdapter( + child: Center(child: CircularProgressIndicator()), + ); + } else if (topTracksQuery.hasError) { + return SliverToBoxAdapter( + child: Center( + child: Text(topTracksQuery.error.toString()), + ), + ); + } + + final topTracks = topTracksQuery.data!; + + void playPlaylist(List tracks, {Track? currentTrack}) async { + currentTrack ??= tracks.first; + if (!isPlaylistPlaying) { + playlistNotifier.load( + tracks, + initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), + autoPlay: true, + ); + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != playlist.activeTrack?.id) { + await playlistNotifier.jumpToTrack(currentTrack); + } + } + + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: Row( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + context.l10n.top_tracks, + style: theme.textTheme.headlineSmall, + ), + ), + if (!isPlaylistPlaying) + IconButton( + icon: const Icon( + SpotubeIcons.queueAdd, + ), + onPressed: () { + playlistNotifier.addTracks(topTracks.toList()); + scaffoldMessenger.showSnackBar( + SnackBar( + width: 300, + behavior: SnackBarBehavior.floating, + content: Text( + context.l10n.added_to_queue( + topTracks.length, + ), + textAlign: TextAlign.center, + ), + ), + ); + }, + ), + const SizedBox(width: 5), + IconButton( + icon: Icon( + isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play, + color: Colors.white, + ), + style: IconButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + ), + onPressed: () => playPlaylist(topTracks.toList()), + ) + ], + ), + ), + SliverList.builder( + itemCount: topTracks.length, + itemBuilder: (context, index) { + final track = topTracks.elementAt(index); + return TrackTile( + index: index, + track: track, + onTap: () async { + playPlaylist( + topTracks.toList(), + currentTrack: track, + ); + }, + ); + }, + ), + ], + ); + } +} diff --git a/lib/pages/home/genres.dart b/lib/pages/home/genres.dart index b4e3c664..54fb6786 100644 --- a/lib/pages/home/genres.dart +++ b/lib/pages/home/genres.dart @@ -7,12 +7,12 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/genre/category_card.dart'; import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/shimmers/shimmer_categories.dart'; import 'package:spotube/components/shared/waypoint.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/queries/queries.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; class GenrePage extends HookConsumerWidget { const GenrePage({Key? key}) : super(key: key); @@ -70,32 +70,26 @@ class GenrePage extends HookConsumerWidget { child: Column( children: [ ExpandableSearchField( - isFiltering: isFiltering, + isFiltering: isFiltering.value, + onChangeFiltering: (value) => isFiltering.value = value, searchController: searchController, searchFocus: searchFocus, ), - if (!categoriesQuery.hasPageData) + if (!categoriesQuery.hasPageData && + !categoriesQuery.isLoadingNextPage) const ShimmerCategories() else Expanded( - child: InterScrollbar( - controller: scrollController, - child: ListView.builder( - controller: scrollController, - itemCount: categories.length, - itemBuilder: (context, index) { - return AnimatedCrossFade( - crossFadeState: searchController.text.isEmpty && - index == categories.length - 1 && - categoriesQuery.hasNextPage - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - duration: const Duration(milliseconds: 300), - firstChild: const ShimmerCategories(), - secondChild: CategoryCard(categories[index]), - ); - }, - ), + child: InfiniteList( + scrollController: scrollController, + itemCount: categories.length, + onFetchData: categoriesQuery.fetchNext, + isLoading: categoriesQuery.isLoadingNextPage, + hasReachedMax: !categoriesQuery.hasNextPage, + loadingBuilder: (context) => const ShimmerCategories(), + itemBuilder: (context, index) { + return CategoryCard(categories[index]); + }, ), ), ], @@ -110,10 +104,11 @@ class GenrePage extends HookConsumerWidget { top: 0, right: 10, child: ExpandableSearchButton( - isFiltering: isFiltering, + isFiltering: isFiltering.value, searchFocus: searchFocus, icon: const Icon(SpotubeIcons.search), onPressed: (value) { + isFiltering.value = value; if (isFiltering.value) { scrollController.animateTo( 0, diff --git a/lib/pages/home/personalized.dart b/lib/pages/home/personalized.dart index d6192592..7fbd27ae 100644 --- a/lib/pages/home/personalized.dart +++ b/lib/pages/home/personalized.dart @@ -1,97 +1,15 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/album/album_card.dart'; -import 'package:spotube/components/playlist/playlist_card.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/components/shared/shimmers/shimmer_categories.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; -import 'package:spotube/components/shared/waypoint.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; -class PersonalizedItemCard extends HookWidget { - final Iterable? playlists; - final Iterable? albums; - final String title; - final bool hasNextPage; - final void Function() onFetchMore; - - PersonalizedItemCard({ - this.playlists, - this.albums, - required this.title, - required this.hasNextPage, - required this.onFetchMore, - Key? key, - }) : assert(playlists == null || albums == null), - super(key: key); - - final logger = getLogger(PersonalizedItemCard); - - @override - Widget build(BuildContext context) { - final scrollController = useScrollController(); - - return Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - title, - style: Theme.of(context).textTheme.titleLarge, - ), - ), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - controller: scrollController, - interactive: false, - child: Waypoint( - controller: scrollController, - onTouchEdge: hasNextPage ? onFetchMore : null, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: scrollController, - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ...?playlists?.map((playlist) => PlaylistCard(playlist)), - ...?albums?.map( - (album) => AlbumCard( - TypeConversionUtils.simpleAlbum_X_Album(album), - ), - ), - if (hasNextPage) const ShimmerPlaybuttonCard(count: 1), - ], - ), - ), - ), - ), - ), - ], - ), - ); - } -} - class PersonalizedPage extends HookConsumerWidget { const PersonalizedPage({Key? key}) : super(key: key); @@ -119,53 +37,74 @@ class PersonalizedPage extends HookConsumerWidget { .whereType>() .expand((page) => page.items ?? const []) .where((album) { - return album.artists - ?.any((artist) => userArtists.contains(artist.id!)) == - true; - }), + return album.artists + ?.any((artist) => userArtists.contains(artist.id!)) == + true; + }) + .map((album) => TypeConversionUtils.simpleAlbum_X_Album(album)) + .toList(), [newReleases.pages], ); - return InterScrollbar( + return CustomScrollView( controller: controller, - child: ListView( - controller: controller, - children: [ - if (!featuredPlaylistsQuery.hasPageData) - const ShimmerCategories() - else - PersonalizedItemCard( - playlists: playlists, - title: context.l10n.featured, - hasNextPage: featuredPlaylistsQuery.hasNextPage, - onFetchMore: featuredPlaylistsQuery.fetchNext, + slivers: [ + SliverList.list( + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: !featuredPlaylistsQuery.hasPageData && + !featuredPlaylistsQuery.isLoadingNextPage + ? const ShimmerCategories() + : HorizontalPlaybuttonCardView( + items: playlists.toList(), + title: Text(context.l10n.featured), + isLoadingNextPage: + featuredPlaylistsQuery.isLoadingNextPage, + hasNextPage: featuredPlaylistsQuery.hasNextPage, + onFetchMore: featuredPlaylistsQuery.fetchNext, + ), ), - if (auth != null && - newReleases.hasPageData && - userArtistsQuery.hasData) - PersonalizedItemCard( - albums: albums, - title: context.l10n.new_releases, - hasNextPage: newReleases.hasNextPage, - onFetchMore: newReleases.fetchNext, - ), - ...?madeForUser.data?["content"]?["items"]?.map((item) { - final playlists = item["content"]?["items"] - ?.where((itemL2) => itemL2["type"] == "playlist") - .map((itemL2) => PlaylistSimple.fromJson(itemL2)) - .toList() - .cast() ?? - []; - if (playlists.isEmpty) return const SizedBox.shrink(); - return PersonalizedItemCard( - playlists: playlists, - title: item["name"] ?? "", - hasNextPage: false, - onFetchMore: () {}, - ); - }) - ], - ), + if (auth != null) + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: newReleases.hasPageData && + userArtistsQuery.hasData && + !newReleases.isLoadingNextPage + ? HorizontalPlaybuttonCardView( + items: albums, + title: Text(context.l10n.new_releases), + isLoadingNextPage: newReleases.isLoadingNextPage, + hasNextPage: newReleases.hasNextPage, + onFetchMore: newReleases.fetchNext, + ) + : const ShimmerCategories(), + ), + ], + ), + SliverSafeArea( + sliver: SliverList.builder( + itemCount: madeForUser.data?["content"]?["items"]?.length ?? 0, + itemBuilder: (context, index) { + final item = madeForUser.data?["content"]?["items"]?[index]; + final playlists = item["content"]?["items"] + ?.where((itemL2) => itemL2["type"] == "playlist") + .map((itemL2) => PlaylistSimple.fromJson(itemL2)) + .toList() + .cast() ?? + []; + if (playlists.isEmpty) return const SizedBox.shrink(); + return HorizontalPlaybuttonCardView( + items: playlists, + title: Text(item["name"] ?? ""), + hasNextPage: false, + isLoadingNextPage: false, + onFetchMore: () {}, + ); + }, + ), + ), + ], ); } } diff --git a/lib/pages/lastfm_login/lastfm_login.dart b/lib/pages/lastfm_login/lastfm_login.dart index f77d0abb..4280328f 100644 --- a/lib/pages/lastfm_login/lastfm_login.dart +++ b/lib/pages/lastfm_login/lastfm_login.dart @@ -108,7 +108,7 @@ class LastFMLoginPage extends HookConsumerWidget { return; } await scrobblerNotifier.login( - username.text, + username.text.trim(), password.text, ); router.pop(); diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 33e244b0..802b28d3 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -17,7 +17,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart'; import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -242,267 +242,284 @@ class PlaylistGeneratorPage extends HookConsumerWidget { }, ); + final controller = useScrollController(); + return Scaffold( appBar: PageWindowTitleBar( leading: const BackButton(), title: Text(context.l10n.generate_playlist), centerTitle: true, ), - body: Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: Breakpoints.lg), - child: SliderTheme( - data: const SliderThemeData( - overlayShape: RoundSliderOverlayShape(), - ), - child: SafeArea( - child: LayoutBuilder(builder: (context, constrains) { - return ListView( - padding: const EdgeInsets.all(16), - children: [ - ValueListenableBuilder( - valueListenable: limit, - builder: (context, value, child) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.number_of_tracks_generate, - style: textTheme.titleMedium, - ), - Row( + body: Scrollbar( + controller: controller, + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: Breakpoints.lg), + child: SliderTheme( + data: const SliderThemeData( + overlayShape: RoundSliderOverlayShape(), + ), + child: SafeArea( + child: LayoutBuilder(builder: (context, constrains) { + return ScrollConfiguration( + behavior: ScrollConfiguration.of(context) + .copyWith(scrollbars: false), + child: ListView( + controller: controller, + padding: const EdgeInsets.all(16), + children: [ + ValueListenableBuilder( + valueListenable: limit, + builder: (context, value, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 40, - height: 40, - alignment: Alignment.center, - decoration: BoxDecoration( - color: theme.colorScheme.primary, - shape: BoxShape.circle, - ), - child: Text( - value.round().toString(), - style: textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.primaryContainer, - ), - ), + Text( + context.l10n.number_of_tracks_generate, + style: textTheme.titleMedium, ), - Expanded( - child: Slider( - value: value.toDouble(), - min: 10, - max: 100, - divisions: 9, - label: value.round().toString(), - onChanged: (value) { - limit.value = value.round(); - }, - ), + Row( + children: [ + Container( + width: 40, + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + color: theme.colorScheme.primary, + shape: BoxShape.circle, + ), + child: Text( + value.round().toString(), + style: textTheme.bodyLarge?.copyWith( + color: theme + .colorScheme.primaryContainer, + ), + ), + ), + Expanded( + child: Slider( + value: value.toDouble(), + min: 10, + max: 100, + divisions: 9, + label: value.round().toString(), + onChanged: (value) { + limit.value = value.round(); + }, + ), + ) + ], ) ], - ) - ], - ); - }, - ), - const SizedBox(height: 16), - if (constrains.mdAndUp) - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: countrySelector, - ), - const SizedBox(width: 16), - Expanded( - child: genreSelector, - ), + ); + }, + ), + const SizedBox(height: 16), + if (constrains.mdAndUp) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: countrySelector, + ), + const SizedBox(width: 16), + Expanded( + child: genreSelector, + ), + ], + ) + else ...[ + countrySelector, + const SizedBox(height: 16), + genreSelector, ], - ) - else ...[ - countrySelector, - const SizedBox(height: 16), - genreSelector, - ], - const SizedBox(height: 16), - if (constrains.mdAndUp) - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: artistAutoComplete, - ), - const SizedBox(width: 16), - Expanded( - child: tracksAutocomplete, - ), + const SizedBox(height: 16), + if (constrains.mdAndUp) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: artistAutoComplete, + ), + const SizedBox(width: 16), + Expanded( + child: tracksAutocomplete, + ), + ], + ) + else ...[ + artistAutoComplete, + const SizedBox(height: 16), + tracksAutocomplete, ], - ) - else ...[ - artistAutoComplete, - const SizedBox(height: 16), - tracksAutocomplete, - ], - const SizedBox(height: 16), - RecommendationAttributeDials( - title: Text(context.l10n.acousticness), - values: acousticness.value, - onChanged: (value) { - acousticness.value = value; - }, + const SizedBox(height: 16), + RecommendationAttributeDials( + title: Text(context.l10n.acousticness), + values: acousticness.value, + onChanged: (value) { + acousticness.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.danceability), + values: danceability.value, + onChanged: (value) { + danceability.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.energy), + values: energy.value, + onChanged: (value) { + energy.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.instrumentalness), + values: instrumentalness.value, + onChanged: (value) { + instrumentalness.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.liveness), + values: liveness.value, + onChanged: (value) { + liveness.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.loudness), + values: loudness.value, + onChanged: (value) { + loudness.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.speechiness), + values: speechiness.value, + onChanged: (value) { + speechiness.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.valence), + values: valence.value, + onChanged: (value) { + valence.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.popularity), + values: popularity.value, + base: 100, + onChanged: (value) { + popularity.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.key), + values: key.value, + base: 11, + onChanged: (value) { + key.value = value; + }, + ), + RecommendationAttributeFields( + title: Text(context.l10n.duration), + values: ( + max: durationMs.value.max / 1000, + target: durationMs.value.target / 1000, + min: durationMs.value.min / 1000, + ), + onChanged: (value) { + durationMs.value = ( + max: value.max * 1000, + target: value.target * 1000, + min: value.min * 1000, + ); + }, + presets: { + context.l10n.short: (min: 50, target: 90, max: 120), + context.l10n.medium: ( + min: 120, + target: 180, + max: 200 + ), + context.l10n.long: (min: 480, target: 560, max: 640) + }, + ), + RecommendationAttributeFields( + title: Text(context.l10n.tempo), + values: tempo.value, + onChanged: (value) { + tempo.value = value; + }, + ), + RecommendationAttributeFields( + title: Text(context.l10n.mode), + values: mode.value, + onChanged: (value) { + mode.value = value; + }, + ), + RecommendationAttributeFields( + title: Text(context.l10n.time_signature), + values: timeSignature.value, + onChanged: (value) { + timeSignature.value = value; + }, + ), + const SizedBox(height: 20), + FilledButton.icon( + icon: const Icon(SpotubeIcons.magic), + label: Text(context.l10n.generate_playlist), + onPressed: artists.value.isEmpty && + tracks.value.isEmpty && + genres.value.isEmpty + ? null + : () { + final PlaylistGenerateResultRouteState + routeState = ( + seeds: ( + artists: artists.value + .map((a) => a.id!) + .toList(), + tracks: tracks.value + .map((t) => t.id!) + .toList(), + genres: genres.value + ), + market: market.value, + limit: limit.value, + parameters: ( + acousticness: acousticness.value, + danceability: danceability.value, + energy: energy.value, + instrumentalness: instrumentalness.value, + liveness: liveness.value, + loudness: loudness.value, + speechiness: speechiness.value, + valence: valence.value, + popularity: popularity.value, + key: key.value, + duration_ms: durationMs.value, + tempo: tempo.value, + mode: mode.value, + time_signature: timeSignature.value, + ) + ); + GoRouter.of(context).push( + "/library/generate/result", + extra: routeState, + ); + }, + ), + ], ), - RecommendationAttributeDials( - title: Text(context.l10n.danceability), - values: danceability.value, - onChanged: (value) { - danceability.value = value; - }, - ), - RecommendationAttributeDials( - title: Text(context.l10n.energy), - values: energy.value, - onChanged: (value) { - energy.value = value; - }, - ), - RecommendationAttributeDials( - title: Text(context.l10n.instrumentalness), - values: instrumentalness.value, - onChanged: (value) { - instrumentalness.value = value; - }, - ), - RecommendationAttributeDials( - title: Text(context.l10n.liveness), - values: liveness.value, - onChanged: (value) { - liveness.value = value; - }, - ), - RecommendationAttributeDials( - title: Text(context.l10n.loudness), - values: loudness.value, - onChanged: (value) { - loudness.value = value; - }, - ), - RecommendationAttributeDials( - title: Text(context.l10n.speechiness), - values: speechiness.value, - onChanged: (value) { - speechiness.value = value; - }, - ), - RecommendationAttributeDials( - title: Text(context.l10n.valence), - values: valence.value, - onChanged: (value) { - valence.value = value; - }, - ), - RecommendationAttributeDials( - title: Text(context.l10n.popularity), - values: popularity.value, - base: 100, - onChanged: (value) { - popularity.value = value; - }, - ), - RecommendationAttributeDials( - title: Text(context.l10n.key), - values: key.value, - base: 11, - onChanged: (value) { - key.value = value; - }, - ), - RecommendationAttributeFields( - title: Text(context.l10n.duration), - values: ( - max: durationMs.value.max / 1000, - target: durationMs.value.target / 1000, - min: durationMs.value.min / 1000, - ), - onChanged: (value) { - durationMs.value = ( - max: value.max * 1000, - target: value.target * 1000, - min: value.min * 1000, - ); - }, - presets: { - context.l10n.short: (min: 50, target: 90, max: 120), - context.l10n.medium: (min: 120, target: 180, max: 200), - context.l10n.long: (min: 480, target: 560, max: 640) - }, - ), - RecommendationAttributeFields( - title: Text(context.l10n.tempo), - values: tempo.value, - onChanged: (value) { - tempo.value = value; - }, - ), - RecommendationAttributeFields( - title: Text(context.l10n.mode), - values: mode.value, - onChanged: (value) { - mode.value = value; - }, - ), - RecommendationAttributeFields( - title: Text(context.l10n.time_signature), - values: timeSignature.value, - onChanged: (value) { - timeSignature.value = value; - }, - ), - const SizedBox(height: 20), - FilledButton.icon( - icon: const Icon(SpotubeIcons.magic), - label: Text(context.l10n.generate_playlist), - onPressed: artists.value.isEmpty && - tracks.value.isEmpty && - genres.value.isEmpty - ? null - : () { - final PlaylistGenerateResultRouteState - routeState = ( - seeds: ( - artists: - artists.value.map((a) => a.id!).toList(), - tracks: - tracks.value.map((t) => t.id!).toList(), - genres: genres.value - ), - market: market.value, - limit: limit.value, - parameters: ( - acousticness: acousticness.value, - danceability: danceability.value, - energy: energy.value, - instrumentalness: instrumentalness.value, - liveness: liveness.value, - loudness: loudness.value, - speechiness: speechiness.value, - valence: valence.value, - popularity: popularity.value, - key: key.value, - duration_ms: durationMs.value, - tempo: tempo.value, - mode: mode.value, - time_signature: timeSignature.value, - ) - ); - GoRouter.of(context).push( - "/library/generate/result", - extra: routeState, - ); - }, - ), - ], - ); - }), + ); + }), + ), ), ), ), diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index 015685f1..f751b65b 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -163,6 +163,7 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { context: context, builder: (context) => PlaylistAddTrackDialog( + openFromPlaylist: null, tracks: selectedTracks.value .map( (e) => generatedPlaylist.data! diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index c97649d7..ac4b61e7 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -11,8 +11,8 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/themed_button_tab_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_custom_status_bar_color.dart'; -import 'package:spotube/hooks/use_palette_color.dart'; +import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart'; +import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/pages/lyrics/plain_lyrics.dart'; import 'package:spotube/pages/lyrics/synced_lyrics.dart'; import 'package:spotube/provider/authentication_provider.dart'; diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index ad3a13ef..2cf73728 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; @@ -11,7 +12,7 @@ import 'package:spotube/components/root/sidebar.dart'; import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_force_update.dart'; +import 'package:spotube/hooks/utils/use_force_update.dart'; import 'package:spotube/pages/lyrics/plain_lyrics.dart'; import 'package:spotube/pages/lyrics/synced_lyrics.dart'; import 'package:spotube/provider/authentication_provider.dart'; @@ -32,6 +33,7 @@ class MiniLyricsPage extends HookConsumerWidget { final areaActive = useState(false); final hoverMode = useState(true); + final showLyrics = useState(true); useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -82,17 +84,41 @@ class MiniLyricsPage extends HookConsumerWidget { child: Sidebar.brandLogo(), ), const Spacer(), - SizedBox( - height: 30, - child: TabBar( - tabs: [ - Tab(text: context.l10n.synced), - Tab(text: context.l10n.plain), - ], - isScrollable: true, + if (showLyrics.value) + SizedBox( + height: 30, + child: TabBar( + tabs: [ + Tab(text: context.l10n.synced), + Tab(text: context.l10n.plain), + ], + isScrollable: true, + ), ), - ), const Spacer(), + IconButton( + tooltip: context.l10n.lyrics, + icon: showLyrics.value + ? const Icon(SpotubeIcons.lyrics) + : const Icon(SpotubeIcons.lyricsOff), + style: ButtonStyle( + foregroundColor: showLyrics.value + ? MaterialStateProperty.all( + theme.colorScheme.primary) + : null, + ), + onPressed: () async { + showLyrics.value = !showLyrics.value; + areaActive.value = true; + hoverMode.value = false; + + await DesktopTools.window.setSize( + showLyrics.value + ? const Size(400, 500) + : const Size(400, 150), + ); + }, + ), IconButton( tooltip: context.l10n.show_hide_ui_on_hover, icon: hoverMode.value @@ -105,9 +131,7 @@ class MiniLyricsPage extends HookConsumerWidget { : null, ), onPressed: () async { - if (!hoverMode.value == true) { - areaActive.value = true; - } + areaActive.value = true; hoverMode.value = !hoverMode.value; }, ), @@ -150,22 +174,25 @@ class MiniLyricsPage extends HookConsumerWidget { playlistQueue.activeTrack!.name!, style: theme.textTheme.titleMedium, ), - Expanded( - child: TabBarView( - children: [ - SyncedLyrics( - palette: PaletteColor(theme.colorScheme.background, 0), - isModal: true, - defaultTextZoom: 65, - ), - PlainLyrics( - palette: PaletteColor(theme.colorScheme.background, 0), - isModal: true, - defaultTextZoom: 65, - ), - ], - ), - ), + if (showLyrics.value) + Expanded( + child: TabBarView( + children: [ + SyncedLyrics( + palette: PaletteColor(theme.colorScheme.background, 0), + isModal: true, + defaultTextZoom: 65, + ), + PlainLyrics( + palette: PaletteColor(theme.colorScheme.background, 0), + isModal: true, + defaultTextZoom: 65, + ), + ], + ), + ) + else + const Gap(20), AnimatedCrossFade( crossFadeState: areaActive.value ? CrossFadeState.showFirst diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index dab2103d..36a9f316 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -7,8 +7,8 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/lyrics/zoom_controls.dart'; import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart'; import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/hooks/use_auto_scroll_controller.dart'; -import 'package:spotube/hooks/use_synced_lyrics.dart'; +import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; +import 'package:spotube/components/lyrics/use_synced_lyrics.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -45,6 +45,11 @@ class SyncedLyrics extends HookConsumerWidget { final lyricValue = timedLyricsQuery.data; + final isUnSyncLyric = useMemoized( + () => lyricValue?.lyrics.every((l) => l.time == Duration.zero), + [lyricValue], + ); + final lyricsMap = useMemoized( () => lyricValue?.lyrics @@ -72,6 +77,9 @@ class SyncedLyrics extends HookConsumerWidget { : textTheme.headlineMedium?.copyWith(fontSize: 25)) ?.copyWith(color: palette.titleTextColor); + var bodyTextTheme = textTheme.bodyLarge?.copyWith( + color: palette.bodyTextColor, + ); return Stack( children: [ Column( @@ -93,7 +101,9 @@ class SyncedLyrics extends HookConsumerWidget { : textTheme.titleLarge, ), ), - if (lyricValue != null && lyricValue.lyrics.isNotEmpty) + if (lyricValue != null && + lyricValue.lyrics.isNotEmpty && + isUnSyncLyric == false) Expanded( child: ListView.builder( controller: controller, @@ -173,8 +183,39 @@ class SyncedLyrics extends HookConsumerWidget { ), ), if (playlist.activeTrack != null && - (lyricValue == null || lyricValue.lyrics.isEmpty == true)) - const Expanded(child: ShimmerLyrics()), + (timedLyricsQuery.isLoading || timedLyricsQuery.isRefreshing)) + const Expanded(child: ShimmerLyrics()) + else if (playlist.activeTrack != null && + (timedLyricsQuery.hasError)) + Text( + "Sorry, no Lyrics were found for `${playlist.activeTrack?.name}` :'(\n${timedLyricsQuery.error.toString()}", + style: bodyTextTheme, + ) + else if (isUnSyncLyric == true) + Expanded( + child: Center( + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: bodyTextTheme, + children: [ + const TextSpan( + text: + "Synced lyrics is not available for this song. Please use the", + ), + TextSpan( + text: " Plain Lyrics ", + style: textTheme.bodyLarge?.copyWith( + color: palette.bodyTextColor, + fontWeight: FontWeight.bold, + ), + ), + const TextSpan(text: "tab instead."), + ], + ), + ), + ), + ), ], ), Align( diff --git a/lib/pages/playlist/liked_playlist.dart b/lib/pages/playlist/liked_playlist.dart new file mode 100644 index 00000000..1f252ed4 --- /dev/null +++ b/lib/pages/playlist/liked_playlist.dart @@ -0,0 +1,45 @@ +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/tracks_view/track_view.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; + +class LikedPlaylistPage extends HookConsumerWidget { + final PlaylistSimple playlist; + const LikedPlaylistPage({ + Key? key, + required this.playlist, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final likedTracks = useQueries.playlist.likedTracksQuery(ref); + final tracks = likedTracks.data ?? []; + + return InheritedTrackView( + collectionId: playlist.id!, + image: TypeConversionUtils.image_X_UrlString( + playlist.images, + placeholder: ImagePlaceholder.collection, + ), + pagination: PaginationProps( + hasNextPage: false, + isLoading: false, + onFetchMore: () {}, + onFetchAll: () async { + return tracks.toList(); + }, + ), + title: playlist.name!, + description: playlist.description, + tracks: tracks, + routePath: '/playlist/${playlist.id}', + isLiked: false, + shareUrl: "", + onHeart: null, + child: const TrackView(), + ); + } +} diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index 1623195b..29601a09 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -1,178 +1,82 @@ -import 'package:flutter/services.dart'; +import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/heart_button.dart'; -import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart'; -import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.dart'; -import 'package:spotube/components/shared/track_table/tracks_table_view.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:flutter/material.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/models/spotube_track.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/components/shared/tracks_view/track_view.dart'; +import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/extensions/infinite_query.dart'; +import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/services/mutations/mutations.dart'; import 'package:spotube/services/queries/queries.dart'; - -import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; -class PlaylistView extends HookConsumerWidget { - final logger = getLogger(PlaylistView); - final PlaylistSimple playlistSimple; - PlaylistView(this.playlistSimple, {Key? key}) : super(key: key); +class PlaylistPage extends HookConsumerWidget { + final PlaylistSimple playlist; + const PlaylistPage({ + Key? key, + required this.playlist, + }) : super(key: key); @override Widget build(BuildContext context, ref) { - final proxyPlaylist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final spotify = ref.watch(spotifyProvider); + final tracksQuery = useQueries.playlist.tracksOfQuery(ref, playlist.id!); - final mediaQuery = MediaQuery.of(context); - - final meSnapshot = useQueries.user.me(ref); - - final playlistQuery = useQueries.playlist.byId(ref, playlistSimple.id!); - final playlist = playlistQuery.data ?? playlistSimple; - - final playlistTrackSnapshot = - useQueries.playlist.tracksOfQuery(ref, playlist.id!); - final likedTracksSnapshot = useQueries.playlist.likedTracksQuery(ref); - final tracksSnapshot = playlist.id! == "user-liked-tracks" - ? likedTracksSnapshot - : playlistTrackSnapshot; - - final isPlaylistPlaying = useMemoized( - () => proxyPlaylist.collections.contains(playlist.id!), - [proxyPlaylist, playlist], + final tracks = useMemoized( + () { + return tracksQuery.pages.expand((page) => page).toList(); + }, + [tracksQuery.pages], ); - final titleImage = useMemoized( - () => TypeConversionUtils.image_X_UrlString( - playlist.images, - placeholder: ImagePlaceholder.collection, - ), - [playlist.images]); + final me = useQueries.user.me(ref); - final playlistTrackPlaying = useMemoized( - () => - tracksSnapshot.data - ?.any((s) => s.id! == proxyPlaylist.activeTrack?.id!) == - true && - proxyPlaylist.activeTrack is SpotubeTrack, - [proxyPlaylist.activeTrack, tracksSnapshot.data], + final isLikedQuery = useQueries.playlist.doesUserFollow( + ref, + playlist.id!, + me.data?.id ?? '', ); - final playPlaylist = useCallback(( - List tracks, - WidgetRef ref, { - Track? currentTrack, - }) async { - final playback = ref.read(ProxyPlaylistNotifier.notifier); - final sortBy = ref.read(trackCollectionSortState(playlist.id!)); - final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy); - currentTrack ??= sortedTracks.first; - final isPlaylistPlaying = proxyPlaylist.containsTracks(tracks); - if (!isPlaylistPlaying) { - playback.addCollection(playlist.id!); // for enabling loading indicator - await playback.load( - sortedTracks, - initialIndex: - sortedTracks.indexWhere((s) => s.id == currentTrack?.id), - autoPlay: true, - ); - playback.addCollection(playlist.id!); - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != proxyPlaylist.activeTrack?.id) { - await playback.jumpToTrack(currentTrack); - } - }, [proxyPlaylist, playlist]); + final togglePlaylistLike = useMutations.playlist.toggleFavorite( + ref, + playlist.id!, + refreshQueries: [ + isLikedQuery.key, + ], + ); - final ownPlaylist = - playlist.owner?.id != null && playlist.owner?.id == meSnapshot.data?.id; - - return TrackCollectionView( - id: playlist.id!, - playingState: isPlaylistPlaying && playlistTrackPlaying - ? PlayButtonState.playing - : isPlaylistPlaying && !playlistTrackPlaying - ? PlayButtonState.loading - : PlayButtonState.notPlaying, - title: playlist.name!, - titleImage: titleImage, - tracksSnapshot: tracksSnapshot, - description: playlist.description, - isOwned: ownPlaylist, - onPlay: ([track]) async { - if (tracksSnapshot.hasData) { - if (!isPlaylistPlaying || (isPlaylistPlaying && track != null)) { - await playPlaylist( - tracksSnapshot.data!, - ref, - currentTrack: track, - ); - } else { - await playlistNotifier - .removeTracks(tracksSnapshot.data!.map((e) => e.id!)); - } - } - }, - onAddToQueue: () { - if (tracksSnapshot.hasData && !isPlaylistPlaying) { - playlistNotifier.addTracks(tracksSnapshot.data!); - playlistNotifier.addCollection(playlist.id!); - } - }, - bottomSpace: mediaQuery.mdAndDown, - showShare: playlist.id != "user-liked-tracks", - routePath: "/playlist/${playlist.id}", - onShare: () { - final data = "https://open.spotify.com/playlist/${playlist.id}"; - Clipboard.setData( - ClipboardData(text: data), - ).then((_) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - "Copied $data to clipboard", - textAlign: TextAlign.center, - ), - ), + return InheritedTrackView( + collectionId: playlist.id!, + image: TypeConversionUtils.image_X_UrlString( + playlist.images, + placeholder: ImagePlaceholder.collection, + ), + pagination: PaginationProps.fromQuery( + tracksQuery, + onFetchAll: () { + return tracksQuery.fetchAllTracks( + getAllTracks: () async { + final res = await spotify.playlists + .getTracksByPlaylistId(playlist.id!) + .all(); + return res.toList(); + }, ); - }); - }, - heartBtn: PlaylistHeartButton( - playlist: playlist, - icon: ownPlaylist ? SpotubeIcons.trash : null, - onData: (data) { - GoRouter.of(context).pop(); }, ), - onShuffledPlay: ([track]) { - final tracks = [...?tracksSnapshot.data]..shuffle(); - - if (tracksSnapshot.hasData) { - if (!isPlaylistPlaying) { - playPlaylist( - tracks, - ref, - currentTrack: track, - ); - } else if (isPlaylistPlaying && track != null) { - playPlaylist( - tracks, - ref, - currentTrack: track, - ); - } else { - // TODO: Remove the ability to stop the playlist - // playlistNotifier.stop(); - } + title: playlist.name!, + description: playlist.description, + tracks: tracks, + routePath: '/playlist/${playlist.id}', + isLiked: isLikedQuery.data ?? false, + shareUrl: playlist.externalUrls?.spotify ?? "", + onHeart: () async { + if (!isLikedQuery.hasData || togglePlaylistLike.isMutating) { + return; } + await togglePlaylistLike.mutate(isLikedQuery.data!); }, + child: const TrackView(), ); } } diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index b2bd4620..87be587c 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -3,17 +3,19 @@ import 'dart:async'; import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/player/player_queue.dart'; import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart'; import 'package:spotube/components/root/bottom_player.dart'; import 'package:spotube/components/root/sidebar.dart'; import 'package:spotube/components/root/spotube_navigation_bar.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_update_checker.dart'; +import 'package:spotube/hooks/configurators/use_update_checker.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; @@ -159,17 +161,33 @@ class RootApp extends HookConsumerWidget { return Scaffold( body: Sidebar( - selectedIndex: rootPaths[location] ?? 0, + selectedIndex: rootPaths[location], onSelectedIndexChanged: onSelectIndexChanged, child: child, ), extendBody: true, + drawerScrimColor: Colors.transparent, + endDrawer: DesktopTools.platform.isDesktop + ? Container( + constraints: const BoxConstraints(maxWidth: 800), + decoration: BoxDecoration( + boxShadow: theme.brightness == Brightness.light + ? null + : kElevationToShadow[8], + ), + margin: const EdgeInsets.only( + top: 40, + bottom: 100, + ), + child: const PlayerQueue(floating: true), + ) + : null, bottomNavigationBar: Column( mainAxisSize: MainAxisSize.min, children: [ BottomPlayer(), SpotubeNavigationBar( - selectedIndex: rootPaths[location] ?? 0, + selectedIndex: rootPaths[location], onSelectedIndexChanged: onSelectIndexChanged, ), ], diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index 19a9aafa..f4a78d4f 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -1,30 +1,24 @@ import 'dart:async'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/album/album_card.dart'; -import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/shared/track_table/track_tile.dart'; -import 'package:spotube/components/shared/waypoint.dart'; -import 'package:spotube/components/artist/artist_card.dart'; -import 'package:spotube/components/playlist/playlist_card.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/search/sections/albums.dart'; +import 'package:spotube/pages/search/sections/artists.dart'; +import 'package:spotube/pages/search/sections/playlists.dart'; +import 'package:spotube/pages/search/sections/tracks.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:collection/collection.dart'; final searchTermStateProvider = StateProvider((ref) => ""); @@ -38,9 +32,6 @@ class SearchPage extends HookConsumerWidget { ref.watch(AuthenticationNotifier.provider); final authenticationNotifier = ref.watch(AuthenticationNotifier.provider.notifier); - final albumController = useScrollController(); - final playlistController = useScrollController(); - final artistController = useScrollController(); final mediaQuery = MediaQuery.of(context); final searchTerm = ref.watch(searchTermStateProvider); @@ -81,270 +72,24 @@ class SearchPage extends HookConsumerWidget { final resultWidget = HookBuilder( builder: (context) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - List albums = []; - List artists = []; - List tracks = []; - List playlists = []; - final pages = [ - ...searchTrack.pages, - ...searchAlbum.pages, - ...searchPlaylist.pages, - ...searchArtist.pages, - ].expand((page) => page).toList(); - for (MapEntry page in pages.asMap().entries) { - for (var item in page.value.items ?? []) { - if (item is AlbumSimple) { - albums.add(item); - } else if (item is PlaylistSimple) { - playlists.add(item); - } else if (item is Artist) { - artists.add(item); - } else if (item is Track) { - tracks.add(item); - } - } - } + final controller = useScrollController(); return InterScrollbar( + controller: controller, child: SingleChildScrollView( + controller: controller, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (tracks.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - context.l10n.songs, - style: theme.textTheme.titleLarge!, - ), - ), - if (!searchTrack.hasPageData && - !searchTrack.hasPageError && - !searchTrack.isLoadingNextPage) - const CircularProgressIndicator() - else if (searchTrack.hasPageError) - Text( - searchTrack.errors.lastOrNull?.toString() ?? "", - ) - else - ...tracks.mapIndexed((i, track) { - return TrackTile( - index: i, - track: track, - onTap: () async { - final isTrackPlaying = - playlist.activeTrack?.id == track.id; - if (!isTrackPlaying && context.mounted) { - final shouldPlay = (playlist.tracks.length) > 20 - ? await showPromptDialog( - context: context, - title: context.l10n.playing_track( - track.name!, - ), - message: context.l10n.queue_clear_alert( - playlist.tracks.length, - ), - ) - : true; - - if (shouldPlay) { - await playlistNotifier.load( - [track], - autoPlay: true, - ); - } - } - }, - ); - }), - if (searchTrack.hasNextPage && tracks.isNotEmpty) - Center( - child: TextButton( - onPressed: searchTrack.isLoadingNextPage - ? null - : () => searchTrack.fetchNext(), - child: searchTrack.isLoadingNextPage - ? const CircularProgressIndicator() - : Text(context.l10n.load_more), - ), - ), - if (playlists.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - context.l10n.playlists, - style: theme.textTheme.titleLarge!, - ), - ), - const SizedBox(height: 10), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - scrollbarOrientation: mediaQuery.lgAndUp - ? ScrollbarOrientation.bottom - : ScrollbarOrientation.top, - controller: playlistController, - child: Waypoint( - onTouchEdge: () { - searchPlaylist.fetchNext(); - }, - controller: playlistController, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: playlistController, - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - ...playlists.mapIndexed( - (i, playlist) { - if (i == playlists.length - 1 && - searchPlaylist.hasNextPage) { - return const ShimmerPlaybuttonCard( - count: 1); - } - return PlaylistCard(playlist); - }, - ), - ], - ), - ), - ), - ), - ), - if (!searchPlaylist.hasPageData && - !searchPlaylist.hasPageError) - const CircularProgressIndicator(), - if (searchPlaylist.hasPageError) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - searchPlaylist.errors.lastOrNull?.toString() ?? "", - ), - ), + SearchTracksSection(query: searchTrack), + SearchPlaylistsSection(query: searchPlaylist), const SizedBox(height: 20), - if (artists.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - context.l10n.artists, - style: theme.textTheme.titleLarge!, - ), - ), - const SizedBox(height: 10), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - controller: artistController, - child: Waypoint( - controller: artistController, - onTouchEdge: () { - searchArtist.fetchNext(); - }, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: artistController, - child: Row( - children: [ - ...artists.mapIndexed( - (i, artist) { - if (i == artists.length - 1 && - searchArtist.hasNextPage) { - return const ShimmerPlaybuttonCard( - count: 1); - } - return Container( - margin: const EdgeInsets.symmetric( - horizontal: 15), - child: ArtistCard(artist), - ); - }, - ), - ], - ), - ), - ), - ), - ), - if (!searchArtist.hasPageData && !searchArtist.hasPageError) - const CircularProgressIndicator(), - if (searchArtist.hasPageError) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - searchArtist.errors.lastOrNull?.toString() ?? "", - ), - ), + SearchArtistsSection(query: searchArtist), const SizedBox(height: 20), - if (albums.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - context.l10n.albums, - style: theme.textTheme.titleLarge!, - ), - ), - const SizedBox(height: 10), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - controller: albumController, - child: Waypoint( - controller: albumController, - onTouchEdge: () { - searchAlbum.fetchNext(); - }, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: albumController, - child: Row( - children: [ - ...albums.mapIndexed((i, album) { - if (i == albums.length - 1 && - searchAlbum.hasNextPage) { - return const ShimmerPlaybuttonCard( - count: 1); - } - return AlbumCard( - TypeConversionUtils.simpleAlbum_X_Album( - album, - ), - ); - }), - ], - ), - ), - ), - ), - ), - if (!searchAlbum.hasPageData && !searchAlbum.hasPageError) - const CircularProgressIndicator(), - if (searchAlbum.hasPageError) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - searchAlbum.errors.lastOrNull?.toString() ?? "", - ), - ), + SearchAlbumsSection(query: searchAlbum), ], ), ), @@ -369,8 +114,9 @@ class SearchPage extends HookConsumerWidget { ), color: theme.scaffoldBackgroundColor, child: TextField( - autofocus: - queries.none((s) => s.hasPageData && !s.hasPageError), + autofocus: queries + .none((s) => s.hasPageData && !s.hasPageError) && + !kIsMobile, decoration: InputDecoration( prefixIcon: const Icon(SpotubeIcons.search), hintText: "${context.l10n.search}...", diff --git a/lib/pages/search/sections/albums.dart b/lib/pages/search/sections/albums.dart new file mode 100644 index 00000000..8aa33feb --- /dev/null +++ b/lib/pages/search/sections/albums.dart @@ -0,0 +1,40 @@ +import 'package:fl_query/fl_query.dart'; + +import 'package:flutter/material.dart' hide Page; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; + +class SearchAlbumsSection extends HookConsumerWidget { + final InfiniteQuery>, dynamic, int> query; + const SearchAlbumsSection({ + required this.query, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final albums = useMemoized( + () => query.pages + .expand( + (page) => page.map((p) => p.items!).expand((element) => element), + ) + .whereType() + .map((e) => TypeConversionUtils.simpleAlbum_X_Album(e)) + .toList(), + [query.pages], + ); + + return HorizontalPlaybuttonCardView( + isLoadingNextPage: query.isLoadingNextPage, + hasNextPage: query.hasNextPage, + items: albums, + onFetchMore: query.fetchNext, + title: Text(context.l10n.albums), + ); + } +} diff --git a/lib/pages/search/sections/artists.dart b/lib/pages/search/sections/artists.dart new file mode 100644 index 00000000..b736bf13 --- /dev/null +++ b/lib/pages/search/sections/artists.dart @@ -0,0 +1,38 @@ +import 'package:fl_query/fl_query.dart'; +import 'package:flutter/material.dart' hide Page; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/extensions/context.dart'; + +class SearchArtistsSection extends HookConsumerWidget { + final InfiniteQuery>, dynamic, int> query; + + const SearchArtistsSection({ + Key? key, + required this.query, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final artists = useMemoized( + () => query.pages + .expand( + (page) => page.map((p) => p.items!).expand((element) => element), + ) + .whereType() + .toList(), + [query.pages], + ); + + return HorizontalPlaybuttonCardView( + isLoadingNextPage: query.isLoadingNextPage, + hasNextPage: query.hasNextPage, + items: artists, + onFetchMore: query.fetchNext, + title: Text(context.l10n.albums), + ); + } +} diff --git a/lib/pages/search/sections/playlists.dart b/lib/pages/search/sections/playlists.dart new file mode 100644 index 00000000..47614a70 --- /dev/null +++ b/lib/pages/search/sections/playlists.dart @@ -0,0 +1,36 @@ +import 'package:fl_query/fl_query.dart'; +import 'package:flutter/material.dart' hide Page; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/extensions/context.dart'; + +class SearchPlaylistsSection extends HookConsumerWidget { + final InfiniteQuery>, dynamic, int> query; + const SearchPlaylistsSection({ + required this.query, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final playlists = useMemoized( + () => query.pages + .expand( + (page) => page.map((p) => p.items!).expand((element) => element), + ) + .whereType() + .toList(), + [query.pages], + ); + + return HorizontalPlaybuttonCardView( + isLoadingNextPage: query.isLoadingNextPage, + hasNextPage: query.hasNextPage, + items: playlists, + onFetchMore: query.fetchNext, + title: Text(context.l10n.playlists), + ); + } +} diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart new file mode 100644 index 00000000..e77cd8f2 --- /dev/null +++ b/lib/pages/search/sections/tracks.dart @@ -0,0 +1,98 @@ +import 'package:collection/collection.dart'; +import 'package:fl_query/fl_query.dart'; +import 'package:flutter/material.dart' hide Page; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; +import 'package:spotube/components/shared/track_tile/track_tile.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; + +class SearchTracksSection extends HookConsumerWidget { + final InfiniteQuery>, dynamic, int> query; + const SearchTracksSection({ + Key? key, + required this.query, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final searchTrack = query; + final tracks = useMemoized( + () => searchTrack.pages + .expand( + (page) => page.map((p) => p.items!).expand((element) => element), + ) + .whereType(), + [searchTrack.pages], + ); + final playlistNotifier = ref.watch(ProxyPlaylistNotifier.provider.notifier); + final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (tracks.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + context.l10n.songs, + style: theme.textTheme.titleLarge!, + ), + ), + if (!searchTrack.hasPageData && + !searchTrack.hasPageError && + !searchTrack.isLoadingNextPage) + const CircularProgressIndicator() + else if (searchTrack.hasPageError) + Text( + searchTrack.errors.lastOrNull?.toString() ?? "", + ) + else + ...tracks.mapIndexed((i, track) { + return TrackTile( + index: i, + track: track, + onTap: () async { + final isTrackPlaying = playlist.activeTrack?.id == track.id; + if (!isTrackPlaying && context.mounted) { + final shouldPlay = (playlist.tracks.length) > 20 + ? await showPromptDialog( + context: context, + title: context.l10n.playing_track( + track.name!, + ), + message: context.l10n.queue_clear_alert( + playlist.tracks.length, + ), + ) + : true; + + if (shouldPlay) { + await playlistNotifier.load( + [track], + autoPlay: true, + ); + } + } + }, + ); + }), + if (searchTrack.hasNextPage && tracks.isNotEmpty) + Center( + child: TextButton( + onPressed: searchTrack.isLoadingNextPage + ? null + : () => searchTrack.fetchNext(), + child: searchTrack.isLoadingNextPage + ? const CircularProgressIndicator() + : Text(context.l10n.load_more), + ), + ) + ], + ); + } +} diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart index 97a0aae9..00263680 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -5,7 +5,7 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/links/hyper_link.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_package_info.dart'; +import 'package:spotube/hooks/controllers/use_package_info.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index 69800633..b4ce5044 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -15,6 +15,7 @@ class BlackListPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { + final controller = useScrollController(); final blacklist = ref.watch(BlackListNotifier.provider); final searchText = useState(""); @@ -58,7 +59,9 @@ class BlackListPage extends HookConsumerWidget { ), ), InterScrollbar( + controller: controller, child: ListView.builder( + controller: controller, shrinkWrap: true, itemCount: filteredBlacklist.length, itemBuilder: (context, index) { diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart index 91d87fbb..cfb28d18 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -52,6 +52,7 @@ class LogsPage extends HookWidget { @override Widget build(BuildContext context) { + final controller = useScrollController(); final logs = useState>([]); final rawLogs = useRef(""); final path = useRef(null); @@ -93,7 +94,9 @@ class LogsPage extends HookWidget { ), body: SafeArea( child: InterScrollbar( + controller: controller, child: ListView.builder( + controller: controller, itemCount: logs.value.length, itemBuilder: (context, index) { final log = logs.value[index]; diff --git a/lib/pages/settings/sections/about.dart b/lib/pages/settings/sections/about.dart new file mode 100644 index 00000000..9fe59662 --- /dev/null +++ b/lib/pages/settings/sections/about.dart @@ -0,0 +1,85 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/env.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/components/shared/adaptive/adaptive_list_tile.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class SettingsAboutSection extends HookConsumerWidget { + const SettingsAboutSection({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final preferences = ref.watch(userPreferencesProvider); + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); + + return SectionCardWithHeading( + heading: context.l10n.about, + children: [ + AdaptiveListTile( + leading: const Icon( + SpotubeIcons.heart, + color: Colors.pink, + ), + title: SizedBox( + height: 50, + width: 200, + child: Align( + alignment: Alignment.centerLeft, + child: AutoSizeText( + context.l10n.u_love_spotube, + maxLines: 1, + style: const TextStyle( + color: Colors.pink, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + trailing: (context, update) => FilledButton( + style: ButtonStyle( + backgroundColor: MaterialStatePropertyAll(Colors.red[100]), + foregroundColor: + const MaterialStatePropertyAll(Colors.pinkAccent), + padding: const MaterialStatePropertyAll(EdgeInsets.all(15)), + ), + onPressed: () { + launchUrlString( + "https://opencollective.com/spotube", + mode: LaunchMode.externalApplication, + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(SpotubeIcons.heart), + const SizedBox(width: 5), + Text(context.l10n.please_sponsor), + ], + ), + ), + ), + if (Env.enableUpdateChecker) + SwitchListTile( + secondary: const Icon(SpotubeIcons.update), + title: Text(context.l10n.check_for_updates), + value: preferences.checkUpdate, + onChanged: (checked) => preferencesNotifier.setCheckUpdate(checked), + ), + ListTile( + leading: const Icon(SpotubeIcons.info), + title: Text(context.l10n.about_spotube), + trailing: const Icon(SpotubeIcons.angleRight), + onTap: () { + GoRouter.of(context).push("/settings/about"); + }, + ) + ], + ); + } +} diff --git a/lib/pages/settings/sections/appearance.dart b/lib/pages/settings/sections/appearance.dart new file mode 100644 index 00000000..5de36c63 --- /dev/null +++ b/lib/pages/settings/sections/appearance.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; +import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + +class SettingsAppearanceSection extends HookConsumerWidget { + const SettingsAppearanceSection({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final preferences = ref.watch(userPreferencesProvider); + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); + final pickColorScheme = useCallback(() { + return () => showDialog( + context: context, + builder: (context) { + return const ColorSchemePickerDialog(); + }); + }, []); + + return SectionCardWithHeading( + heading: context.l10n.appearance, + children: [ + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.dashboard), + title: Text(context.l10n.layout_mode), + subtitle: Text(context.l10n.override_layout_settings), + value: preferences.layoutMode, + onChanged: (value) { + if (value != null) { + preferencesNotifier.setLayoutMode(value); + } + }, + options: [ + DropdownMenuItem( + value: LayoutMode.adaptive, + child: Text(context.l10n.adaptive), + ), + DropdownMenuItem( + value: LayoutMode.compact, + child: Text(context.l10n.compact), + ), + DropdownMenuItem( + value: LayoutMode.extended, + child: Text(context.l10n.extended), + ), + ], + ), + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.darkMode), + title: Text(context.l10n.theme), + value: preferences.themeMode, + options: [ + DropdownMenuItem( + value: ThemeMode.dark, + child: Text(context.l10n.dark), + ), + DropdownMenuItem( + value: ThemeMode.light, + child: Text(context.l10n.light), + ), + DropdownMenuItem( + value: ThemeMode.system, + child: Text(context.l10n.system), + ), + ], + onChanged: (value) { + if (value != null) { + preferencesNotifier.setThemeMode(value); + } + }, + ), + SwitchListTile( + secondary: const Icon(SpotubeIcons.amoled), + title: Text(context.l10n.use_amoled_mode), + subtitle: Text(context.l10n.pitch_dark_theme), + value: preferences.amoledDarkTheme, + onChanged: preferencesNotifier.setAmoledDarkTheme, + ), + ListTile( + leading: const Icon(SpotubeIcons.palette), + title: Text(context.l10n.accent_color), + contentPadding: const EdgeInsets.symmetric( + horizontal: 15, + vertical: 5, + ), + trailing: ColorTile.compact( + color: preferences.accentColorScheme, + onPressed: pickColorScheme(), + isActive: true, + ), + onTap: pickColorScheme(), + ), + SwitchListTile( + secondary: const Icon(SpotubeIcons.colorSync), + title: Text(context.l10n.sync_album_color), + subtitle: Text(context.l10n.sync_album_color_description), + value: preferences.albumColorSync, + onChanged: preferencesNotifier.setAlbumColorSync, + ), + ], + ); + } +} diff --git a/lib/pages/settings/sections/desktop.dart b/lib/pages/settings/sections/desktop.dart new file mode 100644 index 00000000..41d6d61e --- /dev/null +++ b/lib/pages/settings/sections/desktop.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + +class SettingsDesktopSection extends HookConsumerWidget { + const SettingsDesktopSection({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final preferences = ref.watch(userPreferencesProvider); + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); + + return SectionCardWithHeading( + heading: context.l10n.desktop, + children: [ + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.close), + title: Text(context.l10n.close_behavior), + value: preferences.closeBehavior, + options: [ + DropdownMenuItem( + value: CloseBehavior.close, + child: Text(context.l10n.close), + ), + DropdownMenuItem( + value: CloseBehavior.minimizeToTray, + child: Text(context.l10n.minimize_to_tray), + ), + ], + onChanged: (value) { + if (value != null) { + preferencesNotifier.setCloseBehavior(value); + } + }, + ), + SwitchListTile( + secondary: const Icon(SpotubeIcons.tray), + title: Text(context.l10n.show_tray_icon), + value: preferences.showSystemTrayIcon, + onChanged: preferencesNotifier.setShowSystemTrayIcon, + ), + SwitchListTile( + secondary: const Icon(SpotubeIcons.window), + title: Text(context.l10n.use_system_title_bar), + value: preferences.systemTitleBar, + onChanged: preferencesNotifier.setSystemTitleBar, + ), + ], + ); + } +} diff --git a/lib/pages/settings/sections/developers.dart b/lib/pages/settings/sections/developers.dart new file mode 100644 index 00000000..4b5f58a6 --- /dev/null +++ b/lib/pages/settings/sections/developers.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/extensions/context.dart'; + +class SettingsDevelopersSection extends HookWidget { + const SettingsDevelopersSection({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return SectionCardWithHeading( + heading: context.l10n.developers, + children: [ + ListTile( + leading: const Icon(SpotubeIcons.logs), + title: Text(context.l10n.logs), + trailing: const Icon(SpotubeIcons.angleRight), + onTap: () { + GoRouter.of(context).push("/settings/logs"); + }, + ) + ], + ); + } +} diff --git a/lib/pages/settings/sections/downloads.dart b/lib/pages/settings/sections/downloads.dart new file mode 100644 index 00000000..12026909 --- /dev/null +++ b/lib/pages/settings/sections/downloads.dart @@ -0,0 +1,52 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; + +class SettingsDownloadsSection extends HookConsumerWidget { + const SettingsDownloadsSection({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); + final preferences = ref.watch(userPreferencesProvider); + + final pickDownloadLocation = useCallback(() async { + if (DesktopTools.platform.isMobile) { + final dirStr = await FilePicker.platform.getDirectoryPath( + initialDirectory: preferences.downloadLocation, + ); + if (dirStr == null) return; + preferencesNotifier.setDownloadLocation(dirStr); + } else { + String? dirStr = await getDirectoryPath( + initialDirectory: preferences.downloadLocation, + ); + if (dirStr == null) return; + preferencesNotifier.setDownloadLocation(dirStr); + } + }, [preferences.downloadLocation]); + + return SectionCardWithHeading( + heading: context.l10n.downloads, + children: [ + ListTile( + leading: const Icon(SpotubeIcons.download), + title: Text(context.l10n.download_location), + subtitle: Text(preferences.downloadLocation), + trailing: FilledButton( + onPressed: pickDownloadLocation, + child: const Icon(SpotubeIcons.folder), + ), + onTap: pickDownloadLocation, + ), + ], + ); + } +} diff --git a/lib/pages/settings/sections/language_region.dart b/lib/pages/settings/sections/language_region.dart new file mode 100644 index 00000000..9465feb3 --- /dev/null +++ b/lib/pages/settings/sections/language_region.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/language_codes.dart'; +import 'package:spotube/collections/spotify_markets.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/l10n/l10n.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; + +class SettingsLanguageRegionSection extends HookConsumerWidget { + const SettingsLanguageRegionSection({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final preferences = ref.watch(userPreferencesProvider); + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); + final mediaQuery = MediaQuery.of(context); + + return SectionCardWithHeading( + heading: context.l10n.language_region, + children: [ + AdaptiveSelectTile( + value: preferences.locale, + onChanged: (locale) { + if (locale == null) return; + preferencesNotifier.setLocale(locale); + }, + title: Text(context.l10n.language), + secondary: const Icon(SpotubeIcons.language), + options: [ + DropdownMenuItem( + value: const Locale("system", "system"), + child: Text(context.l10n.system_default), + ), + for (final locale in L10n.all) + DropdownMenuItem( + value: locale, + child: Builder(builder: (context) { + final isoCodeName = LanguageLocals.getDisplayLanguage( + locale.languageCode, + ); + return Text( + "${isoCodeName.name} (${isoCodeName.nativeName})", + ); + }), + ), + ], + ), + AdaptiveSelectTile( + breakLayout: mediaQuery.lgAndUp, + secondary: const Icon(SpotubeIcons.shoppingBag), + title: Text(context.l10n.market_place_region), + subtitle: Text(context.l10n.recommendation_country), + value: preferences.recommendationMarket, + onChanged: (value) { + if (value == null) return; + preferencesNotifier.setRecommendationMarket(value); + }, + options: spotifyMarkets + .map( + (country) => DropdownMenuItem( + value: country.$1, + child: Text(country.$2), + ), + ) + .toList(), + ), + ], + ); + } +} diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart new file mode 100644 index 00000000..a0316b33 --- /dev/null +++ b/lib/pages/settings/sections/playback.dart @@ -0,0 +1,227 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:piped_client/piped_client.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/piped_instances_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; + +class SettingsPlaybackSection extends HookConsumerWidget { + const SettingsPlaybackSection({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final preferences = ref.watch(userPreferencesProvider); + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); + final theme = Theme.of(context); + + return SectionCardWithHeading( + heading: context.l10n.playback, + children: [ + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.audioQuality), + title: Text(context.l10n.audio_quality), + value: preferences.audioQuality, + options: [ + DropdownMenuItem( + value: SourceQualities.high, + child: Text(context.l10n.high), + ), + DropdownMenuItem( + value: SourceQualities.medium, + child: Text(context.l10n.medium), + ), + DropdownMenuItem( + value: SourceQualities.low, + child: Text(context.l10n.low), + ), + ], + onChanged: (value) { + if (value != null) { + preferencesNotifier.setAudioQuality(value); + } + }, + ), + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.api), + title: Text(context.l10n.youtube_api_type), + value: preferences.audioSource, + options: AudioSource.values + .map((e) => DropdownMenuItem( + value: e, + child: Text(e.label), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + preferencesNotifier.setAudioSource(value); + }, + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: preferences.audioSource != AudioSource.piped + ? const SizedBox.shrink() + : Consumer(builder: (context, ref, child) { + final instanceList = ref.watch(pipedInstancesFutureProvider); + + return instanceList.when( + data: (data) { + return AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.piped), + title: Text(context.l10n.piped_instance), + subtitle: RichText( + text: TextSpan( + children: [ + TextSpan( + text: context.l10n.piped_description, + style: theme.textTheme.bodyMedium, + ), + const TextSpan(text: "\n"), + TextSpan( + text: context.l10n.piped_warning, + style: theme.textTheme.labelMedium, + ) + ], + ), + ), + value: preferences.pipedInstance, + showValueWhenUnfolded: false, + options: data + .sortedBy((e) => e.name) + .map( + (e) => DropdownMenuItem( + value: e.apiUrl, + child: RichText( + text: TextSpan( + children: [ + TextSpan( + text: "${e.name.trim()}\n", + style: theme.textTheme.labelLarge, + ), + TextSpan( + text: e.locations + .map(countryCodeToEmoji) + .join(""), + style: GoogleFonts.notoColorEmoji(), + ), + ], + ), + ), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + preferencesNotifier.setPipedInstance(value); + } + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, stackTrace) => Text(error.toString()), + ); + }), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: preferences.audioSource != AudioSource.piped + ? const SizedBox.shrink() + : AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.search), + title: Text(context.l10n.search_mode), + value: preferences.searchMode, + options: SearchMode.values + .map((e) => DropdownMenuItem( + value: e, + child: Text(e.label), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + preferencesNotifier.setSearchMode(value); + }, + ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: preferences.searchMode == SearchMode.youtube && + (preferences.audioSource == AudioSource.piped || + preferences.audioSource == AudioSource.youtube) + ? SwitchListTile( + secondary: const Icon(SpotubeIcons.skip), + title: Text(context.l10n.skip_non_music), + value: preferences.skipNonMusic, + onChanged: (state) { + preferencesNotifier.setSkipNonMusic(state); + }, + ) + : const SizedBox.shrink(), + ), + ListTile( + leading: const Icon(SpotubeIcons.playlistRemove), + title: Text(context.l10n.blacklist), + subtitle: Text(context.l10n.blacklist_description), + onTap: () { + GoRouter.of(context).push("/settings/blacklist"); + }, + trailing: const Icon(SpotubeIcons.angleRight), + ), + SwitchListTile( + secondary: const Icon(SpotubeIcons.normalize), + title: Text(context.l10n.normalize_audio), + value: preferences.normalizeAudio, + onChanged: preferencesNotifier.setNormalizeAudio, + ), + if (preferences.audioSource != AudioSource.jiosaavn) + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.stream), + title: Text(context.l10n.streaming_music_codec), + value: preferences.streamMusicCodec, + showValueWhenUnfolded: false, + options: SourceCodecs.values + .map((e) => DropdownMenuItem( + value: e, + child: Text( + e.label, + style: theme.textTheme.labelMedium, + ), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + preferencesNotifier.setStreamMusicCodec(value); + }, + ), + if (preferences.audioSource != AudioSource.jiosaavn) + AdaptiveSelectTile( + secondary: const Icon(SpotubeIcons.file), + title: Text(context.l10n.download_music_codec), + value: preferences.downloadMusicCodec, + showValueWhenUnfolded: false, + options: SourceCodecs.values + .map((e) => DropdownMenuItem( + value: e, + child: Text( + e.label, + style: theme.textTheme.labelMedium, + ), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + preferencesNotifier.setDownloadMusicCodec(value); + }, + ), + ], + ); + } +} diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index e319997a..f773b809 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -1,62 +1,27 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:collection/collection.dart'; -import 'package:file_selector/file_selector.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:piped_client/piped_client.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/env.dart'; -import 'package:spotube/collections/language_codes.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; -import 'package:spotube/components/settings/section_card_with_heading.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_list_tile.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/collections/spotify_markets.dart'; -import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/l10n/l10n.dart'; -import 'package:spotube/models/matched_track.dart'; +import 'package:spotube/pages/settings/sections/about.dart'; import 'package:spotube/pages/settings/sections/accounts.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/provider/piped_instances_provider.dart'; -import 'package:url_launcher/url_launcher_string.dart'; +import 'package:spotube/pages/settings/sections/appearance.dart'; +import 'package:spotube/pages/settings/sections/desktop.dart'; +import 'package:spotube/pages/settings/sections/developers.dart'; +import 'package:spotube/pages/settings/sections/downloads.dart'; +import 'package:spotube/pages/settings/sections/language_region.dart'; +import 'package:spotube/pages/settings/sections/playback.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; class SettingsPage extends HookConsumerWidget { const SettingsPage({Key? key}) : super(key: key); @override Widget build(BuildContext context, ref) { - final preferences = ref.watch(userPreferencesProvider); - final theme = Theme.of(context); - final mediaQuery = MediaQuery.of(context); - - final pickColorScheme = useCallback(() { - return () => showDialog( - context: context, - builder: (context) { - return const ColorSchemePickerDialog(); - }); - }, []); - - final pickDownloadLocation = useCallback(() async { - String? dirStr = await getDirectoryPath( - initialDirectory: preferences.downloadLocation, - ); - if (dirStr == null) return; - if (DesktopTools.platform.isAndroid && dirStr.startsWith("content://")) { - dirStr = - "/storage/emulated/0/${Uri.decodeFull(dirStr).split("primary:").last}"; - } - preferences.setDownloadLocation(dirStr); - }, [preferences.downloadLocation]); + final controller = useScrollController(); + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); return SafeArea( bottom: false, @@ -65,509 +30,37 @@ class SettingsPage extends HookConsumerWidget { title: Text(context.l10n.settings), centerTitle: true, ), - body: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: Container( - constraints: const BoxConstraints(maxWidth: 1366), - child: InterScrollbar( - child: ListView( - children: [ - const SettingsAccountSection(), - SectionCardWithHeading( - heading: context.l10n.language_region, - children: [ - AdaptiveSelectTile( - value: preferences.locale, - onChanged: (locale) { - if (locale == null) return; - preferences.setLocale(locale); - }, - title: Text(context.l10n.language), - secondary: const Icon(SpotubeIcons.language), - options: [ - DropdownMenuItem( - value: const Locale("system", "system"), - child: Text(context.l10n.system_default), - ), - for (final locale in L10n.all) - DropdownMenuItem( - value: locale, - child: Builder(builder: (context) { - final isoCodeName = - LanguageLocals.getDisplayLanguage( - locale.languageCode, - ); - return Text( - "${isoCodeName.name} (${isoCodeName.nativeName})", - ); - }), - ), - ], - ), - AdaptiveSelectTile( - breakLayout: mediaQuery.lgAndUp, - secondary: const Icon(SpotubeIcons.shoppingBag), - title: Text(context.l10n.market_place_region), - subtitle: Text(context.l10n.recommendation_country), - value: preferences.recommendationMarket, - onChanged: (value) { - if (value == null) return; - preferences.setRecommendationMarket(value); - }, - options: spotifyMarkets - .map( - (country) => DropdownMenuItem( - value: country.$1, - child: Text(country.$2), - ), - ) - .toList(), - ), - ], + body: Scrollbar( + controller: controller, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1366), + child: ScrollConfiguration( + behavior: const ScrollBehavior().copyWith(scrollbars: false), + child: ListView( + controller: controller, + children: [ + const SettingsAccountSection(), + const SettingsLanguageRegionSection(), + const SettingsAppearanceSection(), + const SettingsPlaybackSection(), + const SettingsDownloadsSection(), + if (DesktopTools.platform.isDesktop) + const SettingsDesktopSection(), + if (!kIsWeb) const SettingsDevelopersSection(), + const SettingsAboutSection(), + Center( + child: FilledButton( + onPressed: preferencesNotifier.reset, + child: Text(context.l10n.restore_defaults), ), - SectionCardWithHeading( - heading: context.l10n.appearance, - children: [ - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.dashboard), - title: Text(context.l10n.layout_mode), - subtitle: - Text(context.l10n.override_layout_settings), - value: preferences.layoutMode, - onChanged: (value) { - if (value != null) { - preferences.setLayoutMode(value); - } - }, - options: [ - DropdownMenuItem( - value: LayoutMode.adaptive, - child: Text(context.l10n.adaptive), - ), - DropdownMenuItem( - value: LayoutMode.compact, - child: Text(context.l10n.compact), - ), - DropdownMenuItem( - value: LayoutMode.extended, - child: Text(context.l10n.extended), - ), - ], - ), - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.darkMode), - title: Text(context.l10n.theme), - value: preferences.themeMode, - options: [ - DropdownMenuItem( - value: ThemeMode.dark, - child: Text(context.l10n.dark), - ), - DropdownMenuItem( - value: ThemeMode.light, - child: Text(context.l10n.light), - ), - DropdownMenuItem( - value: ThemeMode.system, - child: Text(context.l10n.system), - ), - ], - onChanged: (value) { - if (value != null) { - preferences.setThemeMode(value); - } - }, - ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.amoled), - title: Text(context.l10n.use_amoled_mode), - subtitle: Text(context.l10n.pitch_dark_theme), - value: preferences.amoledDarkTheme, - onChanged: preferences.setAmoledDarkTheme, - ), - ListTile( - leading: const Icon(SpotubeIcons.palette), - title: Text(context.l10n.accent_color), - contentPadding: const EdgeInsets.symmetric( - horizontal: 15, - vertical: 5, - ), - trailing: ColorTile.compact( - color: preferences.accentColorScheme, - onPressed: pickColorScheme(), - isActive: true, - ), - onTap: pickColorScheme(), - ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.colorSync), - title: Text(context.l10n.sync_album_color), - subtitle: - Text(context.l10n.sync_album_color_description), - value: preferences.albumColorSync, - onChanged: preferences.setAlbumColorSync, - ), - ], - ), - SectionCardWithHeading( - heading: context.l10n.playback, - children: [ - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.audioQuality), - title: Text(context.l10n.audio_quality), - value: preferences.audioQuality, - options: [ - DropdownMenuItem( - value: AudioQuality.high, - child: Text(context.l10n.high), - ), - DropdownMenuItem( - value: AudioQuality.low, - child: Text(context.l10n.low), - ), - ], - onChanged: (value) { - if (value != null) { - preferences.setAudioQuality(value); - } - }, - ), - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.api), - title: Text(context.l10n.youtube_api_type), - value: preferences.youtubeApiType, - options: YoutubeApiType.values - .map((e) => DropdownMenuItem( - value: e, - child: Text(e.label), - )) - .toList(), - onChanged: (value) { - if (value == null) return; - preferences.setYoutubeApiType(value); - }, - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: preferences.youtubeApiType == - YoutubeApiType.youtube - ? const SizedBox.shrink() - : Consumer(builder: (context, ref, child) { - final instanceList = - ref.watch(pipedInstancesFutureProvider); - - return instanceList.when( - data: (data) { - return AdaptiveSelectTile( - secondary: - const Icon(SpotubeIcons.piped), - title: - Text(context.l10n.piped_instance), - subtitle: RichText( - text: TextSpan( - children: [ - TextSpan( - text: context - .l10n.piped_description, - style: theme - .textTheme.bodyMedium, - ), - const TextSpan(text: "\n"), - TextSpan( - text: context - .l10n.piped_warning, - style: theme - .textTheme.labelMedium, - ) - ], - ), - ), - value: preferences.pipedInstance, - showValueWhenUnfolded: false, - options: data - .sortedBy((e) => e.name) - .map( - (e) => DropdownMenuItem( - value: e.apiUrl, - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: - "${e.name.trim()}\n", - style: theme.textTheme - .labelLarge, - ), - TextSpan( - text: e.locations - .map( - countryCodeToEmoji) - .join(""), - style: GoogleFonts - .notoColorEmoji(), - ), - ], - ), - ), - ), - ) - .toList(), - onChanged: (value) { - if (value != null) { - preferences - .setPipedInstance(value); - } - }, - ); - }, - loading: () => const Center( - child: CircularProgressIndicator(), - ), - error: (error, stackTrace) => - Text(error.toString()), - ); - }), - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: preferences.youtubeApiType == - YoutubeApiType.youtube - ? const SizedBox.shrink() - : AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.search), - title: Text(context.l10n.search_mode), - value: preferences.searchMode, - options: SearchMode.values - .map((e) => DropdownMenuItem( - value: e, - child: Text(e.label), - )) - .toList(), - onChanged: (value) { - if (value == null) return; - preferences.setSearchMode(value); - }, - ), - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: preferences.searchMode == - SearchMode.youtubeMusic && - preferences.youtubeApiType == - YoutubeApiType.piped - ? const SizedBox.shrink() - : SwitchListTile( - secondary: const Icon(SpotubeIcons.skip), - title: Text(context.l10n.skip_non_music), - value: preferences.skipNonMusic, - onChanged: (state) { - preferences.setSkipNonMusic(state); - }, - ), - ), - ListTile( - leading: const Icon(SpotubeIcons.playlistRemove), - title: Text(context.l10n.blacklist), - subtitle: Text(context.l10n.blacklist_description), - onTap: () { - GoRouter.of(context).push("/settings/blacklist"); - }, - trailing: const Icon(SpotubeIcons.angleRight), - ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.normalize), - title: Text(context.l10n.normalize_audio), - value: preferences.normalizeAudio, - onChanged: preferences.setNormalizeAudio, - ), - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.stream), - title: Text(context.l10n.streaming_music_codec), - value: preferences.streamMusicCodec, - showValueWhenUnfolded: false, - options: MusicCodec.values - .map((e) => DropdownMenuItem( - value: e, - child: Text( - e.label, - style: theme.textTheme.labelMedium, - ), - )) - .toList(), - onChanged: (value) { - if (value == null) return; - preferences.setStreamMusicCodec(value); - }, - ), - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.file), - title: Text(context.l10n.download_music_codec), - value: preferences.downloadMusicCodec, - showValueWhenUnfolded: false, - options: MusicCodec.values - .map((e) => DropdownMenuItem( - value: e, - child: Text( - e.label, - style: theme.textTheme.labelMedium, - ), - )) - .toList(), - onChanged: (value) { - if (value == null) return; - preferences.setDownloadMusicCodec(value); - }, - ), - ], - ), - SectionCardWithHeading( - heading: context.l10n.downloads, - children: [ - ListTile( - leading: const Icon(SpotubeIcons.download), - title: Text(context.l10n.download_location), - subtitle: Text(preferences.downloadLocation), - trailing: FilledButton( - onPressed: pickDownloadLocation, - child: const Icon(SpotubeIcons.folder), - ), - onTap: pickDownloadLocation, - ), - ], - ), - if (DesktopTools.platform.isDesktop) - SectionCardWithHeading( - heading: context.l10n.desktop, - children: [ - AdaptiveSelectTile( - secondary: const Icon(SpotubeIcons.close), - title: Text(context.l10n.close_behavior), - value: preferences.closeBehavior, - options: [ - DropdownMenuItem( - value: CloseBehavior.close, - child: Text(context.l10n.close), - ), - DropdownMenuItem( - value: CloseBehavior.minimizeToTray, - child: Text(context.l10n.minimize_to_tray), - ), - ], - onChanged: (value) { - if (value != null) { - preferences.setCloseBehavior(value); - } - }, - ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.tray), - title: Text(context.l10n.show_tray_icon), - value: preferences.showSystemTrayIcon, - onChanged: preferences.setShowSystemTrayIcon, - ), - SwitchListTile( - secondary: const Icon(SpotubeIcons.window), - title: Text(context.l10n.use_system_title_bar), - value: preferences.systemTitleBar, - onChanged: preferences.setSystemTitleBar, - ), - ], - ), - if (!kIsWeb) - SectionCardWithHeading( - heading: context.l10n.developers, - children: [ - ListTile( - leading: const Icon(SpotubeIcons.logs), - title: Text(context.l10n.logs), - trailing: const Icon(SpotubeIcons.angleRight), - onTap: () { - GoRouter.of(context).push("/settings/logs"); - }, - ) - ], - ), - SectionCardWithHeading( - heading: context.l10n.about, - children: [ - AdaptiveListTile( - leading: const Icon( - SpotubeIcons.heart, - color: Colors.pink, - ), - title: SizedBox( - height: 50, - width: 200, - child: Align( - alignment: Alignment.centerLeft, - child: AutoSizeText( - context.l10n.u_love_spotube, - maxLines: 1, - style: const TextStyle( - color: Colors.pink, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - trailing: (context, update) => FilledButton( - style: ButtonStyle( - backgroundColor: - MaterialStatePropertyAll(Colors.red[100]), - foregroundColor: const MaterialStatePropertyAll( - Colors.pinkAccent), - padding: const MaterialStatePropertyAll( - EdgeInsets.all(15)), - ), - onPressed: () { - launchUrlString( - "https://opencollective.com/spotube", - mode: LaunchMode.externalApplication, - ); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(SpotubeIcons.heart), - const SizedBox(width: 5), - Text(context.l10n.please_sponsor), - ], - ), - ), - ), - if (Env.enableUpdateChecker) - SwitchListTile( - secondary: const Icon(SpotubeIcons.update), - title: Text(context.l10n.check_for_updates), - value: preferences.checkUpdate, - onChanged: (checked) => - preferences.setCheckUpdate(checked), - ), - ListTile( - leading: const Icon(SpotubeIcons.info), - title: Text(context.l10n.about_spotube), - trailing: const Icon(SpotubeIcons.angleRight), - onTap: () { - GoRouter.of(context).push("/settings/about"); - }, - ) - ], - ), - Center( - child: FilledButton( - onPressed: preferences.reset, - child: Text(context.l10n.restore_defaults), - ), - ), - const SizedBox(height: 10), - ], - ), + ), + const SizedBox(height: 10), + ], ), ), ), - ], + ), ), ), ); diff --git a/lib/provider/authentication_provider.dart b/lib/provider/authentication_provider.dart index f1cf58ec..cd77e7bb 100644 --- a/lib/provider/authentication_provider.dart +++ b/lib/provider/authentication_provider.dart @@ -1,6 +1,7 @@ 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'; @@ -51,7 +52,8 @@ class AuthenticationCredentials { ), ); } catch (e) { - if (rootNavigatorKey?.currentContext != null) { + if (rootNavigatorKey?.currentContext != null && + await QueryClient.connectivity.isConnected) { showPromptDialog( context: rootNavigatorKey!.currentContext!, title: rootNavigatorKey!.currentContext!.l10n diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index 46c7ee7e..dc538938 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -9,24 +9,23 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/models/spotube_track.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/provider/youtube_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/download_manager/download_manager.dart'; -import 'package:spotube/services/youtube/youtube.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class DownloadManagerProvider extends ChangeNotifier { DownloadManagerProvider({required this.ref}) - : $history = {}, + : $history = {}, $backHistory = {}, dl = DownloadManager() { dl.statusStream.listen((event) async { final (:request, :status) = event; final track = $history.firstWhereOrNull( - (element) => element.ytUri == request.url, + (element) => element.getUrlOfCodec(downloadCodec) == request.url, ); if (track == null) return; @@ -44,7 +43,7 @@ class DownloadManagerProvider extends ChangeNotifier { //? WebA audiotagging is not supported yet //? Although in future by converting weba to opus & then tagging it //? is possible using vorbis comments - downloadCodec == MusicCodec.weba) return; + downloadCodec == SourceCodecs.weba) return; final file = File(request.path); @@ -90,10 +89,9 @@ class DownloadManagerProvider extends ChangeNotifier { final Ref ref; - YoutubeEndpoints get yt => ref.read(youtubeProvider); String get downloadDirectory => ref.read(userPreferencesProvider.select((s) => s.downloadLocation)); - MusicCodec get downloadCodec => + SourceCodecs get downloadCodec => ref.read(userPreferencesProvider.select((s) => s.downloadMusicCodec)); int get $downloadCount => dl @@ -106,7 +104,7 @@ class DownloadManagerProvider extends ChangeNotifier { ) .length; - final Set $history; + final Set $history; // these are the tracks which metadata hasn't been fetched yet final Set $backHistory; final DownloadManager dl; @@ -143,9 +141,9 @@ class DownloadManagerProvider extends ChangeNotifier { bool isActive(Track track) { if ($backHistory.contains(track)) return true; - final spotubeTrack = mapToSpotubeTrack(track); + final sourcedTrack = mapToSourcedTrack(track); - if (spotubeTrack == null) return false; + if (sourcedTrack == null) return false; return dl .getAllDownloads() @@ -156,7 +154,7 @@ class DownloadManagerProvider extends ChangeNotifier { download.status.value == DownloadStatus.queued, ) .map((e) => e.request.url) - .contains(spotubeTrack.ytUri); + .contains(sourcedTrack.getUrlOfCodec(downloadCodec)); } /// For singular downloads @@ -172,21 +170,27 @@ class DownloadManagerProvider extends ChangeNotifier { await oldFile.rename("$savePath.old"); } - if (track is SpotubeTrack && track.codec == downloadCodec) { - final downloadTask = await dl.addDownload(track.ytUri, savePath); + if (track is SourcedTrack && track.codec == downloadCodec) { + final downloadTask = + await dl.addDownload(track.getUrlOfCodec(downloadCodec), savePath); if (downloadTask != null) { $history.add(track); } } else { $backHistory.add(track); - final spotubeTrack = - await SpotubeTrack.fetchFromTrack(track, yt, downloadCodec).then((d) { + final sourcedTrack = await SourcedTrack.fetchFromTrack( + ref: ref, + track: track, + ).then((d) { $backHistory.remove(track); return d; }); - final downloadTask = await dl.addDownload(spotubeTrack.ytUri, savePath); + final downloadTask = await dl.addDownload( + sourcedTrack.getUrlOfCodec(downloadCodec), + savePath, + ); if (downloadTask != null) { - $history.add(spotubeTrack); + $history.add(sourcedTrack); } } @@ -195,7 +199,7 @@ class DownloadManagerProvider extends ChangeNotifier { Future batchAddToQueue(List tracks) async { $backHistory.addAll( - tracks.where((element) => element is! SpotubeTrack), + tracks.where((element) => element is! SourcedTrack), ); notifyListeners(); for (final track in tracks) { @@ -215,25 +219,25 @@ class DownloadManagerProvider extends ChangeNotifier { } } - Future removeFromQueue(SpotubeTrack track) async { - await dl.removeDownload(track.ytUri); + Future removeFromQueue(SourcedTrack track) async { + await dl.removeDownload(track.getUrlOfCodec(downloadCodec)); $history.remove(track); } - Future pause(SpotubeTrack track) { - return dl.pauseDownload(track.ytUri); + Future pause(SourcedTrack track) { + return dl.pauseDownload(track.getUrlOfCodec(downloadCodec)); } - Future resume(SpotubeTrack track) { - return dl.resumeDownload(track.ytUri); + Future resume(SourcedTrack track) { + return dl.resumeDownload(track.getUrlOfCodec(downloadCodec)); } - Future retry(SpotubeTrack track) { + Future retry(SourcedTrack track) { return addToQueue(track); } - void cancel(SpotubeTrack track) { - dl.cancelDownload(track.ytUri); + void cancel(SourcedTrack track) { + dl.cancelDownload(track.getUrlOfCodec(downloadCodec)); } void cancelAll() { @@ -243,20 +247,20 @@ class DownloadManagerProvider extends ChangeNotifier { } } - SpotubeTrack? mapToSpotubeTrack(Track track) { - if (track is SpotubeTrack) { + SourcedTrack? mapToSourcedTrack(Track track) { + if (track is SourcedTrack) { return track; } else { return $history.firstWhereOrNull((element) => element.id == track.id); } } - ValueNotifier? getStatusNotifier(SpotubeTrack track) { - return dl.getDownload(track.ytUri)?.status; + ValueNotifier? getStatusNotifier(SourcedTrack track) { + return dl.getDownload(track.getUrlOfCodec(downloadCodec))?.status; } - ValueNotifier? getProgressNotifier(SpotubeTrack track) { - return dl.getDownload(track.ytUri)?.progress; + ValueNotifier? getProgressNotifier(SourcedTrack track) { + return dl.getDownload(track.getUrlOfCodec(downloadCodec))?.progress; } } diff --git a/lib/provider/piped_instances_provider.dart b/lib/provider/piped_instances_provider.dart index 290ad2c4..264b7048 100644 --- a/lib/provider/piped_instances_provider.dart +++ b/lib/provider/piped_instances_provider.dart @@ -1,10 +1,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:piped_client/piped_client.dart'; -import 'package:spotube/provider/youtube_provider.dart'; +import 'package:spotube/services/sourced_track/sources/piped.dart'; final pipedInstancesFutureProvider = FutureProvider>( (ref) async { - final youtube = ref.watch(youtubeProvider); - return await youtube.piped?.instanceList() ?? []; + final pipedClient = ref.watch(pipedProvider); + + return await pipedClient.instanceList(); }, ); diff --git a/lib/provider/proxy_playlist/next_fetcher_mixin.dart b/lib/provider/proxy_playlist/next_fetcher_mixin.dart index 61b86d8c..1d2cfde8 100644 --- a/lib/provider/proxy_playlist/next_fetcher_mixin.dart +++ b/lib/provider/proxy_playlist/next_fetcher_mixin.dart @@ -1,39 +1,32 @@ import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/logger.dart'; -import 'package:spotube/models/matched_track.dart'; -import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/services/supabase.dart'; -import 'package:spotube/services/youtube/youtube.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; final logger = getLogger("NextFetcherMixin"); mixin NextFetcher on StateNotifier { - Future> fetchTracks( - UserPreferences preferences, - YoutubeEndpoints youtube, { + Future> fetchTracks( + Ref ref, { int count = 3, int offset = 0, }) async { - /// get [count] [state.tracks] that are not [SpotubeTrack] and [LocalTrack] + /// get [count] [state.tracks] that are not [SourcedTrack] and [LocalTrack] final bareTracks = state.tracks .skip(offset) - .where((element) => element is! SpotubeTrack && element is! LocalTrack) + .where((element) => element is! SourcedTrack && element is! LocalTrack) .take(count); /// fetch [bareTracks] one by one with 100ms delay final fetchedTracks = await Future.wait( bareTracks.mapIndexed((i, track) async { - final future = SpotubeTrack.fetchFromTrack( - track, - youtube, - preferences.streamMusicCodec, + final future = SourcedTrack.fetchFromTrack( + ref: ref, + track: track, ); if (i == 0) { return await future; @@ -48,9 +41,9 @@ mixin NextFetcher on StateNotifier { return fetchedTracks; } - /// Merges List of [SpotubeTrack]s with [Track]s and outputs a mixed List + /// Merges List of [SourcedTrack]s with [Track]s and outputs a mixed List Set mergeTracks( - Iterable fetchTracks, + Iterable fetchTracks, Iterable tracks, ) { return tracks.map((track) { @@ -81,12 +74,12 @@ mixin NextFetcher on StateNotifier { /// Returns appropriate Media source for [Track] /// - /// * If [Track] is [SpotubeTrack] then return [SpotubeTrack.ytUri] + /// * If [Track] is [SourcedTrack] then return [SourcedTrack.ytUri] /// * If [Track] is [LocalTrack] then return [LocalTrack.path] /// * If [Track] is [Track] then return [Track.id] with [isUnPlayable] source String makeAppropriateSource(Track track) { - if (track is SpotubeTrack) { - return track.ytUri; + if (track is SourcedTrack) { + return track.url; } else if (track is LocalTrack) { return track.path; } else { @@ -104,7 +97,7 @@ mixin NextFetcher on StateNotifier { final track = state.tracks.firstWhereOrNull( (track) => trackToUnplayableSource(track) == source || - (track is SpotubeTrack && track.ytUri == source) || + (track is SourcedTrack && track.url == source) || (track is LocalTrack && track.path == source), ); return track; @@ -112,23 +105,4 @@ mixin NextFetcher on StateNotifier { .whereNotNull() .toList(); } - - /// This method must be called after any playback operation as - /// it can increase the latency - Future storeTrack(Track track, SpotubeTrack spotubeTrack) async { - try { - if (track is! SpotubeTrack) { - await supabase.insertTrack( - MatchedTrack( - youtubeId: spotubeTrack.ytTrack.id, - spotifyId: spotubeTrack.id!, - searchMode: spotubeTrack.ytTrack.searchMode, - ), - ); - } - } catch (e, stackTrace) { - logger.e(e.toString()); - logger.t(stackTrace); - } - } } diff --git a/lib/provider/proxy_playlist/proxy_playlist.dart b/lib/provider/proxy_playlist/proxy_playlist.dart index c0563f21..026b3403 100644 --- a/lib/provider/proxy_playlist/proxy_playlist.dart +++ b/lib/provider/proxy_playlist/proxy_playlist.dart @@ -1,8 +1,9 @@ import 'package:collection/collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; -import 'package:spotube/models/spotube_track.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; class ProxyPlaylist { final Set tracks; @@ -11,11 +12,14 @@ class ProxyPlaylist { ProxyPlaylist(this.tracks, [this.active, this.collections = const {}]); - factory ProxyPlaylist.fromJson(Map json) { + factory ProxyPlaylist.fromJson( + Map json, + Ref ref, + ) { return ProxyPlaylist( List.castFrom>( json['tracks'] ?? >[], - ).map(_makeAppropriateTrack).toSet(), + ).map((t) => _makeAppropriateTrack(t, ref)).toSet(), json['active'] as int?, json['collections'] == null ? {} @@ -28,7 +32,7 @@ class ProxyPlaylist { bool get isFetching => activeTrack != null && - activeTrack is! SpotubeTrack && + activeTrack is! SourcedTrack && activeTrack is! LocalTrack; bool containsCollection(String collection) { @@ -44,9 +48,9 @@ class ProxyPlaylist { return tracks.every(containsTrack); } - static Track _makeAppropriateTrack(Map track) { + static Track _makeAppropriateTrack(Map track, Ref ref) { if (track.containsKey("ytUri")) { - return SpotubeTrack.fromJson(track); + return SourcedTrack.fromJson(track, ref: ref); } else if (track.containsKey("path")) { return LocalTrack.fromJson(track); } else { @@ -54,12 +58,14 @@ class ProxyPlaylist { } } + /// To make sure proper instance method is used for JSON serialization + /// Otherwise default super.toJson() is used static Map _makeAppropriateTrackJson(Track track) { - if (track is LocalTrack) { - return track.toJson(); - } else { - return track.toJson(); - } + return switch (track.runtimeType) { + LocalTrack => track.toJson(), + SourcedTrack => track.toJson(), + _ => track.toJson(), + }; } Map toJson() { diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index 685a9942..89bb8a6c 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -12,26 +12,30 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/logger.dart'; -import 'package:spotube/models/matched_track.dart'; + import 'package:spotube/models/skip_segment.dart'; -import 'package:spotube/models/spotube_track.dart'; + import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/next_fetcher_mixin.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/provider/youtube_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_services/audio_services.dart'; -import 'package:spotube/services/youtube/youtube.dart'; +import 'package:spotube/services/discord/discord.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/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; /// Things implemented: /// * [x] Sponsor-Block skip -/// * [x] Prefetch next track as [SpotubeTrack] on 80% of current track -/// * [x] Mixed Queue containing both [SpotubeTrack] and [LocalTrack] +/// * [x] Prefetch next track as [SourcedTrack] on 80% of current track +/// * [x] Mixed Queue containing both [SourcedTrack] and [LocalTrack] /// * [x] Modification of the Queue /// * [x] Add track at the end /// * [x] Add track at the beginning @@ -55,7 +59,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier ScrobblerNotifier get scrobbler => ref.read(scrobblerProvider.notifier); UserPreferences get preferences => ref.read(userPreferencesProvider); - YoutubeEndpoints get youtube => ref.read(youtubeProvider); ProxyPlaylist get playlist => state; BlackListNotifier get blacklist => ref.read(BlackListNotifier.provider.notifier); @@ -90,6 +93,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } notificationService.addTrack(newActiveTrack); + discord.updatePresence(newActiveTrack); state = state.copyWith( active: state.tracks .toList() @@ -130,21 +134,11 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier try { isPreSearching.value = true; - final oldTrack = - mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull; - final track = await ensureSourcePlayable(audioPlayer.nextSource!); if (track != null) { state = state.copyWith(tracks: mergeTracks([track], state.tracks)); } - - if (oldTrack != null && track != null) { - await storeTrack( - oldTrack, - track, - ); - } } catch (e, stackTrace) { // Removing tracks that were not found to avoid queue interruption // TODO: Add a flag to enable/disable skip not found tracks @@ -167,11 +161,11 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier return; } try { - final isYTMusicMode = - preferences.youtubeApiType == YoutubeApiType.piped && - preferences.searchMode == SearchMode.youtubeMusic; + final isNotYTMode = preferences.audioSource != AudioSource.youtube || + (preferences.audioSource == AudioSource.piped && + preferences.searchMode == SearchMode.youtubeMusic); - if (isYTMusicMode || !preferences.skipNonMusic) return; + if (isNotYTMode || !preferences.skipNonMusic) return; final isNotSameSegmentId = currentSegments.value?.source != audioPlayer.currentSource; @@ -183,7 +177,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier currentSegments.value = ( source: audioPlayer.currentSource!, segments: await getAndCacheSkipSegments( - (state.activeTrack as SpotubeTrack).ytTrack.id, + (state.activeTrack as SourcedTrack).sourceInfo.id, ), ); } catch (e) { @@ -213,10 +207,30 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier Catcher2.reportCheckedError(e, stackTrace); } }); + + String? lastScrobbled; + audioPlayer.positionStream.listen((position) { + try { + final uid = state.activeTrack is LocalTrack + ? (state.activeTrack as LocalTrack).path + : state.activeTrack?.id; + + if (state.activeTrack == null || + lastScrobbled == uid || + position.inSeconds < 30) { + return; + } + + scrobbler.scrobble(state.activeTrack!); + lastScrobbled = uid; + } catch (e, stack) { + Catcher2.reportCheckedError(e, stack); + } + }); }(); } - Future ensureSourcePlayable(String source) async { + Future ensureSourcePlayable(String source) async { if (isPlayable(source)) return null; final track = mapSourcesToTracks([source]).firstOrNull; @@ -226,17 +240,13 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } final nthFetchedTrack = switch (track.runtimeType) { - SpotubeTrack => track as SpotubeTrack, - _ => await SpotubeTrack.fetchFromTrack( - track, - youtube, - preferences.streamMusicCodec, - ), + SourcedTrack => track as SourcedTrack, + _ => await SourcedTrack.fetchFromTrack(ref: ref, track: track), }; await audioPlayer.replaceSource( source, - nthFetchedTrack.ytUri, + nthFetchedTrack.url, ); return nthFetchedTrack; @@ -313,16 +323,15 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier collections: {}, ); await notificationService.addTrack(indexTrack); + discord.updatePresence(indexTrack); } else { - final addableTrack = await SpotubeTrack.fetchFromTrack( - tracks.elementAtOrNull(initialIndex) ?? tracks.first, - youtube, - preferences.streamMusicCodec, + final addableTrack = await SourcedTrack.fetchFromTrack( + ref: ref, + track: tracks.elementAtOrNull(initialIndex) ?? tracks.first, ).catchError((e, stackTrace) { - return SpotubeTrack.fetchFromTrack( - tracks.elementAtOrNull(initialIndex + 1) ?? tracks.first, - youtube, - preferences.streamMusicCodec, + return SourcedTrack.fetchFromTrack( + ref: ref, + track: tracks.elementAtOrNull(initialIndex + 1) ?? tracks.first, ); }); @@ -332,10 +341,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier collections: {}, ); await notificationService.addTrack(addableTrack); - await storeTrack( - tracks.elementAt(initialIndex), - addableTrack, - ); + discord.updatePresence(addableTrack); } await audioPlayer.openPlaylist( @@ -364,13 +370,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier if (oldTrack != null || track != null) { await notificationService.addTrack(track ?? oldTrack!); - } - - if (oldTrack != null && track != null) { - await storeTrack( - oldTrack, - track, - ); + discord.updatePresence(track ?? oldTrack!); } } @@ -416,9 +416,9 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } Future populateSibling() async { - if (state.activeTrack is SpotubeTrack) { + if (state.activeTrack is SourcedTrack) { final activeTrackWithSiblingsForSure = - await (state.activeTrack as SpotubeTrack).populatedCopy(youtube); + await (state.activeTrack as SourcedTrack).copyWithSibling(); state = state.copyWith( tracks: mergeTracks([activeTrackWithSiblingsForSure], state.tracks), @@ -428,11 +428,11 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } } - Future swapSibling(YoutubeVideoInfo video) async { - if (state.activeTrack is SpotubeTrack) { + Future swapSibling(SourceInfo sibling) async { + if (state.activeTrack is SourcedTrack) { await populateSibling(); final newTrack = - await (state.activeTrack as SpotubeTrack).swappedCopy(video, youtube); + await (state.activeTrack as SourcedTrack).swapWithSibling(sibling); if (newTrack == null) return; state = state.copyWith( tracks: mergeTracks([newTrack], state.tracks), @@ -473,12 +473,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier if (oldTrack != null || track != null) { await notificationService.addTrack(track ?? oldTrack!); - } - if (oldTrack != null && track != null) { - await storeTrack( - oldTrack, - track, - ); + discord.updatePresence(track ?? oldTrack!); } } @@ -504,18 +499,14 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier await audioPlayer.skipToPrevious(); if (oldTrack != null || track != null) { await notificationService.addTrack(track ?? oldTrack!); - } - if (oldTrack != null && track != null) { - await storeTrack( - oldTrack, - track, - ); + discord.updatePresence(track ?? oldTrack!); } } Future stop() async { state = ProxyPlaylist({}); await audioPlayer.stop(); + discord.clear(); } Future updatePalette() async { @@ -543,7 +534,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier Future> getAndCacheSkipSegments(String id) async { if (!preferences.skipNonMusic || - (preferences.youtubeApiType == YoutubeApiType.piped && + (preferences.audioSource == AudioSource.piped && preferences.searchMode == SearchMode.youtubeMusic)) return []; try { @@ -609,30 +600,12 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier @override set state(state) { - final hasActiveTrackChanged = super.state.activeTrack is SpotubeTrack - ? state.activeTrack?.id != super.state.activeTrack?.id - : super.state.activeTrack is LocalTrack && - state.activeTrack is LocalTrack - ? (super.state.activeTrack as LocalTrack).path != - (state.activeTrack as LocalTrack).path - : super.state.activeTrack?.id != state.activeTrack?.id; - - final oldTrack = super.state.activeTrack; - super.state = state; if (state.tracks.isEmpty && ref.read(paletteProvider) != null) { ref.read(paletteProvider.notifier).state = null; } else { updatePalette(); } - audioPlayer.position.then((position) { - final isMoreThan30secs = position != null && - (position == Duration.zero || position.inSeconds > 30); - - if (hasActiveTrackChanged && oldTrack != null && isMoreThan30secs) { - scrobbler.scrobble(oldTrack); - } - }); } @override @@ -649,7 +622,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier @override FutureOr fromJson(Map json) { - return ProxyPlaylist.fromJson(json); + return ProxyPlaylist.fromJson(json, ref); } @override diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart new file mode 100644 index 00000000..88a0df2e --- /dev/null +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -0,0 +1,165 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; +import 'package:spotube/provider/palette_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; + +import 'package:spotube/utils/persisted_state_notifier.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:path/path.dart' as path; + +class UserPreferencesNotifier extends PersistedStateNotifier { + final Ref ref; + + UserPreferencesNotifier(this.ref) + : super(UserPreferences.withDefaults(), "preferences"); + + void reset() { + state = UserPreferences.withDefaults(); + } + + void setStreamMusicCodec(SourceCodecs codec) { + state = state.copyWith(streamMusicCodec: codec); + } + + void setDownloadMusicCodec(SourceCodecs codec) { + state = state.copyWith(downloadMusicCodec: codec); + } + + void setThemeMode(ThemeMode mode) { + state = state.copyWith(themeMode: mode); + } + + void setRecommendationMarket(Market country) { + state = state.copyWith(recommendationMarket: country); + } + + void setAccentColorScheme(SpotubeColor color) { + state = state.copyWith(accentColorScheme: color); + } + + void setAlbumColorSync(bool sync) { + state = state.copyWith(albumColorSync: sync); + + if (!sync) { + ref.read(paletteProvider.notifier).state = null; + } else { + ref.read(ProxyPlaylistNotifier.notifier).updatePalette(); + } + } + + void setCheckUpdate(bool check) { + state = state.copyWith(checkUpdate: check); + } + + void setAudioQuality(SourceQualities quality) { + state = state.copyWith(audioQuality: quality); + } + + void setDownloadLocation(String downloadDir) { + if (downloadDir.isEmpty) return; + state = state.copyWith(downloadLocation: downloadDir); + } + + void setLayoutMode(LayoutMode mode) { + state = state.copyWith(layoutMode: mode); + } + + void setCloseBehavior(CloseBehavior behavior) { + state = state.copyWith(closeBehavior: behavior); + } + + void setShowSystemTrayIcon(bool show) { + state = state.copyWith(showSystemTrayIcon: show); + } + + void setLocale(Locale locale) { + state = state.copyWith(locale: locale); + } + + void setPipedInstance(String instance) { + state = state.copyWith(pipedInstance: instance); + } + + void setSearchMode(SearchMode mode) { + state = state.copyWith(searchMode: mode); + } + + void setSkipNonMusic(bool skip) { + state = state.copyWith(skipNonMusic: skip); + } + + void setAudioSource(AudioSource type) { + state = state.copyWith(audioSource: type); + } + + void setSystemTitleBar(bool isSystemTitleBar) { + state = state.copyWith(systemTitleBar: isSystemTitleBar); + if (DesktopTools.platform.isDesktop) { + DesktopTools.window.setTitleBarStyle( + isSystemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, + ); + } + } + + void setAmoledDarkTheme(bool isAmoled) { + state = state.copyWith(amoledDarkTheme: isAmoled); + } + + void setNormalizeAudio(bool normalize) { + state = state.copyWith(normalizeAudio: normalize); + audioPlayer.setAudioNormalization(normalize); + } + + Future _getDefaultDownloadDirectory() async { + if (kIsAndroid) return "/storage/emulated/0/Download/Spotube"; + + if (kIsMacOS) { + return path.join((await getLibraryDirectory()).path, "Caches"); + } + + return getDownloadsDirectory().then((dir) { + return path.join(dir!.path, "Spotube"); + }); + } + + @override + FutureOr onInit() async { + if (state.downloadLocation.isEmpty) { + state = state.copyWith( + downloadLocation: await _getDefaultDownloadDirectory(), + ); + } + + if (DesktopTools.platform.isDesktop) { + await DesktopTools.window.setTitleBarStyle( + state.systemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, + ); + } + + await audioPlayer.setAudioNormalization(state.normalizeAudio); + } + + @override + FutureOr fromJson(Map json) { + return UserPreferences.fromJson(json); + } + + @override + Map toJson() { + return state.toJson(); + } +} + +final userPreferencesProvider = + StateNotifierProvider( + (ref) => UserPreferencesNotifier(ref), +); diff --git a/lib/provider/user_preferences/user_preferences_state.dart b/lib/provider/user_preferences/user_preferences_state.dart new file mode 100644 index 00000000..b3d7fe8a --- /dev/null +++ b/lib/provider/user_preferences/user_preferences_state.dart @@ -0,0 +1,282 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; + +part 'user_preferences_state.g.dart'; + +@JsonEnum() +enum LayoutMode { + compact, + extended, + adaptive, +} + +@JsonEnum() +enum CloseBehavior { + minimizeToTray, + close, +} + +@JsonEnum() +enum AudioSource { + youtube, + piped, + jiosaavn; + + String get label => name[0].toUpperCase() + name.substring(1); +} + +@JsonEnum() +enum MusicCodec { + m4a._("M4a (Best for downloaded music)"), + weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"); + + final String label; + const MusicCodec._(this.label); +} + +@JsonEnum() +enum SearchMode { + youtube._("YouTube"), + youtubeMusic._("YouTube Music"); + + final String label; + + const SearchMode._(this.label); + + factory SearchMode.fromString(String key) { + return SearchMode.values.firstWhere((e) => e.name == key); + } +} + +@JsonSerializable() +final class UserPreferences { + @JsonKey( + defaultValue: SourceQualities.high, + unknownEnumValue: SourceQualities.high, + ) + final SourceQualities audioQuality; + + @JsonKey(defaultValue: true) + final bool albumColorSync; + + @JsonKey(defaultValue: false) + final bool amoledDarkTheme; + + @JsonKey(defaultValue: true) + final bool checkUpdate; + + @JsonKey(defaultValue: false) + final bool normalizeAudio; + + @JsonKey(defaultValue: true) + final bool showSystemTrayIcon; + + @JsonKey(defaultValue: true) + final bool skipNonMusic; + + @JsonKey(defaultValue: false) + final bool systemTitleBar; + + @JsonKey( + defaultValue: CloseBehavior.minimizeToTray, + unknownEnumValue: CloseBehavior.minimizeToTray, + ) + final CloseBehavior closeBehavior; + + static SpotubeColor _accentColorSchemeFromJson(Map json) { + return SpotubeColor.fromString(json["color"]); + } + + static Map? _accentColorSchemeReadValue( + Map json, String key) { + if (json[key] is String) { + return {"color": json[key]}; + } + + return json[key] as Map?; + } + + static Map _accentColorSchemeToJson(SpotubeColor color) { + return {"color": color.toString()}; + } + + static SpotubeColor _defaultAccentColorScheme() => + const SpotubeColor(0xFF2196F3, name: "Blue"); + + @JsonKey( + defaultValue: UserPreferences._defaultAccentColorScheme, + fromJson: UserPreferences._accentColorSchemeFromJson, + toJson: UserPreferences._accentColorSchemeToJson, + readValue: UserPreferences._accentColorSchemeReadValue, + ) + final SpotubeColor accentColorScheme; + + @JsonKey( + defaultValue: LayoutMode.adaptive, + unknownEnumValue: LayoutMode.adaptive, + ) + final LayoutMode layoutMode; + + static Locale _localeFromJson(Map json) { + return Locale(json["languageCode"], json["countryCode"]); + } + + static Map _localeToJson(Locale locale) { + return { + "languageCode": locale.languageCode, + "countryCode": locale.countryCode, + }; + } + + static Map? _localeReadValue( + Map json, String key) { + if (json[key] is String) { + final map = jsonDecode(json[key]); + return { + "languageCode": map["lc"], + "countryCode": map["cc"], + }; + } + + return json[key] as Map?; + } + + static Locale _defaultLocaleValue() => const Locale("system", "system"); + + @JsonKey( + defaultValue: UserPreferences._defaultLocaleValue, + toJson: UserPreferences._localeToJson, + fromJson: UserPreferences._localeFromJson, + readValue: UserPreferences._localeReadValue, + ) + final Locale locale; + + @JsonKey( + defaultValue: Market.US, + unknownEnumValue: Market.US, + ) + final Market recommendationMarket; + + @JsonKey( + defaultValue: SearchMode.youtube, + unknownEnumValue: SearchMode.youtube, + ) + final SearchMode searchMode; + + @JsonKey(defaultValue: "") + final String downloadLocation; + + @JsonKey(defaultValue: "https://pipedapi.kavin.rocks") + final String pipedInstance; + + @JsonKey( + defaultValue: ThemeMode.system, + unknownEnumValue: ThemeMode.system, + ) + final ThemeMode themeMode; + + @JsonKey( + defaultValue: AudioSource.youtube, + unknownEnumValue: AudioSource.youtube, + ) + final AudioSource audioSource; + + @JsonKey( + defaultValue: SourceCodecs.weba, + unknownEnumValue: SourceCodecs.weba, + ) + final SourceCodecs streamMusicCodec; + + @JsonKey( + defaultValue: SourceCodecs.m4a, + unknownEnumValue: SourceCodecs.m4a, + ) + final SourceCodecs downloadMusicCodec; + + UserPreferences({ + required this.audioQuality, + required this.albumColorSync, + required this.amoledDarkTheme, + required this.checkUpdate, + required this.normalizeAudio, + required this.showSystemTrayIcon, + required this.skipNonMusic, + required this.systemTitleBar, + required this.closeBehavior, + required this.accentColorScheme, + required this.layoutMode, + required this.locale, + required this.recommendationMarket, + required this.searchMode, + required this.downloadLocation, + required this.pipedInstance, + required this.themeMode, + required this.audioSource, + required this.streamMusicCodec, + required this.downloadMusicCodec, + }); + + factory UserPreferences.withDefaults() { + return UserPreferences.fromJson({}); + } + + factory UserPreferences.fromJson(Map json) { + return _$UserPreferencesFromJson(json); + } + + Map toJson() { + return _$UserPreferencesToJson(this); + } + + UserPreferences copyWith({ + ThemeMode? themeMode, + SpotubeColor? accentColorScheme, + bool? albumColorSync, + bool? checkUpdate, + SourceQualities? audioQuality, + String? downloadLocation, + LayoutMode? layoutMode, + CloseBehavior? closeBehavior, + bool? showSystemTrayIcon, + Locale? locale, + String? pipedInstance, + SearchMode? searchMode, + bool? skipNonMusic, + AudioSource? audioSource, + Market? recommendationMarket, + bool? saveTrackLyrics, + bool? amoledDarkTheme, + bool? normalizeAudio, + SourceCodecs? downloadMusicCodec, + SourceCodecs? streamMusicCodec, + bool? systemTitleBar, + }) { + return UserPreferences( + themeMode: themeMode ?? this.themeMode, + accentColorScheme: accentColorScheme ?? this.accentColorScheme, + albumColorSync: albumColorSync ?? this.albumColorSync, + checkUpdate: checkUpdate ?? this.checkUpdate, + audioQuality: audioQuality ?? this.audioQuality, + downloadLocation: downloadLocation ?? this.downloadLocation, + layoutMode: layoutMode ?? this.layoutMode, + closeBehavior: closeBehavior ?? this.closeBehavior, + showSystemTrayIcon: showSystemTrayIcon ?? this.showSystemTrayIcon, + locale: locale ?? this.locale, + pipedInstance: pipedInstance ?? this.pipedInstance, + searchMode: searchMode ?? this.searchMode, + skipNonMusic: skipNonMusic ?? this.skipNonMusic, + audioSource: audioSource ?? this.audioSource, + recommendationMarket: recommendationMarket ?? this.recommendationMarket, + amoledDarkTheme: amoledDarkTheme ?? this.amoledDarkTheme, + downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, + normalizeAudio: normalizeAudio ?? this.normalizeAudio, + streamMusicCodec: streamMusicCodec ?? this.streamMusicCodec, + systemTitleBar: systemTitleBar ?? this.systemTitleBar, + ); + } +} diff --git a/lib/provider/user_preferences/user_preferences_state.g.dart b/lib/provider/user_preferences/user_preferences_state.g.dart new file mode 100644 index 00000000..54cd3aa2 --- /dev/null +++ b/lib/provider/user_preferences/user_preferences_state.g.dart @@ -0,0 +1,383 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_preferences_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +UserPreferences _$UserPreferencesFromJson(Map json) => + UserPreferences( + audioQuality: $enumDecodeNullable( + _$SourceQualitiesEnumMap, json['audioQuality'], + unknownValue: SourceQualities.high) ?? + SourceQualities.high, + albumColorSync: json['albumColorSync'] as bool? ?? true, + amoledDarkTheme: json['amoledDarkTheme'] as bool? ?? false, + checkUpdate: json['checkUpdate'] as bool? ?? true, + normalizeAudio: json['normalizeAudio'] as bool? ?? false, + showSystemTrayIcon: json['showSystemTrayIcon'] as bool? ?? true, + skipNonMusic: json['skipNonMusic'] as bool? ?? true, + systemTitleBar: json['systemTitleBar'] as bool? ?? false, + closeBehavior: $enumDecodeNullable( + _$CloseBehaviorEnumMap, json['closeBehavior'], + unknownValue: CloseBehavior.minimizeToTray) ?? + CloseBehavior.minimizeToTray, + accentColorScheme: UserPreferences._accentColorSchemeReadValue( + json, 'accentColorScheme') == + null + ? UserPreferences._defaultAccentColorScheme() + : UserPreferences._accentColorSchemeFromJson( + UserPreferences._accentColorSchemeReadValue( + json, 'accentColorScheme') as Map), + layoutMode: $enumDecodeNullable(_$LayoutModeEnumMap, json['layoutMode'], + unknownValue: LayoutMode.adaptive) ?? + LayoutMode.adaptive, + locale: UserPreferences._localeReadValue(json, 'locale') == null + ? UserPreferences._defaultLocaleValue() + : UserPreferences._localeFromJson( + UserPreferences._localeReadValue(json, 'locale') + as Map), + recommendationMarket: $enumDecodeNullable( + _$MarketEnumMap, json['recommendationMarket'], + unknownValue: Market.US) ?? + Market.US, + searchMode: $enumDecodeNullable(_$SearchModeEnumMap, json['searchMode'], + unknownValue: SearchMode.youtube) ?? + SearchMode.youtube, + downloadLocation: json['downloadLocation'] as String? ?? '', + pipedInstance: + json['pipedInstance'] as String? ?? 'https://pipedapi.kavin.rocks', + themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode'], + unknownValue: ThemeMode.system) ?? + ThemeMode.system, + audioSource: $enumDecodeNullable( + _$AudioSourceEnumMap, json['audioSource'], + unknownValue: AudioSource.youtube) ?? + AudioSource.youtube, + streamMusicCodec: $enumDecodeNullable( + _$SourceCodecsEnumMap, json['streamMusicCodec'], + unknownValue: SourceCodecs.weba) ?? + SourceCodecs.weba, + downloadMusicCodec: $enumDecodeNullable( + _$SourceCodecsEnumMap, json['downloadMusicCodec'], + unknownValue: SourceCodecs.m4a) ?? + SourceCodecs.m4a, + ); + +Map _$UserPreferencesToJson(UserPreferences instance) => + { + 'audioQuality': _$SourceQualitiesEnumMap[instance.audioQuality]!, + 'albumColorSync': instance.albumColorSync, + 'amoledDarkTheme': instance.amoledDarkTheme, + 'checkUpdate': instance.checkUpdate, + 'normalizeAudio': instance.normalizeAudio, + 'showSystemTrayIcon': instance.showSystemTrayIcon, + 'skipNonMusic': instance.skipNonMusic, + 'systemTitleBar': instance.systemTitleBar, + 'closeBehavior': _$CloseBehaviorEnumMap[instance.closeBehavior]!, + 'accentColorScheme': + UserPreferences._accentColorSchemeToJson(instance.accentColorScheme), + 'layoutMode': _$LayoutModeEnumMap[instance.layoutMode]!, + 'locale': UserPreferences._localeToJson(instance.locale), + 'recommendationMarket': _$MarketEnumMap[instance.recommendationMarket]!, + 'searchMode': _$SearchModeEnumMap[instance.searchMode]!, + 'downloadLocation': instance.downloadLocation, + 'pipedInstance': instance.pipedInstance, + 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, + 'audioSource': _$AudioSourceEnumMap[instance.audioSource]!, + 'streamMusicCodec': _$SourceCodecsEnumMap[instance.streamMusicCodec]!, + 'downloadMusicCodec': _$SourceCodecsEnumMap[instance.downloadMusicCodec]!, + }; + +const _$SourceQualitiesEnumMap = { + SourceQualities.high: 'high', + SourceQualities.medium: 'medium', + SourceQualities.low: 'low', +}; + +const _$CloseBehaviorEnumMap = { + CloseBehavior.minimizeToTray: 'minimizeToTray', + CloseBehavior.close: 'close', +}; + +const _$LayoutModeEnumMap = { + LayoutMode.compact: 'compact', + LayoutMode.extended: 'extended', + LayoutMode.adaptive: 'adaptive', +}; + +const _$MarketEnumMap = { + Market.AD: 'AD', + Market.AE: 'AE', + Market.AF: 'AF', + Market.AG: 'AG', + Market.AI: 'AI', + Market.AL: 'AL', + Market.AM: 'AM', + Market.AO: 'AO', + Market.AQ: 'AQ', + Market.AR: 'AR', + Market.AS: 'AS', + Market.AT: 'AT', + Market.AU: 'AU', + Market.AW: 'AW', + Market.AX: 'AX', + Market.AZ: 'AZ', + Market.BA: 'BA', + Market.BB: 'BB', + Market.BD: 'BD', + Market.BE: 'BE', + Market.BF: 'BF', + Market.BG: 'BG', + Market.BH: 'BH', + Market.BI: 'BI', + Market.BJ: 'BJ', + Market.BL: 'BL', + Market.BM: 'BM', + Market.BN: 'BN', + Market.BO: 'BO', + Market.BQ: 'BQ', + Market.BR: 'BR', + Market.BS: 'BS', + Market.BT: 'BT', + Market.BV: 'BV', + Market.BW: 'BW', + Market.BY: 'BY', + Market.BZ: 'BZ', + Market.CA: 'CA', + Market.CC: 'CC', + Market.CD: 'CD', + Market.CF: 'CF', + Market.CG: 'CG', + Market.CH: 'CH', + Market.CI: 'CI', + Market.CK: 'CK', + Market.CL: 'CL', + Market.CM: 'CM', + Market.CN: 'CN', + Market.CO: 'CO', + Market.CR: 'CR', + Market.CU: 'CU', + Market.CV: 'CV', + Market.CW: 'CW', + Market.CX: 'CX', + Market.CY: 'CY', + Market.CZ: 'CZ', + Market.DE: 'DE', + Market.DJ: 'DJ', + Market.DK: 'DK', + Market.DM: 'DM', + Market.DO: 'DO', + Market.DZ: 'DZ', + Market.EC: 'EC', + Market.EE: 'EE', + Market.EG: 'EG', + Market.EH: 'EH', + Market.ER: 'ER', + Market.ES: 'ES', + Market.ET: 'ET', + Market.FI: 'FI', + Market.FJ: 'FJ', + Market.FK: 'FK', + Market.FM: 'FM', + Market.FO: 'FO', + Market.FR: 'FR', + Market.GA: 'GA', + Market.GB: 'GB', + Market.GD: 'GD', + Market.GE: 'GE', + Market.GF: 'GF', + Market.GG: 'GG', + Market.GH: 'GH', + Market.GI: 'GI', + Market.GL: 'GL', + Market.GM: 'GM', + Market.GN: 'GN', + Market.GP: 'GP', + Market.GQ: 'GQ', + Market.GR: 'GR', + Market.GS: 'GS', + Market.GT: 'GT', + Market.GU: 'GU', + Market.GW: 'GW', + Market.GY: 'GY', + Market.HK: 'HK', + Market.HM: 'HM', + Market.HN: 'HN', + Market.HR: 'HR', + Market.HT: 'HT', + Market.HU: 'HU', + Market.ID: 'ID', + Market.IE: 'IE', + Market.IL: 'IL', + Market.IM: 'IM', + Market.IN: 'IN', + Market.IO: 'IO', + Market.IQ: 'IQ', + Market.IR: 'IR', + Market.IS: 'IS', + Market.IT: 'IT', + Market.JE: 'JE', + Market.JM: 'JM', + Market.JO: 'JO', + Market.JP: 'JP', + Market.KE: 'KE', + Market.KG: 'KG', + Market.KH: 'KH', + Market.KI: 'KI', + Market.KM: 'KM', + Market.KN: 'KN', + Market.KP: 'KP', + Market.KR: 'KR', + Market.KW: 'KW', + Market.KY: 'KY', + Market.KZ: 'KZ', + Market.LA: 'LA', + Market.LB: 'LB', + Market.LC: 'LC', + Market.LI: 'LI', + Market.LK: 'LK', + Market.LR: 'LR', + Market.LS: 'LS', + Market.LT: 'LT', + Market.LU: 'LU', + Market.LV: 'LV', + Market.LY: 'LY', + Market.MA: 'MA', + Market.MC: 'MC', + Market.MD: 'MD', + Market.ME: 'ME', + Market.MF: 'MF', + Market.MG: 'MG', + Market.MH: 'MH', + Market.MK: 'MK', + Market.ML: 'ML', + Market.MM: 'MM', + Market.MN: 'MN', + Market.MO: 'MO', + Market.MP: 'MP', + Market.MQ: 'MQ', + Market.MR: 'MR', + Market.MS: 'MS', + Market.MT: 'MT', + Market.MU: 'MU', + Market.MV: 'MV', + Market.MW: 'MW', + Market.MX: 'MX', + Market.MY: 'MY', + Market.MZ: 'MZ', + Market.NA: 'NA', + Market.NC: 'NC', + Market.NE: 'NE', + Market.NF: 'NF', + Market.NG: 'NG', + Market.NI: 'NI', + Market.NL: 'NL', + Market.NO: 'NO', + Market.NP: 'NP', + Market.NR: 'NR', + Market.NU: 'NU', + Market.NZ: 'NZ', + Market.OM: 'OM', + Market.PA: 'PA', + Market.PE: 'PE', + Market.PF: 'PF', + Market.PG: 'PG', + Market.PH: 'PH', + Market.PK: 'PK', + Market.PL: 'PL', + Market.PM: 'PM', + Market.PN: 'PN', + Market.PR: 'PR', + Market.PS: 'PS', + Market.PT: 'PT', + Market.PW: 'PW', + Market.PY: 'PY', + Market.QA: 'QA', + Market.RE: 'RE', + Market.RO: 'RO', + Market.RS: 'RS', + Market.RU: 'RU', + Market.RW: 'RW', + Market.SA: 'SA', + Market.SB: 'SB', + Market.SC: 'SC', + Market.SD: 'SD', + Market.SE: 'SE', + Market.SG: 'SG', + Market.SH: 'SH', + Market.SI: 'SI', + Market.SJ: 'SJ', + Market.SK: 'SK', + Market.SL: 'SL', + Market.SM: 'SM', + Market.SN: 'SN', + Market.SO: 'SO', + Market.SR: 'SR', + Market.SS: 'SS', + Market.ST: 'ST', + Market.SV: 'SV', + Market.SX: 'SX', + Market.SY: 'SY', + Market.SZ: 'SZ', + Market.TC: 'TC', + Market.TD: 'TD', + Market.TF: 'TF', + Market.TG: 'TG', + Market.TH: 'TH', + Market.TJ: 'TJ', + Market.TK: 'TK', + Market.TL: 'TL', + Market.TM: 'TM', + Market.TN: 'TN', + Market.TO: 'TO', + Market.TR: 'TR', + Market.TT: 'TT', + Market.TV: 'TV', + Market.TW: 'TW', + Market.TZ: 'TZ', + Market.UA: 'UA', + Market.UG: 'UG', + Market.UM: 'UM', + Market.US: 'US', + Market.UY: 'UY', + Market.UZ: 'UZ', + Market.VA: 'VA', + Market.VC: 'VC', + Market.VE: 'VE', + Market.VG: 'VG', + Market.VI: 'VI', + Market.VN: 'VN', + Market.VU: 'VU', + Market.WF: 'WF', + Market.WS: 'WS', + Market.XK: 'XK', + Market.YE: 'YE', + Market.YT: 'YT', + Market.ZA: 'ZA', + Market.ZM: 'ZM', + Market.ZW: 'ZW', +}; + +const _$SearchModeEnumMap = { + SearchMode.youtube: 'youtube', + SearchMode.youtubeMusic: 'youtubeMusic', +}; + +const _$ThemeModeEnumMap = { + ThemeMode.system: 'system', + ThemeMode.light: 'light', + ThemeMode.dark: 'dark', +}; + +const _$AudioSourceEnumMap = { + AudioSource.youtube: 'youtube', + AudioSource.piped: 'piped', + AudioSource.jiosaavn: 'jiosaavn', +}; + +const _$SourceCodecsEnumMap = { + SourceCodecs.m4a: 'm4a', + SourceCodecs.weba: 'weba', +}; diff --git a/lib/provider/user_preferences_provider.dart b/lib/provider/user_preferences_provider.dart deleted file mode 100644 index 3355adb0..00000000 --- a/lib/provider/user_preferences_provider.dart +++ /dev/null @@ -1,416 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; -import 'package:spotube/models/matched_track.dart'; -import 'package:spotube/provider/palette_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; - -import 'package:spotube/utils/persisted_change_notifier.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:path/path.dart' as path; - -enum LayoutMode { - compact, - extended, - adaptive, -} - -enum AudioQuality { - high, - low, -} - -enum CloseBehavior { - minimizeToTray, - close, -} - -enum YoutubeApiType { - youtube, - piped; - - String get label => name[0].toUpperCase() + name.substring(1); -} - -enum MusicCodec { - m4a._("M4a (Best for downloaded music)"), - weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"); - - final String label; - const MusicCodec._(this.label); -} - -class UserPreferences extends PersistedChangeNotifier { - AudioQuality audioQuality; - bool albumColorSync; - bool amoledDarkTheme; - bool checkUpdate; - bool normalizeAudio; - bool showSystemTrayIcon; - bool skipNonMusic; - bool systemTitleBar; - CloseBehavior closeBehavior; - late SpotubeColor accentColorScheme; - LayoutMode layoutMode; - Locale locale; - Market recommendationMarket; - SearchMode searchMode; - String downloadLocation; - String pipedInstance; - ThemeMode themeMode; - YoutubeApiType youtubeApiType; - MusicCodec streamMusicCodec; - MusicCodec downloadMusicCodec; - - final Ref ref; - - UserPreferences( - this.ref, { - this.recommendationMarket = Market.US, - this.themeMode = ThemeMode.system, - this.layoutMode = LayoutMode.adaptive, - this.albumColorSync = true, - this.checkUpdate = true, - this.audioQuality = AudioQuality.high, - this.downloadLocation = "", - this.closeBehavior = CloseBehavior.close, - this.showSystemTrayIcon = true, - this.locale = const Locale("system", "system"), - this.pipedInstance = "https://pipedapi.kavin.rocks", - this.searchMode = SearchMode.youtube, - this.skipNonMusic = true, - this.youtubeApiType = YoutubeApiType.youtube, - this.systemTitleBar = false, - this.amoledDarkTheme = false, - this.normalizeAudio = true, - this.streamMusicCodec = MusicCodec.weba, - this.downloadMusicCodec = MusicCodec.m4a, - SpotubeColor? accentColorScheme, - }) : super() { - this.accentColorScheme = - accentColorScheme ?? SpotubeColor(Colors.blue.value, name: "Blue"); - if (downloadLocation.isEmpty && !kIsWeb) { - _getDefaultDownloadDirectory().then( - (value) { - downloadLocation = value; - }, - ); - } - } - - void reset() { - setRecommendationMarket(Market.US); - setThemeMode(ThemeMode.system); - setLayoutMode(LayoutMode.adaptive); - setAlbumColorSync(true); - setCheckUpdate(true); - setAudioQuality(AudioQuality.high); - setDownloadLocation(""); - setCloseBehavior(CloseBehavior.close); - setShowSystemTrayIcon(true); - setLocale(const Locale("system", "system")); - setPipedInstance("https://pipedapi.kavin.rocks"); - setSearchMode(SearchMode.youtube); - setSkipNonMusic(true); - setYoutubeApiType(YoutubeApiType.youtube); - setSystemTitleBar(false); - setAmoledDarkTheme(false); - setNormalizeAudio(true); - setAccentColorScheme(SpotubeColor(Colors.blue.value, name: "Blue")); - setStreamMusicCodec(MusicCodec.weba); - setDownloadMusicCodec(MusicCodec.m4a); - } - - void setStreamMusicCodec(MusicCodec codec) { - streamMusicCodec = codec; - notifyListeners(); - updatePersistence(); - } - - void setDownloadMusicCodec(MusicCodec codec) { - downloadMusicCodec = codec; - notifyListeners(); - updatePersistence(); - } - - void setThemeMode(ThemeMode mode) { - themeMode = mode; - notifyListeners(); - updatePersistence(); - } - - void setRecommendationMarket(Market country) { - recommendationMarket = country; - notifyListeners(); - updatePersistence(); - } - - void setAccentColorScheme(SpotubeColor color) { - accentColorScheme = color; - notifyListeners(); - updatePersistence(); - } - - void setAlbumColorSync(bool sync) { - albumColorSync = sync; - if (!sync) { - ref.read(paletteProvider.notifier).state = null; - } else { - ref.read(ProxyPlaylistNotifier.notifier).updatePalette(); - } - notifyListeners(); - updatePersistence(); - } - - void setCheckUpdate(bool check) { - checkUpdate = check; - notifyListeners(); - updatePersistence(); - } - - void setAudioQuality(AudioQuality quality) { - audioQuality = quality; - notifyListeners(); - updatePersistence(); - } - - void setDownloadLocation(String downloadDir) { - if (downloadDir.isEmpty) return; - downloadLocation = downloadDir; - notifyListeners(); - updatePersistence(); - } - - void setLayoutMode(LayoutMode mode) { - layoutMode = mode; - notifyListeners(); - updatePersistence(); - } - - void setCloseBehavior(CloseBehavior behavior) { - closeBehavior = behavior; - notifyListeners(); - updatePersistence(); - } - - void setShowSystemTrayIcon(bool show) { - showSystemTrayIcon = show; - notifyListeners(); - updatePersistence(); - } - - void setLocale(Locale locale) { - this.locale = locale; - notifyListeners(); - updatePersistence(); - } - - void setPipedInstance(String instance) { - pipedInstance = instance; - notifyListeners(); - updatePersistence(); - } - - void setSearchMode(SearchMode mode) { - searchMode = mode; - notifyListeners(); - updatePersistence(); - } - - void setSkipNonMusic(bool skip) { - skipNonMusic = skip; - notifyListeners(); - updatePersistence(); - } - - void setYoutubeApiType(YoutubeApiType type) { - youtubeApiType = type; - notifyListeners(); - updatePersistence(); - } - - void setSystemTitleBar(bool isSystemTitleBar) { - systemTitleBar = isSystemTitleBar; - if (DesktopTools.platform.isDesktop) { - DesktopTools.window.setTitleBarStyle( - systemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, - ); - } - notifyListeners(); - updatePersistence(); - } - - void setAmoledDarkTheme(bool isAmoled) { - amoledDarkTheme = isAmoled; - notifyListeners(); - updatePersistence(); - } - - void setNormalizeAudio(bool normalize) { - normalizeAudio = normalize; - audioPlayer.setAudioNormalization(normalize); - notifyListeners(); - updatePersistence(); - } - - Future _getDefaultDownloadDirectory() async { - if (kIsAndroid) return "/storage/emulated/0/Download/Spotube"; - - if (kIsMacOS) { - return path.join((await getLibraryDirectory()).path, "Caches"); - } - - return getDownloadsDirectory().then((dir) { - return path.join(dir!.path, "Spotube"); - }); - } - - @override - FutureOr loadFromLocal(Map map) async { - recommendationMarket = Market.values.firstWhere( - (market) => - market.name == (map["recommendationMarket"] ?? recommendationMarket), - orElse: () => Market.US, - ); - checkUpdate = map["checkUpdate"] ?? checkUpdate; - - themeMode = ThemeMode.values[map["themeMode"] ?? 0]; - accentColorScheme = map["accentColorScheme"] != null - ? SpotubeColor.fromString(map["accentColorScheme"]) - : accentColorScheme; - albumColorSync = map["albumColorSync"] ?? albumColorSync; - audioQuality = map["audioQuality"] != null - ? AudioQuality.values[map["audioQuality"]] - : audioQuality; - - if (!kIsWeb) { - downloadLocation = - map["downloadLocation"] ?? await _getDefaultDownloadDirectory(); - } - - layoutMode = LayoutMode.values.firstWhere( - (mode) => mode.name == map["layoutMode"], - orElse: () => kIsDesktop ? LayoutMode.extended : LayoutMode.compact, - ); - - closeBehavior = map["closeBehavior"] != null - ? CloseBehavior.values[map["closeBehavior"]] - : closeBehavior; - - showSystemTrayIcon = map["showSystemTrayIcon"] ?? showSystemTrayIcon; - - final localeMap = map["locale"] != null ? jsonDecode(map["locale"]) : null; - locale = - localeMap != null ? Locale(localeMap?["lc"], localeMap?["cc"]) : locale; - - pipedInstance = map["pipedInstance"] ?? pipedInstance; - - searchMode = SearchMode.values.firstWhere( - (mode) => mode.name == map["searchMode"], - orElse: () => SearchMode.youtube, - ); - - skipNonMusic = map["skipNonMusic"] ?? skipNonMusic; - - youtubeApiType = YoutubeApiType.values.firstWhere( - (type) => type.name == map["youtubeApiType"], - orElse: () => YoutubeApiType.youtube, - ); - - systemTitleBar = map["systemTitleBar"] ?? systemTitleBar; - // updates the title bar - setSystemTitleBar(systemTitleBar); - - amoledDarkTheme = map["amoledDarkTheme"] ?? amoledDarkTheme; - - normalizeAudio = map["normalizeAudio"] ?? normalizeAudio; - audioPlayer.setAudioNormalization(normalizeAudio); - - streamMusicCodec = MusicCodec.values.firstWhere( - (codec) => codec.name == map["streamMusicCodec"], - orElse: () => MusicCodec.weba, - ); - - downloadMusicCodec = MusicCodec.values.firstWhere( - (codec) => codec.name == map["downloadMusicCodec"], - orElse: () => MusicCodec.m4a, - ); - } - - @override - FutureOr> toMap() { - return { - "recommendationMarket": recommendationMarket.name, - "themeMode": themeMode.index, - "accentColorScheme": accentColorScheme.toString(), - "albumColorSync": albumColorSync, - "checkUpdate": checkUpdate, - "audioQuality": audioQuality.index, - "downloadLocation": downloadLocation, - "layoutMode": layoutMode.name, - "closeBehavior": closeBehavior.index, - "showSystemTrayIcon": showSystemTrayIcon, - "locale": - jsonEncode({"lc": locale.languageCode, "cc": locale.countryCode}), - "pipedInstance": pipedInstance, - "searchMode": searchMode.name, - "skipNonMusic": skipNonMusic, - "youtubeApiType": youtubeApiType.name, - 'systemTitleBar': systemTitleBar, - "amoledDarkTheme": amoledDarkTheme, - "normalizeAudio": normalizeAudio, - "streamMusicCodec": streamMusicCodec.name, - "downloadMusicCodec": downloadMusicCodec.name, - }; - } - - UserPreferences copyWith({ - ThemeMode? themeMode, - SpotubeColor? accentColorScheme, - bool? albumColorSync, - bool? checkUpdate, - AudioQuality? audioQuality, - String? downloadLocation, - LayoutMode? layoutMode, - CloseBehavior? closeBehavior, - bool? showSystemTrayIcon, - Locale? locale, - String? pipedInstance, - SearchMode? searchMode, - bool? skipNonMusic, - YoutubeApiType? youtubeApiType, - Market? recommendationMarket, - bool? saveTrackLyrics, - }) { - return UserPreferences( - ref, - themeMode: themeMode ?? this.themeMode, - accentColorScheme: accentColorScheme ?? this.accentColorScheme, - albumColorSync: albumColorSync ?? this.albumColorSync, - checkUpdate: checkUpdate ?? this.checkUpdate, - audioQuality: audioQuality ?? this.audioQuality, - downloadLocation: downloadLocation ?? this.downloadLocation, - layoutMode: layoutMode ?? this.layoutMode, - closeBehavior: closeBehavior ?? this.closeBehavior, - showSystemTrayIcon: showSystemTrayIcon ?? this.showSystemTrayIcon, - locale: locale ?? this.locale, - pipedInstance: pipedInstance ?? this.pipedInstance, - searchMode: searchMode ?? this.searchMode, - skipNonMusic: skipNonMusic ?? this.skipNonMusic, - youtubeApiType: youtubeApiType ?? this.youtubeApiType, - recommendationMarket: recommendationMarket ?? this.recommendationMarket, - ); - } -} - -final userPreferencesProvider = ChangeNotifierProvider( - (ref) => UserPreferences(ref), -); diff --git a/lib/provider/youtube_provider.dart b/lib/provider/youtube_provider.dart deleted file mode 100644 index 0e7b7d0e..00000000 --- a/lib/provider/youtube_provider.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/services/youtube/youtube.dart'; - -final youtubeProvider = Provider((ref) { - final preferences = ref.watch(userPreferencesProvider); - return YoutubeEndpoints(preferences); -}); diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index c944004c..b3957964 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -5,9 +5,9 @@ import 'dart:async'; import 'package:media_kit/media_kit.dart' as mk; -import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:spotube/services/audio_player/playback_state.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; part 'audio_players_streams_mixin.dart'; part 'audio_player_impl.dart'; diff --git a/lib/services/audio_player/audio_player_impl.dart b/lib/services/audio_player/audio_player_impl.dart index 4576ce8d..2af94dd7 100644 --- a/lib/services/audio_player/audio_player_impl.dart +++ b/lib/services/audio_player/audio_player_impl.dart @@ -121,11 +121,13 @@ class SpotubeAudioPlayer extends AudioPlayerInterface // } } - List resolveTracksForSource(List tracks) { - return tracks.where((e) => sources.contains(e.ytUri)).toList(); + // TODO: Make sure audio player soruces are also + // TODO: changed when preferences sources are changed + List resolveTracksForSource(List tracks) { + return tracks.where((e) => sources.contains(e.url)).toList(); } - bool tracksExistsInPlaylist(List tracks) { + bool tracksExistsInPlaylist(List tracks) { return resolveTracksForSource(tracks).length == tracks.length; } diff --git a/lib/services/audio_player/mk_state_player.dart b/lib/services/audio_player/mk_state_player.dart index 386b0a0e..a556afec 100644 --- a/lib/services/audio_player/mk_state_player.dart +++ b/lib/services/audio_player/mk_state_player.dart @@ -96,7 +96,10 @@ class MkPlayerWithState extends Player { if (shuffle) { _tempMedias = _playlist!.medias; final active = _playlist!.medias[_playlist!.index]; - final newMedias = _playlist!.medias.toList()..shuffle(); + final newMedias = _playlist!.medias.toList() + ..shuffle() + ..remove(active) + ..insert(0, active); playlist = _playlist!.copyWith( medias: newMedias, index: newMedias.indexOf(active), @@ -174,9 +177,6 @@ class MkPlayerWithState extends Player { case PlaylistMode.none: // Fixes auto-repeating the last track await super.stop(); - await Future.delayed(const Duration(seconds: 2), () { - super.open(_playlist!.medias[_playlist!.index], play: false); - }); break; default: } diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index 645548fb..a6ecac3f 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -2,10 +2,10 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_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 { @@ -47,8 +47,8 @@ class AudioServices { album: track.album?.name ?? "", title: track.name!, artist: TypeConversionUtils.artists_X_String(track.artists ?? []), - duration: track is SpotubeTrack - ? track.ytTrack.duration + duration: track is SourcedTrack + ? track.sourceInfo.duration : Duration(milliseconds: track.durationMs ?? 0), artUri: Uri.parse(TypeConversionUtils.image_X_UrlString( track.album?.images ?? [], diff --git a/lib/services/audio_services/linux_audio_service.dart b/lib/services/audio_services/linux_audio_service.dart index 28370c86..436627e6 100644 --- a/lib/services/audio_services/linux_audio_service.dart +++ b/lib/services/audio_services/linux_audio_service.dart @@ -3,13 +3,12 @@ import 'dart:io'; import 'package:dbus/dbus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/loop_mode.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; -import 'package:window_manager/window_manager.dart'; final dbus = DBusClient.session(); @@ -86,8 +85,7 @@ class _MprisMediaPlayer2 extends DBusObject { /// Implementation of org.mpris.MediaPlayer2.Quit() Future doQuit() async { - await windowManager.close(); - return DBusMethodSuccessResponse(); + exit(0); } @override @@ -322,8 +320,8 @@ class _MprisMediaPlayer2Player extends DBusObject { ), "xesam:title": DBusString(playlist.activeTrack!.name!), "xesam:url": DBusString( - playlist.activeTrack is SpotubeTrack - ? (playlist.activeTrack as SpotubeTrack).ytUri + playlist.activeTrack is SourcedTrack + ? (playlist.activeTrack as SourcedTrack).url : playlist.activeTrack!.previewUrl ?? "", ), "xesam:genre": const DBusString("Unknown"), diff --git a/lib/services/audio_services/windows_audio_service.dart b/lib/services/audio_services/windows_audio_service.dart index 4481140b..fde88145 100644 --- a/lib/services/audio_services/windows_audio_service.dart +++ b/lib/services/audio_services/windows_audio_service.dart @@ -51,11 +51,9 @@ class WindowsAudioService { break; case AudioPlaybackState.stopped: await smtc.setPlaybackStatus(PlaybackStatus.Stopped); - await smtc.disableSmtc(); break; case AudioPlaybackState.completed: await smtc.setPlaybackStatus(PlaybackStatus.Changing); - await smtc.disableSmtc(); break; default: break; diff --git a/lib/services/discord/discord.dart b/lib/services/discord/discord.dart new file mode 100644 index 00000000..2a40e388 --- /dev/null +++ b/lib/services/discord/discord.dart @@ -0,0 +1,44 @@ +import 'package:dart_discord_rpc/dart_discord_rpc.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/env.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; + +class Discord { + final DiscordRPC? discordRPC; + + Discord() + : discordRPC = + DesktopTools.platform.isWindows || DesktopTools.platform.isLinux + ? DiscordRPC(applicationId: Env.discordAppId) + : null { + discordRPC?.start(autoRegister: true); + } + + void updatePresence(Track track) { + clear(); + final artistNames = + TypeConversionUtils.artists_X_String(track.artists ?? []); + discordRPC?.updatePresence( + DiscordPresence( + details: "Song: ${track.name} by $artistNames", + state: "Vibing in Music", + startTimeStamp: DateTime.now().millisecondsSinceEpoch, + largeImageKey: "spotube-logo-foreground", + largeImageText: "Spotube", + smallImageKey: "spotube-logo-foreground", + smallImageText: "Spotube", + ), + ); + } + + void clear() { + discordRPC?.clearPresence(); + } + + void shutdown() { + discordRPC?.shutDown(); + } +} + +final discord = Discord(); diff --git a/lib/services/mutations/album.dart b/lib/services/mutations/album.dart index dfc72fcc..144b6a8f 100644 --- a/lib/services/mutations/album.dart +++ b/lib/services/mutations/album.dart @@ -1,6 +1,6 @@ import 'package:fl_query/fl_query.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/hooks/use_spotify_mutation.dart'; +import 'package:spotube/hooks/spotify/use_spotify_mutation.dart'; class AlbumMutations { const AlbumMutations(); diff --git a/lib/services/mutations/playlist.dart b/lib/services/mutations/playlist.dart index 176b5cd8..077fff06 100644 --- a/lib/services/mutations/playlist.dart +++ b/lib/services/mutations/playlist.dart @@ -2,7 +2,7 @@ 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/use_spotify_mutation.dart'; +import 'package:spotube/hooks/spotify/use_spotify_mutation.dart'; import 'package:spotube/services/queries/queries.dart'; typedef PlaylistCRUDVariables = ({ @@ -131,10 +131,8 @@ class PlaylistMutations { ); } }, - refreshQueries: [ - "playlist/$playlistId", - ], refreshInfiniteQueries: [ + "playlist/$playlistId", "current-user-playlists", ], ref: ref, diff --git a/lib/services/mutations/track.dart b/lib/services/mutations/track.dart index 2245c497..f8208b5e 100644 --- a/lib/services/mutations/track.dart +++ b/lib/services/mutations/track.dart @@ -1,6 +1,6 @@ import 'package:fl_query/fl_query.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/hooks/use_spotify_mutation.dart'; +import 'package:spotube/hooks/spotify/use_spotify_mutation.dart'; class TrackMutations { const TrackMutations(); diff --git a/lib/services/queries/album.dart b/lib/services/queries/album.dart index 53fcaf86..0cc10256 100644 --- a/lib/services/queries/album.dart +++ b/lib/services/queries/album.dart @@ -1,10 +1,13 @@ import 'package:catcher_2/catcher_2.dart'; import 'package:fl_query/fl_query.dart'; +import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/use_spotify_query.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; +import 'package:spotube/hooks/spotify/use_spotify_query.dart'; +import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class AlbumQueries { const AlbumQueries(); @@ -27,19 +30,42 @@ class AlbumQueries { ); } - Query, dynamic> tracksOf( + static final tracksOfJob = InfiniteQueryJob.withVariableKey< + List, + dynamic, + int, + ({ + SpotifyApi spotify, + AlbumSimple album, + })>( + baseQueryKey: "album-tracks", + initialPage: 0, + task: (albumId, page, args) async { + final res = + await args!.spotify.albums.tracks(albumId).getPage(20, page * 20); + return res.items + ?.map((track) => + TypeConversionUtils.simpleTrack_X_Track(track, args.album)) + .toList() ?? + []; + }, + nextPage: (lastPage, lastPageData) { + if (lastPageData.length < 20) { + return null; + } + return lastPage + 1; + }, + ); + + InfiniteQuery, dynamic, int> tracksOf( WidgetRef ref, - String albumId, + AlbumSimple album, ) { - return useSpotifyQuery, dynamic>( - "album-tracks/$albumId", - (spotify) { - return spotify.albums - .getTracks(albumId) - .all() - .then((value) => value.toList()); - }, - ref: ref, + final spotify = ref.watch(spotifyProvider); + + return useInfiniteQueryJob( + job: tracksOfJob(album.id!), + args: (spotify: spotify, album: album), ); } diff --git a/lib/services/queries/artist.dart b/lib/services/queries/artist.dart index 6dad2718..1b939c82 100644 --- a/lib/services/queries/artist.dart +++ b/lib/services/queries/artist.dart @@ -1,8 +1,12 @@ import 'package:fl_query/fl_query.dart'; +import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/use_spotify_query.dart'; +import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; +import 'package:spotube/hooks/spotify/use_spotify_query.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/wikipedia/wikipedia.dart'; +import 'package:wikipedia_api/wikipedia_api.dart'; class ArtistQueries { const ArtistQueries(); @@ -72,11 +76,11 @@ class ArtistQueries { return useSpotifyQuery( "user-follows-artists-query/$artist", (spotify) async { - final result = await spotify.me.isFollowing( + final result = await spotify.me.checkFollowing( FollowingType.artist, [artist], ); - return result.first; + return result[artist]; }, ref: ref, ); @@ -86,10 +90,12 @@ class ArtistQueries { WidgetRef ref, String artist, ) { + final preferences = ref.watch(userPreferencesProvider); return useSpotifyQuery, dynamic>( "artist-top-track-query/$artist", (spotify) { - return spotify.artists.getTopTracks(artist, "US"); + return spotify.artists + .topTracks(artist, preferences.recommendationMarket); }, ref: ref, ); @@ -122,9 +128,24 @@ class ArtistQueries { return useSpotifyQuery, dynamic>( "artist-related-artist-query/$artist", (spotify) { - return spotify.artists.getRelatedArtists(artist); + return spotify.artists.relatedArtists(artist); }, ref: ref, ); } + + Query wikipediaSummary(ArtistSimple artist) { + return useQuery( + "artist-wikipedia-query/${artist.id}", + () async { + final query = artist.name!.replaceAll(" ", "_"); + final res = await wikipedia.pageContent.pageSummaryTitleGet(query); + if (res?.type != "standard") { + return await wikipedia.pageContent + .pageSummaryTitleGet("${query}_(singer)"); + } + return res; + }, + ); + } } diff --git a/lib/services/queries/category.dart b/lib/services/queries/category.dart index 33668d82..960b5702 100644 --- a/lib/services/queries/category.dart +++ b/lib/services/queries/category.dart @@ -4,9 +4,9 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/hooks/use_spotify_infinite_query.dart'; +import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; class CategoryQueries { const CategoryQueries(); diff --git a/lib/services/queries/lyrics.dart b/lib/services/queries/lyrics.dart index 989a2e97..faa5bdec 100644 --- a/lib/services/queries/lyrics.dart +++ b/lib/services/queries/lyrics.dart @@ -6,9 +6,9 @@ import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/map.dart'; -import 'package:spotube/hooks/use_spotify_query.dart'; +import 'package:spotube/hooks/spotify/use_spotify_query.dart'; import 'package:spotube/models/lyrics.dart'; -import 'package:spotube/models/spotube_track.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:http/http.dart' as http; @@ -44,7 +44,7 @@ class LyricsQueries { return useQuery( "synced-lyrics/${track?.id}}", () async { - if (track == null || track is! SpotubeTrack) { + if (track == null || track is! SourcedTrack) { throw "No track currently"; } final timedLyrics = await ServiceUtils.getTimedLyrics(track); diff --git a/lib/services/queries/playlist.dart b/lib/services/queries/playlist.dart index ac8dc73f..836f9d72 100644 --- a/lib/services/queries/playlist.dart +++ b/lib/services/queries/playlist.dart @@ -7,11 +7,11 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/components/library/playlist_generate/recommendation_attribute_dials.dart'; import 'package:spotube/extensions/map.dart'; import 'package:spotube/extensions/track.dart'; -import 'package:spotube/hooks/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/use_spotify_query.dart'; +import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; +import 'package:spotube/hooks/spotify/use_spotify_query.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; typedef RecommendationParameters = ({ RecommendationAttribute acousticness, @@ -143,17 +143,37 @@ class PlaylistQueries { ); } - Future> likedTracks( - SpotifyApi spotify, - WidgetRef ref, - ) async { + 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, ref), []); + final query = useCallback((spotify) => likedTracks(spotify), []); final context = useContext(); return useSpotifyQuery, dynamic>( @@ -178,28 +198,6 @@ class PlaylistQueries { ); } - Future> tracksOf( - String playlistId, - SpotifyApi spotify, - WidgetRef ref, - ) async { - if (playlistId == "user-liked-tracks") return []; - return spotify.playlists.getTracksByPlaylistId(playlistId).all().then( - (value) => value.where((track) => track.id != null).toList(), - ); - } - - Query, dynamic> tracksOfQuery( - WidgetRef ref, - String playlistId, - ) { - return useSpotifyQuery, dynamic>( - "playlist-tracks/$playlistId", - (spotify) => tracksOf(playlistId, spotify, ref), - ref: ref, - ); - } - Query byId(WidgetRef ref, String id) { return useSpotifyQuery( "playlist/$id", @@ -210,6 +208,42 @@ class PlaylistQueries { ); } + 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, ) { diff --git a/lib/services/queries/search.dart b/lib/services/queries/search.dart index eaf9c1b7..f11f4399 100644 --- a/lib/services/queries/search.dart +++ b/lib/services/queries/search.dart @@ -1,7 +1,7 @@ import 'package:fl_query/fl_query.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/use_spotify_infinite_query.dart'; +import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; class SearchQueries { const SearchQueries(); diff --git a/lib/services/queries/user.dart b/lib/services/queries/user.dart index 89792592..40799c1e 100644 --- a/lib/services/queries/user.dart +++ b/lib/services/queries/user.dart @@ -2,7 +2,7 @@ import 'package:fl_query/fl_query.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/use_spotify_query.dart'; +import 'package:spotube/hooks/spotify/use_spotify_query.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; diff --git a/lib/services/queries/views.dart b/lib/services/queries/views.dart index b56f07d9..4864ffe1 100644 --- a/lib/services/queries/views.dart +++ b/lib/services/queries/views.dart @@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; class ViewsQueries { const ViewsQueries(); diff --git a/lib/services/sourced_track/enums.dart b/lib/services/sourced_track/enums.dart new file mode 100644 index 00000000..48ce1cbd --- /dev/null +++ b/lib/services/sourced_track/enums.dart @@ -0,0 +1,18 @@ +import 'package:spotube/services/sourced_track/models/source_info.dart'; +import 'package:spotube/services/sourced_track/models/source_map.dart'; + +enum SourceCodecs { + m4a._("M4a (Best for downloaded music)"), + weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"); + + final String label; + const SourceCodecs._(this.label); +} + +enum SourceQualities { + high, + medium, + low, +} + +typedef SiblingType = ({SourceInfo info, SourceMap? source}); diff --git a/lib/services/sourced_track/exceptions.dart b/lib/services/sourced_track/exceptions.dart new file mode 100644 index 00000000..517d6ba4 --- /dev/null +++ b/lib/services/sourced_track/exceptions.dart @@ -0,0 +1,7 @@ +import 'package:spotify/spotify.dart'; + +class TrackNotFoundException implements Exception { + factory TrackNotFoundException(Track track) { + throw Exception("Failed to find any results for ${track.name}"); + } +} diff --git a/lib/services/sourced_track/models/source_info.dart b/lib/services/sourced_track/models/source_info.dart new file mode 100644 index 00000000..4ba90355 --- /dev/null +++ b/lib/services/sourced_track/models/source_info.dart @@ -0,0 +1,33 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'source_info.g.dart'; + +@JsonSerializable() +class SourceInfo { + final String id; + final String title; + final String artist; + final String artistUrl; + final String? album; + + final String thumbnail; + final String pageUrl; + + final Duration duration; + + SourceInfo({ + required this.id, + required this.title, + required this.artist, + required this.thumbnail, + required this.pageUrl, + required this.duration, + required this.artistUrl, + this.album, + }); + + factory SourceInfo.fromJson(Map json) => + _$SourceInfoFromJson(json); + + Map toJson() => _$SourceInfoToJson(this); +} diff --git a/lib/services/sourced_track/models/source_info.g.dart b/lib/services/sourced_track/models/source_info.g.dart new file mode 100644 index 00000000..1ec9f75f --- /dev/null +++ b/lib/services/sourced_track/models/source_info.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'source_info.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SourceInfo _$SourceInfoFromJson(Map json) => SourceInfo( + id: json['id'] as String, + title: json['title'] as String, + artist: json['artist'] as String, + thumbnail: json['thumbnail'] as String, + pageUrl: json['pageUrl'] as String, + duration: Duration(microseconds: json['duration'] as int), + artistUrl: json['artistUrl'] as String, + album: json['album'] as String?, + ); + +Map _$SourceInfoToJson(SourceInfo instance) => + { + 'id': instance.id, + 'title': instance.title, + 'artist': instance.artist, + 'artistUrl': instance.artistUrl, + 'album': instance.album, + 'thumbnail': instance.thumbnail, + 'pageUrl': instance.pageUrl, + 'duration': instance.duration.inMicroseconds, + }; diff --git a/lib/services/sourced_track/models/source_map.dart b/lib/services/sourced_track/models/source_map.dart new file mode 100644 index 00000000..f99f95e4 --- /dev/null +++ b/lib/services/sourced_track/models/source_map.dart @@ -0,0 +1,58 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; + +part 'source_map.g.dart'; + +@JsonSerializable() +class SourceQualityMap { + final String high; + final String medium; + final String low; + + const SourceQualityMap({ + required this.high, + required this.medium, + required this.low, + }); + + factory SourceQualityMap.fromJson(Map json) => + _$SourceQualityMapFromJson(json); + + Map toJson() => _$SourceQualityMapToJson(this); + + operator [](SourceQualities key) { + switch (key) { + case SourceQualities.high: + return high; + case SourceQualities.medium: + return medium; + case SourceQualities.low: + return low; + } + } +} + +@JsonSerializable() +class SourceMap { + final SourceQualityMap? weba; + final SourceQualityMap? m4a; + + const SourceMap({ + this.weba, + this.m4a, + }); + + factory SourceMap.fromJson(Map json) => + _$SourceMapFromJson(json); + + Map toJson() => _$SourceMapToJson(this); + + operator [](SourceCodecs key) { + switch (key) { + case SourceCodecs.weba: + return weba; + case SourceCodecs.m4a: + return m4a; + } + } +} diff --git a/lib/services/sourced_track/models/source_map.g.dart b/lib/services/sourced_track/models/source_map.g.dart new file mode 100644 index 00000000..e1085aa8 --- /dev/null +++ b/lib/services/sourced_track/models/source_map.g.dart @@ -0,0 +1,35 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'source_map.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SourceQualityMap _$SourceQualityMapFromJson(Map json) => + SourceQualityMap( + high: json['high'] as String, + medium: json['medium'] as String, + low: json['low'] as String, + ); + +Map _$SourceQualityMapToJson(SourceQualityMap instance) => + { + 'high': instance.high, + 'medium': instance.medium, + 'low': instance.low, + }; + +SourceMap _$SourceMapFromJson(Map json) => SourceMap( + weba: json['weba'] == null + ? null + : SourceQualityMap.fromJson(json['weba'] as Map), + m4a: json['m4a'] == null + ? null + : SourceQualityMap.fromJson(json['m4a'] as Map), + ); + +Map _$SourceMapToJson(SourceMap instance) => { + 'weba': instance.weba, + 'm4a': instance.m4a, + }; diff --git a/lib/services/sourced_track/models/video_info.dart b/lib/services/sourced_track/models/video_info.dart new file mode 100644 index 00000000..031a8943 --- /dev/null +++ b/lib/services/sourced_track/models/video_info.dart @@ -0,0 +1,114 @@ +import 'package:piped_client/piped_client.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; + +class YoutubeVideoInfo { + final SearchMode searchMode; + final String title; + final Duration duration; + final String thumbnailUrl; + final String id; + final int likes; + final int dislikes; + final int views; + final String channelName; + final String channelId; + final DateTime publishedAt; + + YoutubeVideoInfo({ + required this.searchMode, + required this.title, + required this.duration, + required this.thumbnailUrl, + required this.id, + required this.likes, + required this.dislikes, + required this.views, + required this.channelName, + required this.publishedAt, + required this.channelId, + }); + + YoutubeVideoInfo.fromJson(Map json) + : title = json['title'], + searchMode = SearchMode.fromString(json['searchMode']), + duration = Duration(seconds: json['duration']), + thumbnailUrl = json['thumbnailUrl'], + id = json['id'], + likes = json['likes'], + dislikes = json['dislikes'], + views = json['views'], + channelName = json['channelName'], + channelId = json['channelId'], + publishedAt = DateTime.tryParse(json['publishedAt']) ?? DateTime.now(); + + Map toJson() => { + 'title': title, + 'duration': duration.inSeconds, + 'thumbnailUrl': thumbnailUrl, + 'id': id, + 'likes': likes, + 'dislikes': dislikes, + 'views': views, + 'channelName': channelName, + 'channelId': channelId, + 'publishedAt': publishedAt.toIso8601String(), + 'searchMode': searchMode.name, + }; + + factory YoutubeVideoInfo.fromVideo(Video video) { + return YoutubeVideoInfo( + searchMode: SearchMode.youtube, + title: video.title, + duration: video.duration ?? Duration.zero, + thumbnailUrl: video.thumbnails.mediumResUrl, + id: video.id.value, + likes: video.engagement.likeCount ?? 0, + dislikes: video.engagement.dislikeCount ?? 0, + views: video.engagement.viewCount, + channelName: video.author, + channelId: '/c/${video.channelId.value}', + publishedAt: video.uploadDate ?? DateTime(2003, 9, 9), + ); + } + + factory YoutubeVideoInfo.fromSearchItemStream( + PipedSearchItemStream searchItem, + SearchMode searchMode, + ) { + return YoutubeVideoInfo( + searchMode: searchMode, + title: searchItem.title, + duration: searchItem.duration, + thumbnailUrl: searchItem.thumbnail, + id: searchItem.id, + likes: 0, + dislikes: 0, + views: searchItem.views, + channelName: searchItem.uploaderName, + channelId: searchItem.uploaderUrl ?? "", + publishedAt: searchItem.uploadedDate != null + ? DateTime.tryParse(searchItem.uploadedDate!) ?? DateTime(2003, 9, 9) + : DateTime(2003, 9, 9), + ); + } + + factory YoutubeVideoInfo.fromStreamResponse( + PipedStreamResponse stream, SearchMode searchMode) { + return YoutubeVideoInfo( + searchMode: searchMode, + title: stream.title, + duration: stream.duration, + thumbnailUrl: stream.thumbnailUrl, + id: stream.id, + likes: stream.likes, + dislikes: stream.dislikes, + views: stream.views, + channelName: stream.uploader, + publishedAt: stream.uploadedDate != null + ? DateTime.tryParse(stream.uploadedDate!) ?? DateTime(2003, 9, 9) + : DateTime(2003, 9, 9), + channelId: stream.uploaderUrl, + ); + } +} diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart new file mode 100644 index 00000000..3ceafbf7 --- /dev/null +++ b/lib/services/sourced_track/sourced_track.dart @@ -0,0 +1,170 @@ +import 'package:collection/collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; +import 'package:spotube/services/sourced_track/models/source_info.dart'; +import 'package:spotube/services/sourced_track/models/source_map.dart'; +import 'package:spotube/services/sourced_track/sources/jiosaavn.dart'; +import 'package:spotube/services/sourced_track/sources/piped.dart'; +import 'package:spotube/services/sourced_track/sources/youtube.dart'; +import 'package:spotube/utils/service_utils.dart'; + +abstract class SourcedTrack extends Track { + final SourceMap source; + final List siblings; + final SourceInfo sourceInfo; + final Ref ref; + + SourcedTrack({ + required this.ref, + required this.source, + required this.siblings, + required this.sourceInfo, + required Track track, + }) { + id = track.id; + name = track.name; + artists = track.artists; + album = track.album; + durationMs = track.durationMs; + discNumber = track.discNumber; + explicit = track.explicit; + externalIds = track.externalIds; + href = track.href; + isPlayable = track.isPlayable; + linkedFrom = track.linkedFrom; + popularity = track.popularity; + previewUrl = track.previewUrl; + trackNumber = track.trackNumber; + type = track.type; + uri = track.uri; + } + + static SourcedTrack fromJson( + Map json, { + required Ref ref, + }) { + final preferences = ref.read(userPreferencesProvider); + + final sourceInfo = SourceInfo.fromJson(json); + final source = SourceMap.fromJson(json); + final track = Track.fromJson(json); + final siblings = (json["siblings"] as List) + .map((sibling) => SourceInfo.fromJson(sibling)) + .toList() + .cast(); + + return switch (preferences.audioSource) { + AudioSource.youtube => YoutubeSourcedTrack( + ref: ref, + source: source, + siblings: siblings, + sourceInfo: sourceInfo, + track: track, + ), + AudioSource.piped => PipedSourcedTrack( + ref: ref, + source: source, + siblings: siblings, + sourceInfo: sourceInfo, + track: track, + ), + AudioSource.jiosaavn => JioSaavnSourcedTrack( + ref: ref, + source: source, + siblings: siblings, + sourceInfo: sourceInfo, + track: track, + ), + }; + } + + static String getSearchTerm(Track track) { + final artists = (track.artists ?? []) + .map((ar) => ar.name) + .toList() + .whereNotNull() + .toList(); + + final title = ServiceUtils.getTitle( + track.name!, + artists: artists, + onlyCleanArtist: true, + ).trim(); + + return "$title - ${artists.join(", ")}"; + } + + static Future fetchFromTrack({ + required Track track, + required Ref ref, + }) async { + try { + final preferences = ref.read(userPreferencesProvider); + + return switch (preferences.audioSource) { + AudioSource.piped => + await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref), + AudioSource.youtube => + await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref), + AudioSource.jiosaavn => + await JioSaavnSourcedTrack.fetchFromTrack(track: track, ref: ref), + }; + } catch (e) { + return YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref); + } + } + + static Future> fetchSiblings({ + required Track track, + required Ref ref, + }) { + final preferences = ref.read(userPreferencesProvider); + + return switch (preferences.audioSource) { + AudioSource.piped => + PipedSourcedTrack.fetchSiblings(track: track, ref: ref), + AudioSource.youtube => + YoutubeSourcedTrack.fetchSiblings(track: track, ref: ref), + AudioSource.jiosaavn => + JioSaavnSourcedTrack.fetchSiblings(track: track, ref: ref), + }; + } + + Future copyWithSibling(); + + Future swapWithSibling(SourceInfo sibling); + + Future swapWithSiblingOfIndex(int index) { + return swapWithSibling(siblings[index]); + } + + String get url { + final preferences = ref.read(userPreferencesProvider); + + final codec = preferences.audioSource == AudioSource.jiosaavn + ? SourceCodecs.m4a + : preferences.streamMusicCodec; + + return getUrlOfCodec(codec); + } + + String getUrlOfCodec(SourceCodecs codec) { + final preferences = ref.read(userPreferencesProvider); + + return source[codec]?[preferences.audioQuality] ?? + // this will ensure playback doesn't break + source[codec == SourceCodecs.m4a ? SourceCodecs.weba : SourceCodecs.m4a] + [preferences.audioQuality]; + } + + SourceCodecs get codec { + final preferences = ref.read(userPreferencesProvider); + + return preferences.audioSource == AudioSource.jiosaavn + ? SourceCodecs.m4a + : preferences.streamMusicCodec; + } +} diff --git a/lib/services/sourced_track/sources/jiosaavn.dart b/lib/services/sourced_track/sources/jiosaavn.dart new file mode 100644 index 00000000..a447b0c1 --- /dev/null +++ b/lib/services/sourced_track/sources/jiosaavn.dart @@ -0,0 +1,179 @@ +import 'package:collection/collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/source_match.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; +import 'package:spotube/services/sourced_track/exceptions.dart'; +import 'package:spotube/services/sourced_track/models/source_info.dart'; +import 'package:spotube/services/sourced_track/models/source_map.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:jiosaavn/jiosaavn.dart'; +import 'package:spotube/extensions/string.dart'; + +final jiosaavnClient = JioSaavnClient(); + +class JioSaavnSourcedTrack extends SourcedTrack { + JioSaavnSourcedTrack({ + required super.ref, + required super.source, + required super.siblings, + required super.sourceInfo, + required super.track, + }); + + static Future fetchFromTrack({ + required Track track, + required Ref ref, + }) async { + final cachedSource = await SourceMatch.box.get(track.id); + + if (cachedSource == null || + cachedSource.sourceType != SourceType.jiosaavn) { + final siblings = await fetchSiblings(ref: ref, track: track); + + if (siblings.isEmpty) { + throw TrackNotFoundException(track); + } + + await SourceMatch.box.put( + track.id!, + SourceMatch( + id: track.id!, + sourceType: SourceType.jiosaavn, + createdAt: DateTime.now(), + sourceId: siblings.first.info.id, + ), + ); + + return JioSaavnSourcedTrack( + ref: ref, + siblings: siblings.map((s) => s.info).skip(1).toList(), + source: siblings.first.source!, + sourceInfo: siblings.first.info, + track: track, + ); + } + + final [item] = + await jiosaavnClient.songs.detailsById([cachedSource.sourceId]); + + final (:info, :source) = toSiblingType(item); + + return JioSaavnSourcedTrack( + ref: ref, + siblings: [], + source: source!, + sourceInfo: info, + track: track, + ); + } + + static SiblingType toSiblingType(SongResponse result) { + final SiblingType sibling = ( + info: SourceInfo( + artist: [ + result.primaryArtists, + if (result.featuredArtists.isNotEmpty) ", ", + result.featuredArtists + ].join("").unescapeHtml(), + artistUrl: + "https://www.jiosaavn.com/artist/${result.primaryArtistsId.split(",").firstOrNull ?? ""}", + duration: Duration(seconds: int.parse(result.duration)), + id: result.id, + pageUrl: result.url, + thumbnail: result.image?.last.link ?? "", + title: result.name!.unescapeHtml(), + album: result.album.name, + ), + source: SourceMap( + m4a: SourceQualityMap( + high: result.downloadUrl! + .firstWhere((element) => element.quality == "320kbps") + .link, + medium: result.downloadUrl! + .firstWhere((element) => element.quality == "160kbps") + .link, + low: result.downloadUrl! + .firstWhere((element) => element.quality == "96kbps") + .link, + ), + ), + ); + + return sibling; + } + + static Future> fetchSiblings({ + required Track track, + required Ref ref, + }) async { + final query = SourcedTrack.getSearchTerm(track); + + final SongSearchResponse(:results) = + await jiosaavnClient.search.songs(query, limit: 20); + + final trackArtistNames = track.artists?.map((ar) => ar.name).toList(); + return results + .where( + (s) { + final sameName = s.name?.unescapeHtml() == track.name; + final artistNames = [ + s.primaryArtists, + if (s.featuredArtists.isNotEmpty) ", ", + s.featuredArtists + ].join("").unescapeHtml(); + final sameArtists = artistNames.split(", ").any( + (artist) => + trackArtistNames?.any((ar) => artist == ar) ?? false, + ); + + return sameName && sameArtists; + }, + ) + .map(toSiblingType) + .toList(); + } + + @override + Future copyWithSibling() async { + if (siblings.isNotEmpty) { + return this; + } + final fetchedSiblings = await fetchSiblings(ref: ref, track: this); + + return JioSaavnSourcedTrack( + ref: ref, + siblings: fetchedSiblings + .where((s) => s.info.id != sourceInfo.id) + .map((s) => s.info) + .toList(), + source: source, + sourceInfo: sourceInfo, + track: this, + ); + } + + @override + Future swapWithSibling(SourceInfo sibling) async { + if (sibling.id == sourceInfo.id || + siblings.none((s) => s.id == sibling.id)) { + return null; + } + + final newSourceInfo = siblings.firstWhere((s) => s.id == sibling.id); + final newSiblings = siblings.where((s) => s.id != sibling.id).toList() + ..insert(0, sourceInfo); + + final [item] = await jiosaavnClient.songs.detailsById([newSourceInfo.id]); + + final (:info, :source) = toSiblingType(item); + + return JioSaavnSourcedTrack( + ref: ref, + siblings: newSiblings, + source: source!, + sourceInfo: info, + track: this, + ); + } +} diff --git a/lib/services/sourced_track/sources/piped.dart b/lib/services/sourced_track/sources/piped.dart new file mode 100644 index 00000000..0778a7cf --- /dev/null +++ b/lib/services/sourced_track/sources/piped.dart @@ -0,0 +1,257 @@ +import 'package:collection/collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:piped_client/piped_client.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/source_match.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; +import 'package:spotube/services/sourced_track/exceptions.dart'; +import 'package:spotube/services/sourced_track/models/source_info.dart'; +import 'package:spotube/services/sourced_track/models/source_map.dart'; +import 'package:spotube/services/sourced_track/models/video_info.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:spotube/services/sourced_track/sources/youtube.dart'; +import 'package:spotube/utils/service_utils.dart'; + +final pipedProvider = Provider( + (ref) { + final instance = + ref.watch(userPreferencesProvider.select((s) => s.pipedInstance)); + return PipedClient(instance: instance); + }, +); + +class PipedSourcedTrack extends SourcedTrack { + PipedSourcedTrack({ + required super.ref, + required super.source, + required super.siblings, + required super.sourceInfo, + required super.track, + }); + + static Future fetchFromTrack({ + required Track track, + required Ref ref, + }) async { + final cachedSource = await SourceMatch.box.get(track.id); + final preferences = ref.read(userPreferencesProvider); + final pipedClient = ref.read(pipedProvider); + + if (cachedSource == null) { + final siblings = await fetchSiblings(ref: ref, track: track); + if (siblings.isEmpty) { + throw TrackNotFoundException(track); + } + + await SourceMatch.box.put( + track.id!, + SourceMatch( + id: track.id!, + sourceType: preferences.searchMode == SearchMode.youtube + ? SourceType.youtube + : SourceType.youtubeMusic, + createdAt: DateTime.now(), + sourceId: siblings.first.info.id, + ), + ); + + return PipedSourcedTrack( + ref: ref, + siblings: siblings.map((s) => s.info).skip(1).toList(), + source: siblings.first.source as SourceMap, + sourceInfo: siblings.first.info, + track: track, + ); + } else { + final manifest = await pipedClient.streams(cachedSource.sourceId); + + return PipedSourcedTrack( + ref: ref, + siblings: [], + source: toSourceMap(manifest), + sourceInfo: SourceInfo( + id: manifest.id, + artist: manifest.uploader, + artistUrl: manifest.uploaderUrl, + pageUrl: "https://www.youtube.com/watch?v=${manifest.id}", + thumbnail: manifest.thumbnailUrl, + title: manifest.title, + duration: manifest.duration, + album: null, + ), + track: track, + ); + } + } + + static SourceMap toSourceMap(PipedStreamResponse manifest) { + final m4a = manifest.audioStreams + .where((audio) => audio.format == PipedAudioStreamFormat.m4a) + .sorted((a, b) => a.bitrate.compareTo(b.bitrate)); + + final weba = manifest.audioStreams + .where((audio) => audio.format == PipedAudioStreamFormat.webm) + .sorted((a, b) => a.bitrate.compareTo(b.bitrate)); + + return SourceMap( + m4a: SourceQualityMap( + high: m4a.first.url.toString(), + medium: (m4a.elementAtOrNull(m4a.length ~/ 2) ?? m4a[1]).url.toString(), + low: m4a.last.url.toString(), + ), + weba: SourceQualityMap( + high: weba.first.url.toString(), + medium: + (weba.elementAtOrNull(weba.length ~/ 2) ?? weba[1]).url.toString(), + low: weba.last.url.toString(), + ), + ); + } + + static Future toSiblingType( + int index, + YoutubeVideoInfo item, + PipedClient pipedClient, + ) async { + SourceMap? sourceMap; + if (index == 0) { + final manifest = await pipedClient.streams(item.id); + sourceMap = toSourceMap(manifest); + } + + final SiblingType sibling = ( + info: SourceInfo( + id: item.id, + artist: item.channelName, + artistUrl: "https://www.youtube.com/${item.channelId}", + pageUrl: "https://www.youtube.com/watch?v=${item.id}", + thumbnail: item.thumbnailUrl, + title: item.title, + duration: item.duration, + album: null, + ), + source: sourceMap, + ); + + return sibling; + } + + static Future> fetchSiblings({ + required Track track, + required Ref ref, + }) async { + final pipedClient = ref.read(pipedProvider); + final preference = ref.read(userPreferencesProvider); + final query = SourcedTrack.getSearchTerm(track); + + final PipedSearchResult(items: searchResults) = await pipedClient.search( + query, + preference.searchMode == SearchMode.youtube + ? PipedFilter.video + : PipedFilter.musicSongs, + ); + + final isYouTubeMusic = preference.searchMode == SearchMode.youtubeMusic; + + if (isYouTubeMusic) { + final artists = (track.artists ?? []) + .map((ar) => ar.name) + .toList() + .whereNotNull() + .toList(); + + return await Future.wait( + searchResults + .map( + (result) => YoutubeVideoInfo.fromSearchItemStream( + result as PipedSearchItemStream, + preference.searchMode, + ), + ) + .sorted((a, b) => b.views.compareTo(a.views)) + .where( + (item) => artists.any( + (artist) => + artist.toLowerCase() == item.channelName.toLowerCase(), + ), + ) + .mapIndexed((i, r) => toSiblingType(i, r, pipedClient)), + ); + } + + if (ServiceUtils.onlyContainsEnglish(query)) { + return await Future.wait( + searchResults + .whereType() + .map( + (result) => YoutubeVideoInfo.fromSearchItemStream( + result, + preference.searchMode, + ), + ) + .mapIndexed((i, r) => toSiblingType(i, r, pipedClient)), + ); + } + + final rankedSiblings = YoutubeSourcedTrack.rankResults( + searchResults + .map( + (result) => YoutubeVideoInfo.fromSearchItemStream( + result as PipedSearchItemStream, + preference.searchMode, + ), + ) + .toList(), + track, + ); + + return await Future.wait( + rankedSiblings.mapIndexed((i, r) => toSiblingType(i, r, pipedClient)), + ); + } + + @override + Future copyWithSibling() async { + if (siblings.isNotEmpty) { + return this; + } + final fetchedSiblings = await fetchSiblings(ref: ref, track: this); + + return PipedSourcedTrack( + ref: ref, + siblings: fetchedSiblings + .where((s) => s.info.id != sourceInfo.id) + .map((s) => s.info) + .toList(), + source: source, + sourceInfo: sourceInfo, + track: this, + ); + } + + @override + Future swapWithSibling(SourceInfo sibling) async { + if (sibling.id == sourceInfo.id || + siblings.none((s) => s.id == sibling.id)) { + return null; + } + + final newSourceInfo = siblings.firstWhere((s) => s.id == sibling.id); + final newSiblings = siblings.where((s) => s.id != sibling.id).toList() + ..insert(0, sourceInfo); + + final pipedClient = ref.read(pipedProvider); + + final manifest = await pipedClient.streams(newSourceInfo.id); + + return PipedSourcedTrack( + ref: ref, + siblings: newSiblings, + source: toSourceMap(manifest), + sourceInfo: newSourceInfo, + track: this, + ); + } +} diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart new file mode 100644 index 00000000..096de2d4 --- /dev/null +++ b/lib/services/sourced_track/sources/youtube.dart @@ -0,0 +1,256 @@ +import 'package:collection/collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/source_match.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; +import 'package:spotube/services/sourced_track/exceptions.dart'; +import 'package:spotube/services/sourced_track/models/source_info.dart'; +import 'package:spotube/services/sourced_track/models/source_map.dart'; +import 'package:spotube/services/sourced_track/models/video_info.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:spotube/utils/service_utils.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; + +final youtubeClient = YoutubeExplode(); +final officialMusicRegex = RegExp( + r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)", + caseSensitive: false, +); + +class YoutubeSourcedTrack extends SourcedTrack { + YoutubeSourcedTrack({ + required super.source, + required super.siblings, + required super.sourceInfo, + required super.track, + required super.ref, + }); + + static Future fetchFromTrack({ + required Track track, + required Ref ref, + }) async { + final cachedSource = await SourceMatch.box.get(track.id); + + if (cachedSource == null || cachedSource.sourceType != SourceType.youtube) { + final siblings = await fetchSiblings(ref: ref, track: track); + if (siblings.isEmpty) { + throw TrackNotFoundException(track); + } + + await SourceMatch.box.put( + track.id!, + SourceMatch( + id: track.id!, + sourceType: SourceType.youtube, + createdAt: DateTime.now(), + sourceId: siblings.first.info.id, + ), + ); + + return YoutubeSourcedTrack( + ref: ref, + siblings: siblings.map((s) => s.info).skip(1).toList(), + source: siblings.first.source as SourceMap, + sourceInfo: siblings.first.info, + track: track, + ); + } + final item = await youtubeClient.videos.get(cachedSource.sourceId); + final manifest = await youtubeClient.videos.streamsClient.getManifest( + cachedSource.sourceId, + ); + return YoutubeSourcedTrack( + ref: ref, + siblings: [], + source: toSourceMap(manifest), + sourceInfo: SourceInfo( + id: item.id.value, + artist: item.author, + artistUrl: "https://www.youtube.com/channel/${item.channelId}", + pageUrl: item.url, + thumbnail: item.thumbnails.highResUrl, + title: item.title, + duration: item.duration ?? Duration.zero, + album: null, + ), + track: track, + ); + } + + static SourceMap toSourceMap(StreamManifest manifest) { + final m4a = manifest.audioOnly + .where((audio) => audio.codec.mimeType == "audio/mp4") + .sortByBitrate(); + + final weba = manifest.audioOnly + .where((audio) => audio.codec.mimeType == "audio/webm") + .sortByBitrate(); + + return SourceMap( + m4a: SourceQualityMap( + high: m4a.first.url.toString(), + medium: (m4a.elementAtOrNull(m4a.length ~/ 2) ?? m4a[1]).url.toString(), + low: m4a.last.url.toString(), + ), + weba: SourceQualityMap( + high: weba.first.url.toString(), + medium: + (weba.elementAtOrNull(weba.length ~/ 2) ?? weba[1]).url.toString(), + low: weba.last.url.toString(), + ), + ); + } + + static Future toSiblingType( + int index, + YoutubeVideoInfo item, + ) async { + SourceMap? sourceMap; + if (index == 0) { + final manifest = + await youtubeClient.videos.streamsClient.getManifest(item.id); + sourceMap = toSourceMap(manifest); + } + + final SiblingType sibling = ( + info: SourceInfo( + id: item.id, + artist: item.channelName, + artistUrl: "https://www.youtube.com/channel/${item.channelId}", + pageUrl: "https://www.youtube.com/watch?v=${item.id}", + thumbnail: item.thumbnailUrl, + title: item.title, + duration: item.duration, + album: null, + ), + source: sourceMap, + ); + + return sibling; + } + + static List rankResults( + List results, Track track) { + final artists = (track.artists ?? []) + .map((ar) => ar.name) + .toList() + .whereNotNull() + .toList(); + + return results + .sorted((a, b) => b.views.compareTo(a.views)) + .map((sibling) { + int score = 0; + + for (final artist in artists) { + final isSameChannelArtist = + sibling.channelName.toLowerCase() == artist.toLowerCase(); + final channelContainsArtist = sibling.channelName + .toLowerCase() + .contains(artist.toLowerCase()); + + if (isSameChannelArtist || channelContainsArtist) { + score += 1; + } + + final titleContainsArtist = + sibling.title.toLowerCase().contains(artist.toLowerCase()); + + if (titleContainsArtist) { + score += 1; + } + } + + final titleContainsTrackName = + sibling.title.toLowerCase().contains(track.name!.toLowerCase()); + + final hasOfficialFlag = + officialMusicRegex.hasMatch(sibling.title.toLowerCase()); + + if (titleContainsTrackName) { + score += 3; + } + + if (hasOfficialFlag) { + score += 1; + } + + if (hasOfficialFlag && titleContainsTrackName) { + score += 2; + } + + return (sibling: sibling, score: score); + }) + .sorted((a, b) => b.score.compareTo(a.score)) + .map((e) => e.sibling) + .toList(); + } + + static Future> fetchSiblings({ + required Track track, + required Ref ref, + }) async { + final query = SourcedTrack.getSearchTerm(track); + + final searchResults = await youtubeClient.search.search( + query, + filter: TypeFilters.video, + ); + + if (ServiceUtils.onlyContainsEnglish(query)) { + return await Future.wait(searchResults + .map(YoutubeVideoInfo.fromVideo) + .mapIndexed(toSiblingType)); + } + + final rankedSiblings = rankResults( + searchResults.map(YoutubeVideoInfo.fromVideo).toList(), + track, + ); + + return await Future.wait(rankedSiblings.mapIndexed(toSiblingType)); + } + + @override + Future swapWithSibling(SourceInfo sibling) async { + if (sibling.id == sourceInfo.id || + siblings.none((s) => s.id == sibling.id)) { + return null; + } + + final newSourceInfo = siblings.firstWhere((s) => s.id == sibling.id); + final newSiblings = siblings.where((s) => s.id != sibling.id).toList() + ..insert(0, sourceInfo); + + final manifest = + await youtubeClient.videos.streamsClient.getManifest(newSourceInfo.id); + + return YoutubeSourcedTrack( + ref: ref, + siblings: newSiblings, + source: toSourceMap(manifest), + sourceInfo: newSourceInfo, + track: this, + ); + } + + @override + Future copyWithSibling() async { + if (siblings.isNotEmpty) { + return this; + } + final fetchedSiblings = await fetchSiblings(ref: ref, track: this); + + return YoutubeSourcedTrack( + ref: ref, + siblings: fetchedSiblings + .where((s) => s.info.id != sourceInfo.id) + .map((s) => s.info) + .toList(), + source: source, + sourceInfo: sourceInfo, + track: this, + ); + } +} diff --git a/lib/services/supabase.dart b/lib/services/supabase.dart deleted file mode 100644 index d42d8eeb..00000000 --- a/lib/services/supabase.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:spotube/collections/env.dart'; -import 'package:spotube/models/matched_track.dart'; -import 'package:supabase/supabase.dart'; - -class SupabaseService { - static final api = SupabaseClient( - Env.supabaseUrl ?? "", - Env.supabaseAnonKey ?? "", - ); - - Future insertTrack(MatchedTrack track) async { - await api.from("tracks").insert(track.toJson()); - } -} - -final supabase = SupabaseService(); diff --git a/lib/services/wikipedia/wikipedia.dart b/lib/services/wikipedia/wikipedia.dart new file mode 100644 index 00000000..b571f30f --- /dev/null +++ b/lib/services/wikipedia/wikipedia.dart @@ -0,0 +1,3 @@ +import 'package:wikipedia_api/wikipedia_api.dart'; + +final wikipedia = WikipediaApi(); diff --git a/lib/services/youtube/youtube.dart b/lib/services/youtube/youtube.dart deleted file mode 100644 index c8c277e3..00000000 --- a/lib/services/youtube/youtube.dart +++ /dev/null @@ -1,248 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:piped_client/piped_client.dart'; -import 'package:spotube/collections/routes.dart'; -import 'package:spotube/components/shared/dialogs/piped_down_dialog.dart'; -import 'package:spotube/models/matched_track.dart'; -import 'package:spotube/provider/user_preferences_provider.dart'; -import 'package:spotube/utils/primitive_utils.dart'; -import 'package:youtube_explode_dart/youtube_explode_dart.dart'; - -class YoutubeVideoInfo { - final SearchMode searchMode; - final String title; - final Duration duration; - final String thumbnailUrl; - final String id; - final int likes; - final int dislikes; - final int views; - final String channelName; - final String channelId; - final DateTime publishedAt; - - YoutubeVideoInfo({ - required this.searchMode, - required this.title, - required this.duration, - required this.thumbnailUrl, - required this.id, - required this.likes, - required this.dislikes, - required this.views, - required this.channelName, - required this.publishedAt, - required this.channelId, - }); - - YoutubeVideoInfo.fromJson(Map json) - : title = json['title'], - searchMode = SearchMode.fromString(json['searchMode']), - duration = Duration(seconds: json['duration']), - thumbnailUrl = json['thumbnailUrl'], - id = json['id'], - likes = json['likes'], - dislikes = json['dislikes'], - views = json['views'], - channelName = json['channelName'], - channelId = json['channelId'], - publishedAt = DateTime.tryParse(json['publishedAt']) ?? DateTime.now(); - - Map toJson() => { - 'title': title, - 'duration': duration.inSeconds, - 'thumbnailUrl': thumbnailUrl, - 'id': id, - 'likes': likes, - 'dislikes': dislikes, - 'views': views, - 'channelName': channelName, - 'channelId': channelId, - 'publishedAt': publishedAt.toIso8601String(), - 'searchMode': searchMode.name, - }; - - factory YoutubeVideoInfo.fromVideo(Video video) { - return YoutubeVideoInfo( - searchMode: SearchMode.youtube, - title: video.title, - duration: video.duration ?? Duration.zero, - thumbnailUrl: video.thumbnails.mediumResUrl, - id: video.id.value, - likes: video.engagement.likeCount ?? 0, - dislikes: video.engagement.dislikeCount ?? 0, - views: video.engagement.viewCount, - channelName: video.author, - channelId: '/c/${video.channelId.value}', - publishedAt: video.uploadDate ?? DateTime(2003, 9, 9), - ); - } - - factory YoutubeVideoInfo.fromSearchItemStream( - PipedSearchItemStream searchItem, - SearchMode searchMode, - ) { - return YoutubeVideoInfo( - searchMode: searchMode, - title: searchItem.title, - duration: searchItem.duration, - thumbnailUrl: searchItem.thumbnail, - id: searchItem.id, - likes: 0, - dislikes: 0, - views: searchItem.views, - channelName: searchItem.uploaderName, - channelId: searchItem.uploaderUrl ?? "", - publishedAt: searchItem.uploadedDate != null - ? DateTime.tryParse(searchItem.uploadedDate!) ?? DateTime(2003, 9, 9) - : DateTime(2003, 9, 9), - ); - } - - factory YoutubeVideoInfo.fromStreamResponse( - PipedStreamResponse stream, SearchMode searchMode) { - return YoutubeVideoInfo( - searchMode: searchMode, - title: stream.title, - duration: stream.duration, - thumbnailUrl: stream.thumbnailUrl, - id: stream.id, - likes: stream.likes, - dislikes: stream.dislikes, - views: stream.views, - channelName: stream.uploader, - publishedAt: stream.uploadedDate != null - ? DateTime.tryParse(stream.uploadedDate!) ?? DateTime(2003, 9, 9) - : DateTime(2003, 9, 9), - channelId: stream.uploaderUrl, - ); - } -} - -class YoutubeEndpoints { - PipedClient? piped; - YoutubeExplode? youtube; - - final UserPreferences preferences; - - YoutubeEndpoints(this.preferences) { - switch (preferences.youtubeApiType) { - case YoutubeApiType.youtube: - youtube = YoutubeExplode(); - break; - case YoutubeApiType.piped: - piped = PipedClient(instance: preferences.pipedInstance); - break; - } - } - - Future showPipedErrorDialog(Exception e) async { - if (e is DioException && (e.response?.statusCode ?? 0) >= 500) { - final context = rootNavigatorKey?.currentContext; - if (context != null) { - await showDialog( - context: context, - builder: (context) => const PipedDownDialog(), - ); - } - } - } - - Future> search(String query) async { - if (youtube != null) { - final res = await youtube!.search( - query, - filter: TypeFilters.video, - ); - - return res.map(YoutubeVideoInfo.fromVideo).toList(); - } else { - try { - final res = await piped!.search( - query, - switch (preferences.searchMode) { - SearchMode.youtube => PipedFilter.video, - SearchMode.youtubeMusic => PipedFilter.musicSongs, - }, - ); - return res.items - .whereType() - .map( - (e) => YoutubeVideoInfo.fromSearchItemStream( - e, - preferences.searchMode, - ), - ) - .toList(); - } on Exception catch (e) { - await showPipedErrorDialog(e); - rethrow; - } - } - } - - String _pipedStreamResponseToStreamUrl( - PipedStreamResponse stream, - MusicCodec codec, - ) { - final pipedStreamFormat = switch (codec) { - MusicCodec.m4a => PipedAudioStreamFormat.m4a, - MusicCodec.weba => PipedAudioStreamFormat.webm, - }; - - return switch (preferences.audioQuality) { - AudioQuality.high => - stream.highestBitrateAudioStreamOfFormat(pipedStreamFormat)!.url, - AudioQuality.low => - stream.lowestBitrateAudioStreamOfFormat(pipedStreamFormat)!.url, - }; - } - - Future streamingUrl(String id, MusicCodec codec) async { - if (youtube != null) { - final res = await PrimitiveUtils.raceMultiple( - () => youtube!.videos.streams.getManifest(id), - ); - final audioOnlyManifests = res.audioOnly.where((info) { - return switch (codec) { - MusicCodec.m4a => info.codec.mimeType == "audio/mp4", - MusicCodec.weba => info.codec.mimeType == "audio/webm", - }; - }); - - return switch (preferences.audioQuality) { - AudioQuality.high => - audioOnlyManifests.withHighestBitrate().url.toString(), - AudioQuality.low => - audioOnlyManifests.sortByBitrate().last.url.toString(), - }; - } else { - return _pipedStreamResponseToStreamUrl(await piped!.streams(id), codec); - } - } - - Future<(YoutubeVideoInfo info, String streamingUrl)> video( - String id, - SearchMode searchMode, - MusicCodec codec, - ) async { - if (youtube != null) { - final res = await youtube!.videos.get(id); - return ( - YoutubeVideoInfo.fromVideo(res), - await streamingUrl(id, codec), - ); - } else { - try { - final res = await piped!.streams(id); - return ( - YoutubeVideoInfo.fromStreamResponse(res, searchMode), - _pipedStreamResponseToStreamUrl(res, codec), - ); - } on Exception catch (e) { - await showPipedErrorDialog(e); - rethrow; - } - } - } -} diff --git a/lib/themes/theme.dart b/lib/themes/theme.dart index 8c968e1b..9a5e473f 100644 --- a/lib/themes/theme.dart +++ b/lib/themes/theme.dart @@ -71,5 +71,8 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { scrollbarTheme: const ScrollbarThemeData( thickness: MaterialStatePropertyAll(14), ), + checkboxTheme: CheckboxThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + ), ); } diff --git a/lib/utils/persisted_state_notifier.dart b/lib/utils/persisted_state_notifier.dart index 2937bff9..218cd64a 100644 --- a/lib/utils/persisted_state_notifier.dart +++ b/lib/utils/persisted_state_notifier.dart @@ -59,32 +59,32 @@ abstract class PersistedStateNotifier extends StateNotifier { static Future read(String key) async { final localStorage = await SharedPreferences.getInstance(); - if (kIsMacOS || kIsIOS) { + if (kIsMacOS || kIsIOS || (kIsLinux && !kIsFlatpak)) { + return localStorage.getString(key); + } + + try { + await localStorage.setBool(kIsUsingEncryption, true); + return await secureStorage.read(key: key); + } catch (e) { + await localStorage.setBool(kIsUsingEncryption, false); return localStorage.getString(key); - } else { - try { - await localStorage.setBool(kIsUsingEncryption, true); - return await secureStorage.read(key: key); - } catch (e) { - await localStorage.setBool(kIsUsingEncryption, false); - return localStorage.getString(key); - } } } static Future write(String key, String value) async { final localStorage = await SharedPreferences.getInstance(); - if (kIsMacOS || kIsIOS) { + if (kIsMacOS || kIsIOS || (kIsLinux && !kIsFlatpak)) { await localStorage.setString(key, value); return; - } else { - try { - await localStorage.setBool(kIsUsingEncryption, true); - await secureStorage.write(key: key, value: value); - } catch (e) { - await localStorage.setBool(kIsUsingEncryption, false); - await localStorage.setString(key, value); - } + } + + try { + await localStorage.setBool(kIsUsingEncryption, true); + await secureStorage.write(key: key, value: value); + } catch (e) { + await localStorage.setBool(kIsUsingEncryption, false); + await localStorage.setString(key, value); } } diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart index 0be1dd97..9e3b5893 100644 --- a/lib/utils/service_utils.dart +++ b/lib/utils/service_utils.dart @@ -8,7 +8,7 @@ import 'package:spotube/components/library/user_local_tracks.dart'; import 'package:spotube/models/logger.dart'; import 'package:http/http.dart' as http; import 'package:spotube/models/lyrics.dart'; -import 'package:spotube/models/spotube_track.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/primitive_utils.dart'; import 'package:collection/collection.dart'; @@ -171,7 +171,7 @@ abstract class ServiceUtils { static const baseUri = "https://www.rentanadviser.com/subtitles"; @Deprecated("In favor spotify lyrics api, this isn't needed anymore") - static Future getTimedLyrics(SpotubeTrack track) async { + static Future getTimedLyrics(SourcedTrack track) async { final artistNames = track.artists?.map((artist) => artist.name!).toList() ?? []; final query = getTitle( @@ -199,7 +199,7 @@ abstract class ServiceUtils { false; final hasTrackName = title.contains(track.name!.toLowerCase()); final isNotLive = !PrimitiveUtils.containsTextInBracket(title, "live"); - final exactYtMatch = title == track.ytTrack.title.toLowerCase(); + final exactYtMatch = title == track.sourceInfo.title.toLowerCase(); if (exactYtMatch) points = 7; for (final criteria in [hasTrackName, hasAllArtists, isNotLive]) { if (criteria) points++; diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart index a805272c..662b611c 100644 --- a/lib/utils/type_conversion_utils.dart +++ b/lib/utils/type_conversion_utils.dart @@ -147,7 +147,7 @@ abstract class TypeConversionUtils { track.name = metadata?.title ?? basenameWithoutExtension(file.path); track.type = "track"; track.uri = file.path; - track.durationMs = (metadata?.durationMs?.toInt() ?? 0) * 1000; + track.durationMs = (metadata?.durationMs?.toInt() ?? 0); return track; } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index d455dc02..a07f7f9b 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -18,6 +19,9 @@ #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) dart_discord_rpc_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "DartDiscordRpcPlugin"); + dart_discord_rpc_plugin_register_with_registrar(dart_discord_rpc_registrar); g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 22319e92..97d541b3 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + dart_discord_rpc file_selector_linux flutter_secure_storage_linux local_notifier diff --git a/pubspec.lock b/pubspec.lock index 3dbc3cbf..06ca8202 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -325,10 +325,10 @@ packages: dependency: "direct main" description: name: collection - sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a url: "https://pub.dev" source: hosted - version: "1.17.2" + version: "1.18.0" color: dependency: transitive description: @@ -385,6 +385,23 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.3" + dart_des: + dependency: transitive + description: + name: dart_des + sha256: "0a66afb8883368c824497fd2a1fd67bdb1a785965a3956728382c03d40747c33" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + dart_discord_rpc: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "4d05017838ebeadcdb832e1893fabad1506fddba" + url: "https://github.com/Tommypop2/dart_discord_rpc.git" + source: git + version: "0.0.3" dart_style: dependency: transitive description: @@ -465,6 +482,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + draggable_scrollbar: + dependency: "direct main" + description: + path: "." + ref: cfd570035bf393de541d32e9b28808b5d7e602df + resolved-ref: cfd570035bf393de541d32e9b28808b5d7e602df + url: "https://github.com/thielepaul/flutter-draggable-scrollbar.git" + source: git + version: "0.1.0" duration: dependency: "direct main" description: @@ -513,6 +539,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.4" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "4e42aacde3b993c5947467ab640882c56947d9d27342a5b6f2895b23956954a6" + url: "https://pub.dev" + source: hosted + version: "6.1.1" file_selector: dependency: "direct main" description: @@ -588,30 +622,27 @@ packages: fl_query: dependency: "direct main" description: - path: "packages/fl_query" - ref: HEAD - resolved-ref: a817713a0bb0c486e908e9ed74467c4f7f58bea7 - url: "https://github.com/KRTirtho/fl-query.git" - source: git - version: "1.0.0-alpha.5" + name: fl_query + sha256: daee5ab0ed8899baa201b89b5813107df5258144a9e2bcf192dbcf922c57d985 + url: "https://pub.dev" + source: hosted + version: "1.0.0" fl_query_devtools: dependency: "direct main" description: - path: "packages/fl_query_devtools" - ref: HEAD - resolved-ref: a817713a0bb0c486e908e9ed74467c4f7f58bea7 - url: "https://github.com/KRTirtho/fl-query.git" - source: git - version: "0.1.0-alpha.3" + name: fl_query_devtools + sha256: "2ae8905fd4a95f1d245a1b54057c31c8d27fc961223bcb7ce13088bcf6595059" + url: "https://pub.dev" + source: hosted + version: "0.1.0" fl_query_hooks: dependency: "direct main" description: - path: "packages/fl_query_hooks" - ref: HEAD - resolved-ref: a817713a0bb0c486e908e9ed74467c4f7f58bea7 - url: "https://github.com/KRTirtho/fl-query.git" - source: git - version: "1.0.0-alpha.5" + name: fl_query_hooks + sha256: "6c88b3bfbdc3e1330931b927903929d7351f86fc63266ac93b3acb9f133a09a9" + url: "https://pub.dev" + source: hosted + version: "1.0.0" fluentui_system_icons: dependency: "direct main" description: @@ -931,14 +962,6 @@ packages: description: flutter source: sdk version: "0.0.0" - functions_client: - dependency: transitive - description: - name: functions_client - sha256: "3b157b4d3ae9e38614fd80fab76d1ef1e0e39ff3412a45de2651f27cecb9d2d2" - url: "https://pub.dev" - source: hosted - version: "1.3.2" fuzzywuzzy: dependency: "direct main" description: @@ -947,6 +970,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.6" + gap: + dependency: "direct main" + description: + name: gap + sha256: f19387d4e32f849394758b91377f9153a1b41d79513ef7668c088c77dbc6955d + url: "https://pub.dev" + source: hosted + version: "3.0.1" glob: dependency: transitive description: @@ -971,14 +1002,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.0" - gotrue: - dependency: transitive - description: - name: gotrue - sha256: af61c5c6a2374d9032b7e4b388de0bb0442f4bedc56372d5382c1ef61c85f1f3 - url: "https://pub.dev" - source: hosted - version: "1.12.1" graphs: dependency: transitive description: @@ -1035,6 +1058,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.4" + html_unescape: + dependency: "direct main" + description: + name: html_unescape + sha256: "15362d7a18f19d7b742ef8dcb811f5fd2a2df98db9f80ea393c075189e0b61e3" + url: "https://pub.dev" + source: hosted + version: "2.0.0" http: dependency: "direct main" description: @@ -1160,6 +1191,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + jiosaavn: + dependency: "direct main" + description: + name: jiosaavn + sha256: d32b4f43f26488f942f5d7d19d748a1f2664ae3d41ff9c7d50eeb81705174bd2 + url: "https://pub.dev" + source: hosted + version: "0.1.0" js: dependency: transitive description: @@ -1192,14 +1231,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.2" - jwt_decode: - dependency: transitive - description: - name: jwt_decode - sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb - url: "https://pub.dev" - source: hosted - version: "0.3.1" lints: dependency: transitive description: @@ -1324,10 +1355,10 @@ packages: dependency: transitive description: name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.10.0" metadata_god: dependency: "direct main" description: @@ -1540,10 +1571,10 @@ packages: dependency: transitive description: name: platform - sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76" + sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102 url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.2" plugin_platform_interface: dependency: transitive description: @@ -1576,14 +1607,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.8+2" - postgrest: - dependency: transitive - description: - name: postgrest - sha256: d6cc0f60c7dc761f84d1c6d11d9e02b3ad90399bd84639a28c1c024adbaa9bde - url: "https://pub.dev" - source: hosted - version: "1.5.0" process: dependency: transitive description: @@ -1648,22 +1671,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" - realtime_client: - dependency: transitive - description: - name: realtime_client - sha256: b4b7bb293417dafc73943ed639209b2dcb796db8495e56bba29a4e26fadef5cd - url: "https://pub.dev" - source: hosted - version: "1.2.1" - retry: - dependency: transitive - description: - name: retry - sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" - url: "https://pub.dev" - source: hosted - version: "3.1.2" riverpod: dependency: transitive description: @@ -1830,6 +1837,14 @@ packages: description: flutter source: sdk version: "0.0.99" + sliver_tools: + dependency: "direct main" + description: + name: sliver_tools + sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 + url: "https://pub.dev" + source: hosted + version: "0.2.12" smtc_windows: dependency: "direct main" description: @@ -1890,10 +1905,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.11.1" state_notifier: dependency: transitive description: @@ -1902,22 +1917,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.2+1" - storage_client: - dependency: transitive - description: - name: storage_client - sha256: "4bf2fc76f09c3698f0ba3f1a44d567995796f6aef76501f194631d0c03752ab7" - url: "https://pub.dev" - source: hosted - version: "1.5.2" stream_channel: dependency: transitive description: name: stream_channel - sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" stream_transform: dependency: transitive description: @@ -1942,14 +1949,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.2" - supabase: - dependency: "direct main" - description: - name: supabase - sha256: "4bfa8f673b39c036ed82829a2ddc462dcacfc36fe168b680664ab954c7d91ccd" - url: "https://pub.dev" - source: hosted - version: "1.11.3" sync_http: dependency: transitive description: @@ -2002,10 +2001,10 @@ packages: dependency: transitive description: name: test_api - sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "0.6.1" time: dependency: transitive description: @@ -2166,6 +2165,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + very_good_infinite_list: + dependency: "direct main" + description: + name: very_good_infinite_list + sha256: "6f5ad429edbce6084e1c600e56b26b1de8c6b138e8e8fc2de41b686166029aa5" + url: "https://pub.dev" + source: hosted + version: "0.7.1" visibility_detector: dependency: "direct main" description: @@ -2178,10 +2185,10 @@ packages: dependency: transitive description: name: vm_service - sha256: c620a6f783fa22436da68e42db7ebbf18b8c44b9a46ab911f666ff09ffd9153f + sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583 url: "https://pub.dev" source: hosted - version: "11.7.1" + version: "11.10.0" watcher: dependency: transitive description: @@ -2194,10 +2201,10 @@ packages: dependency: transitive description: name: web - sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 + sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 url: "https://pub.dev" source: hosted - version: "0.1.4-beta" + version: "0.3.0" web_socket_channel: dependency: transitive description: @@ -2214,6 +2221,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + wikipedia_api: + dependency: "direct main" + description: + name: wikipedia_api + sha256: "8bae02778c40e0c09ea237b7c1952c99a33a19ccbe31545e03c807fdc7c56ec6" + url: "https://pub.dev" + source: hosted + version: "0.1.0" win32: dependency: transitive description: @@ -2271,14 +2286,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" - yet_another_json_isolate: - dependency: transitive - description: - name: yet_another_json_isolate - sha256: "86fad76026c4241a32831d6c7febd8f9bded5019e2cd36c5b148499808d8307d" - url: "https://pub.dev" - source: hosted - version: "1.1.1" youtube_explode_dart: dependency: "direct main" description: @@ -2288,5 +2295,5 @@ packages: source: hosted version: "2.0.2" sdks: - dart: ">=3.1.0 <4.0.0" + dart: ">=3.2.0 <4.0.0" flutter: ">=3.13.0" diff --git a/pubspec.yaml b/pubspec.yaml index 04f2d8b8..ba758cbf 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.2.0+25 +version: 3.3.0+26 homepage: https://spotube.krtirtho.dev repository: https://github.com/KRTirtho/spotube @@ -32,18 +32,9 @@ dependencies: duration: ^3.0.12 envied: ^0.3.0 file_selector: ^1.0.1 - fl_query: - git: - url: https://github.com/KRTirtho/fl-query.git - path: packages/fl_query - fl_query_hooks: - git: - url: https://github.com/KRTirtho/fl-query.git - path: packages/fl_query_hooks - fl_query_devtools: - git: - url: https://github.com/KRTirtho/fl-query.git - path: packages/fl_query_devtools + 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 @@ -98,7 +89,6 @@ dependencies: smtc_windows: ^0.1.1 spotify: ^0.12.0 stroke_text: ^0.0.2 - supabase: ^1.9.9 system_theme: ^2.1.0 titlebar_buttons: ^1.0.0 url_launcher: ^6.1.7 @@ -114,6 +104,20 @@ dependencies: youtube_explode_dart: ^2.0.1 simple_icons: ^7.10.0 audio_service_mpris: ^0.1.0 + file_picker: ^6.0.0 + jiosaavn: ^0.1.0 + draggable_scrollbar: + git: + url: https://github.com/thielepaul/flutter-draggable-scrollbar.git + ref: cfd570035bf393de541d32e9b28808b5d7e602df + very_good_infinite_list: ^0.7.1 + gap: ^3.0.1 + sliver_tools: ^0.2.12 + dart_discord_rpc: + git: + url: https://github.com/Tommypop2/dart_discord_rpc.git + html_unescape: ^2.0.0 + wikipedia_api: ^0.1.0 dev_dependencies: build_runner: ^2.3.2 @@ -134,14 +138,6 @@ dev_dependencies: dependency_overrides: http: ^1.1.0 system_tray: 2.0.2 - fl_query: - git: - url: https://github.com/KRTirtho/fl-query.git - path: packages/fl_query - fl_query_hooks: - git: - url: https://github.com/KRTirtho/fl-query.git - path: packages/fl_query_hooks flutter: generate: true diff --git a/website/components/Footer.tsx b/website/components/Footer.tsx index b940815d..a51cf0a2 100644 --- a/website/components/Footer.tsx +++ b/website/components/Footer.tsx @@ -49,7 +49,7 @@ const Footer = () => { color: "white", }} > - © 2022, Spotube. All rights reserved + © {new Date().getFullYear()}, Spotube. All rights reserved diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt index b2e4bd8d..4f2af69b 100644 --- a/windows/flutter/CMakeLists.txt +++ b/windows/flutter/CMakeLists.txt @@ -9,6 +9,11 @@ include(${EPHEMERAL_DIR}/generated_config.cmake) # https://github.com/flutter/flutter/issues/57146. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") @@ -91,7 +96,7 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - windows-x64 $ + ${FLUTTER_TARGET_PLATFORM} $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index ff25c4e3..b9c6a481 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -19,6 +20,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + DartDiscordRpcPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("DartDiscordRpcPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 0a5ab976..5cd55ff3 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + dart_discord_rpc file_selector_windows flutter_secure_storage_windows local_notifier