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..f5cc65ee 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,7 +26,7 @@ on: default: true env: - FLUTTER_VERSION: '3.13.2' + FLUTTER_VERSION: '3.16.0' jobs: windows: @@ -163,7 +163,6 @@ jobs: dart pub global activate flutter_distributor alias dpkg-deb="dpkg-deb --Zxz" flutter_distributor package --platform=linux --targets=deb - flutter_distributor package --platform=linux --targets=appimage flutter_distributor package --platform=linux --targets=rpm - name: Create tar.xz (stable) @@ -179,7 +178,6 @@ jobs: mv build/spotube-linux-*-x86_64.tar.xz dist/ mv dist/**/spotube-*-linux.deb dist/Spotube-linux-x86_64.deb mv dist/**/spotube-*-linux.rpm dist/Spotube-linux-x86_64.rpm - mv dist/**/spotube-*-linux.AppImage dist/Spotube-linux-x86_64.AppImage - uses: actions/upload-artifact@v3 @@ -187,7 +185,6 @@ jobs: if-no-files-found: error name: Spotube-Release-Binaries path: | - dist/Spotube-linux-x86_64.AppImage dist/Spotube-linux-x86_64.deb dist/Spotube-linux-x86_64.rpm dist/spotube-linux-${{ env.BUILD_VERSION }}-x86_64.tar.xz @@ -319,6 +316,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 8086ada7..50fe1e6a 100644 --- a/lib/collections/env.dart +++ b/lib/collections/env.dart @@ -5,12 +5,6 @@ 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; @@ -33,4 +27,6 @@ abstract class Env { static bool get enableUpdateChecker => DesktopTools.platform.isFlatpak || _enableUpdateChecker == "1"; + + static String discordAppId = "1176718791388975124"; } 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 945f8ecf..c7ae2f9a 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -4,9 +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/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'; @@ -15,7 +18,7 @@ extension FormattedAlbumType on AlbumType { } class AlbumCard extends HookConsumerWidget { - final Album album; + final AlbumSimple album; const AlbumCard( this.album, { Key? key, @@ -27,7 +30,9 @@ 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], @@ -36,6 +41,34 @@ class AlbumCard extends HookConsumerWidget { 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, @@ -54,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; @@ -80,28 +108,16 @@ class AlbumCard extends HookConsumerWidget { updating.value = true; try { - final fetchedTracks = - await queryClient.fetchQuery, SpotifyApi>( - "album-tracks/${album.id}", - () { - return spotify.albums - .tracks(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: () { @@ -110,7 +126,8 @@ class AlbumCard extends HookConsumerWidget { }, ), ); - ScaffoldMessenger.maybeOf(context)?.showSnackBar(snackbar); + + scaffoldMessenger?.showSnackBar(snackbar); } } finally { updating.value = false; 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 354d9fe6..cc8b10cf 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -18,7 +18,7 @@ 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/models/local_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; @@ -199,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), @@ -222,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) { @@ -284,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 0102a3c7..f7736ca7 100644 --- a/lib/components/library/user_playlists.dart +++ b/lib/components/library/user_playlists.dart @@ -14,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'; @@ -120,31 +121,33 @@ class UserPlaylists extends HookConsumerWidget { const SliverToBoxAdapter( child: SizedBox(height: 10), ), - SliverGrid.builder( - itemCount: playlists.length + 1, - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - mainAxisExtent: DesktopTools.platform.isMobile ? 225 : 250, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - itemBuilder: (context, index) { - if (index == playlists.length) { - if (!playlistsQuery.hasNextPage) { - return const SizedBox.shrink(); + 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 Waypoint( - controller: controller, - isGrid: true, - onTouchEdge: playlistsQuery.fetchNext, - child: const ShimmerPlaybuttonCard(count: 1), - ); - } - - return PlaylistCard(playlists[index]); - }, - ) + return PlaylistCard(playlists[index]); + }, + ); + }) ], ), ), diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index a6f69925..8142740c 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -11,7 +11,7 @@ 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/controllers/use_auto_scroll_controller.dart'; diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index ee8d9719..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'; @@ -12,13 +13,13 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/hooks/utils/use_debounce.dart'; -import 'package:spotube/models/matched_track.dart'; -import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; -import 'package:spotube/provider/youtube_provider.dart'; -import 'package:spotube/services/youtube/youtube.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'; @@ -35,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); @@ -61,18 +61,31 @@ class SiblingTracksSheet extends HookConsumerWidget { 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) @@ -82,21 +95,21 @@ 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) { + (SourceInfo sourceInfo) { return ListTile( - title: Text(video.title), + title: Text(sourceInfo.title), leading: Padding( padding: const EdgeInsets.all(8.0), child: UniversalImage( - path: video.thumbnailUrl, + path: sourceInfo.thumbnail, height: 60, width: 60, ), @@ -104,16 +117,18 @@ class SiblingTracksSheet extends HookConsumerWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(5), ), - trailing: Text(video.duration.toHumanReadableString()), - subtitle: Text(video.channelName), + trailing: Text(sourceInfo.duration.toHumanReadableString()), + subtitle: Text(sourceInfo.artist), enabled: playlist.isFetching != true, selected: playlist.isFetching != true && - video.id == (playlist.activeTrack as SpotubeTrack).ytTrack.id, + sourceInfo.id == + (playlist.activeTrack as SourcedTrack).sourceInfo.id, selectedTileColor: theme.popupMenuTheme.color, onTap: () { if (playlist.isFetching == false && - video.id != (playlist.activeTrack as SpotubeTrack).ytTrack.id) { - playlistNotifier.swapSibling(video); + sourceInfo.id != + (playlist.activeTrack as SourcedTrack).sourceInfo.id) { + playlistNotifier.swapSibling(sourceInfo); Navigator.of(context).pop(); } }, @@ -175,7 +190,7 @@ class SiblingTracksSheet extends HookConsumerWidget { }, ) else ...[ - if (preferences.youtubeApiType == YoutubeApiType.piped) + if (preferences.audioSource == AudioSource.piped) PopupMenuButton( icon: const Icon(SpotubeIcons.filter, size: 18), onSelected: (SearchMode mode) { 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/shared/dialogs/playlist_add_track_dialog.dart b/lib/components/shared/dialogs/playlist_add_track_dialog.dart index aadcd9d6..1c8e5aaa 100644 --- a/lib/components/shared/dialogs/playlist_add_track_dialog.dart +++ b/lib/components/shared/dialogs/playlist_add_track_dialog.dart @@ -11,9 +11,12 @@ 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); @@ -30,11 +33,12 @@ class PlaylistAddTrackDialog extends HookConsumerWidget { ?.where( (playlist) => playlist.owner?.id != null && - playlist.owner!.id == me.data?.id, + playlist.owner!.id == me.data?.id && + playlist.id != openFromPlaylist, ) .toList() ?? [], - [userPlaylists.data, me.data?.id], + [userPlaylists.data, me.data?.id, openFromPlaylist], ); final playlistsCheck = useState({}); 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/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart index 43435f7d..d8e20184 100644 --- a/lib/components/shared/page_window_title_bar.dart +++ b/lib/components/shared/page_window_title_bar.dart @@ -11,16 +11,6 @@ import 'dart:io' show Platform, exit; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:local_notifier/local_notifier.dart'; -final closeNotification = DesktopTools.createNotification( - title: 'Spotube', - message: 'Running in background. Minimized to System Tray', - actions: [ - LocalNotificationAction(text: 'Close The App'), - ], -)?..onClickAction = (value) { - exit(0); - }; - class PageWindowTitleBar extends StatefulHookConsumerWidget implements PreferredSizeWidget { final Widget? leading; @@ -113,12 +103,7 @@ class WindowTitleBarButtons extends HookConsumerWidget { const type = ThemeType.auto; Future onClose() async { - if (preferences.closeBehavior == CloseBehavior.close) { - exit(0); - } else { - await DesktopTools.window.hide(); - await closeNotification?.show(); - } + await DesktopTools.window.close(); } useEffect(() { diff --git a/lib/components/shared/playbutton_card.dart b/lib/components/shared/playbutton_card.dart index 4fef72c0..d9c48640 100644 --- a/lib/components/shared/playbutton_card.dart +++ b/lib/components/shared/playbutton_card.dart @@ -63,26 +63,8 @@ class PlaybuttonCard extends HookWidget { others: 15, ); - final textsHeight = useState( - (textsKey.currentContext?.findRenderObject() as RenderBox?) - ?.size - .height ?? - 110.00, - ); - final cleanDescription = useDescription(description); - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - textsHeight.value = - (textsKey.currentContext?.findRenderObject() as RenderBox?) - ?.size - .height ?? - textsHeight.value; - }); - return null; - }, [textsKey]); - return Container( constraints: BoxConstraints(maxWidth: size), margin: margin, diff --git a/lib/components/shared/shimmers/shimmer_artist_profile.dart b/lib/components/shared/shimmers/shimmer_artist_profile.dart index d0b0288f..75e50cd0 100644 --- a/lib/components/shared/shimmers/shimmer_artist_profile.dart +++ b/lib/components/shared/shimmers/shimmer_artist_profile.dart @@ -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_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/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 f211a521..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/utils/use_custom_status_bar_color.dart'; -import 'package:spotube/hooks/utils/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 14a4f1a9..00000000 --- a/lib/components/shared/track_table/tracks_table_view.dart +++ /dev/null @@ -1,368 +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/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.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 93% rename from lib/components/shared/track_table/track_options.dart rename to lib/components/shared/track_tile/track_options.dart index 96bd8b60..8405d6ea 100644 --- a/lib/components/shared/track_table/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -12,6 +13,7 @@ import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/authentication_provider.dart'; @@ -22,6 +24,7 @@ import 'package:spotube/services/mutations/mutations.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; enum TrackOptionValue { + album, share, addToPlaylist, addToQueue, @@ -64,20 +67,27 @@ 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, ), ); } @override Widget build(BuildContext context, ref) { + final scaffoldMessenger = ScaffoldMessenger.of(context); + final mediaQuery = MediaQuery.of(context); + final router = GoRouter.of(context); + final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playback = ref.watch(ProxyPlaylistNotifier.notifier); - final scaffoldMessenger = ScaffoldMessenger.of(context); final auth = ref.watch(AuthenticationNotifier.provider); ref.watch(downloadManagerProvider); final downloadManager = ref.watch(downloadManagerProvider.notifier); @@ -110,7 +120,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); }); @@ -118,6 +128,12 @@ class TrackOptions extends HookConsumerWidget { final adaptivePopSheetList = AdaptivePopSheetList( onSelected: (value) async { switch (value) { + case TrackOptionValue.album: + await router.push( + '/album/${track.album!.id}', + extra: track.album!, + ); + break; case TrackOptionValue.delete: await File((track as LocalTrack).path).delete(); ref.refresh(localTracksProvider); @@ -229,6 +245,13 @@ class TrackOptions extends HookConsumerWidget { ) ], _ => [ + if (mediaQuery.smAndDown) + PopSheetEntry( + value: TrackOptionValue.album, + leading: const Icon(SpotubeIcons.album), + title: Text(context.l10n.go_to_album), + subtitle: Text(track.album!.name!), + ), if (!playlist.containsTrack(track)) ...[ PopSheetEntry( value: TrackOptionValue.addToQueue, diff --git a/lib/components/shared/track_table/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart similarity index 99% rename from lib/components/shared/track_table/track_tile.dart rename to lib/components/shared/track_tile/track_tile.dart index 4980f96b..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'; 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..217aaed4 --- /dev/null +++ b/lib/components/shared/tracks_view/track_view.dart @@ -0,0 +1,47 @@ +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: RefreshIndicator( + onRefresh: props.pagination.onRefresh, + child: 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..1c6c7647 --- /dev/null +++ b/lib/components/shared/tracks_view/track_view_props.dart @@ -0,0 +1,107 @@ +import 'package:fl_query/fl_query.dart'; +import 'package:flutter/material.dart' hide Page; +import 'package:spotify/spotify.dart'; + +class PaginationProps { + final bool hasNextPage; + final bool isLoading; + final VoidCallback onFetchMore; + final Future Function() onRefresh; + final Future> Function() onFetchAll; + + const PaginationProps({ + required this.hasNextPage, + required this.isLoading, + required this.onFetchMore, + required this.onFetchAll, + required this.onRefresh, + }); + + factory PaginationProps.fromQuery( + InfiniteQuery, dynamic, int> query, { + required Future> Function() onFetchAll, + }) { + return PaginationProps( + hasNextPage: query.hasNextPage, + isLoading: query.isLoadingNextPage, + onFetchMore: query.fetchNext, + onFetchAll: onFetchAll, + onRefresh: query.refreshAll, + ); + } + + @override + operator ==(Object other) { + return other is PaginationProps && + other.hasNextPage == hasNextPage && + other.isLoading == isLoading && + other.onFetchMore == onFetchMore && + other.onFetchAll == onFetchAll && + other.onRefresh == onRefresh; + } + + @override + int get hashCode => + super.hashCode ^ + hasNextPage.hashCode ^ + isLoading.hashCode ^ + onFetchMore.hashCode ^ + onFetchAll.hashCode ^ + onRefresh.hashCode; +} + +class InheritedTrackView extends InheritedWidget { + final String collectionId; + final String title; + final String? description; + final String image; + final String routePath; + final List tracks; + final PaginationProps pagination; + final bool isLiked; + final String shareUrl; + + // events + final VoidCallback? onHeart; // if null heart button will hidden + + const InheritedTrackView({ + super.key, + required super.child, + required this.collectionId, + required this.title, + this.description, + required this.image, + required this.tracks, + required this.pagination, + required this.routePath, + required this.shareUrl, + this.isLiked = false, + this.onHeart, + }); + + @override + bool updateShouldNotify(InheritedTrackView oldWidget) { + return oldWidget.title != title || + oldWidget.description != description || + oldWidget.image != image || + oldWidget.tracks != tracks || + oldWidget.pagination != pagination || + oldWidget.isLiked != isLiked || + oldWidget.onHeart != onHeart || + oldWidget.shareUrl != shareUrl || + oldWidget.routePath != routePath || + oldWidget.collectionId != collectionId || + oldWidget.child != child; + } + + static InheritedTrackView of(BuildContext context) { + final widget = + context.dependOnInheritedWidgetOfExactType(); + if (widget == null) { + throw Exception( + 'InheritedTrackView not found. Make sure to wrap [TrackView] with [InheritedTrackView]', + ); + } + return widget; + } +} diff --git a/lib/components/shared/tracks_view/track_view_provider.dart b/lib/components/shared/tracks_view/track_view_provider.dart new file mode 100644 index 00000000..14dc1136 --- /dev/null +++ b/lib/components/shared/tracks_view/track_view_provider.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/library/user_local_tracks.dart'; + +class TrackViewNotifier extends ChangeNotifier { + List tracks; + List selectedTrackIds; + SortBy sortBy; + String? searchQuery; + + TrackViewNotifier( + this.tracks, { + this.selectedTrackIds = const [], + this.sortBy = SortBy.none, + this.searchQuery, + }); + + bool get isSelecting => selectedTrackIds.isNotEmpty; + + bool get hasSelectedAll => + selectedTrackIds.length == tracks.length && tracks.isNotEmpty; + + List get selectedTracks => + tracks.where((e) => selectedTrackIds.contains(e.id)).toList(); + + void selectTrack(String trackId) { + selectedTrackIds = [...selectedTrackIds, trackId]; + notifyListeners(); + } + + void unselectTrack(String trackId) { + selectedTrackIds = selectedTrackIds.where((e) => e != trackId).toList(); + notifyListeners(); + } + + void toggleTrackSelection(String trackId) { + if (selectedTrackIds.contains(trackId)) { + unselectTrack(trackId); + } else { + selectTrack(trackId); + } + } + + void selectAll() { + selectedTrackIds = tracks.map((e) => e.id!).toList(); + notifyListeners(); + } + + void deselectAll() { + selectedTrackIds = []; + notifyListeners(); + } + + void sort(SortBy sortBy) { + this.sortBy = sortBy; + notifyListeners(); + } +} + +final trackViewProvider = ChangeNotifierProvider.autoDispose + .family>((ref, tracks) { + return TrackViewNotifier(tracks); +}); diff --git a/lib/extensions/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/hooks/configurators/use_close_behavior.dart b/lib/hooks/configurators/use_close_behavior.dart new file mode 100644 index 00000000..05c03fff --- /dev/null +++ b/lib/hooks/configurators/use_close_behavior.dart @@ -0,0 +1,32 @@ +import 'dart:io'; + +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/hooks/configurators/use_window_listener.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:local_notifier/local_notifier.dart'; + +final closeNotification = DesktopTools.createNotification( + title: 'Spotube', + message: 'Running in background. Minimized to System Tray', + actions: [ + LocalNotificationAction(text: 'Close The App'), + ], +)?..onClickAction = (value) { + exit(0); + }; + +void useCloseBehavior(WidgetRef ref) { + useWindowListener( + onWindowClose: () async { + final preferences = ref.read(userPreferencesProvider); + if (preferences.closeBehavior == CloseBehavior.minimizeToTray) { + await DesktopTools.window.hide(); + closeNotification?.show(); + } else { + exit(0); + } + }, + ); +} diff --git a/lib/hooks/configurators/use_window_listener.dart b/lib/hooks/configurators/use_window_listener.dart new file mode 100644 index 00000000..b91ad413 --- /dev/null +++ b/lib/hooks/configurators/use_window_listener.dart @@ -0,0 +1,197 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +class CallbackWindowListener implements WindowListener { + final VoidCallback? _onWindowClose; + final VoidCallback? _onWindowFocus; + final VoidCallback? _onWindowBlur; + final VoidCallback? _onWindowMaximize; + final VoidCallback? _onWindowUnmaximize; + final VoidCallback? _onWindowMinimize; + final VoidCallback? _onWindowRestore; + final VoidCallback? _onWindowResize; + final VoidCallback? _onWindowResized; + final VoidCallback? _onWindowMove; + final VoidCallback? _onWindowMoved; + final VoidCallback? _onWindowEnterFullScreen; + final VoidCallback? _onWindowLeaveFullScreen; + final VoidCallback? _onWindowDocked; + final VoidCallback? _onWindowUndocked; + final VoidCallback? _onWindowEvent; + + const CallbackWindowListener({ + VoidCallback? onWindowClose, + VoidCallback? onWindowFocus, + VoidCallback? onWindowBlur, + VoidCallback? onWindowMaximize, + VoidCallback? onWindowUnmaximize, + VoidCallback? onWindowMinimize, + VoidCallback? onWindowRestore, + VoidCallback? onWindowResize, + VoidCallback? onWindowResized, + VoidCallback? onWindowMove, + VoidCallback? onWindowMoved, + VoidCallback? onWindowEnterFullScreen, + VoidCallback? onWindowLeaveFullScreen, + VoidCallback? onWindowDocked, + VoidCallback? onWindowUndocked, + VoidCallback? onWindowEvent, + }) : _onWindowClose = onWindowClose, + _onWindowFocus = onWindowFocus, + _onWindowBlur = onWindowBlur, + _onWindowMaximize = onWindowMaximize, + _onWindowUnmaximize = onWindowUnmaximize, + _onWindowMinimize = onWindowMinimize, + _onWindowRestore = onWindowRestore, + _onWindowResize = onWindowResize, + _onWindowResized = onWindowResized, + _onWindowMove = onWindowMove, + _onWindowMoved = onWindowMoved, + _onWindowEnterFullScreen = onWindowEnterFullScreen, + _onWindowLeaveFullScreen = onWindowLeaveFullScreen, + _onWindowDocked = onWindowDocked, + _onWindowUndocked = onWindowUndocked, + _onWindowEvent = onWindowEvent; + + @override + void onWindowBlur() { + return _onWindowBlur?.call(); + } + + @override + void onWindowClose() { + return _onWindowClose?.call(); + } + + @override + void onWindowDocked() { + return _onWindowDocked?.call(); + } + + @override + void onWindowEnterFullScreen() { + return _onWindowEnterFullScreen?.call(); + } + + @override + void onWindowEvent(String eventName) { + return _onWindowEvent?.call(); + } + + @override + void onWindowFocus() { + return _onWindowFocus?.call(); + } + + @override + void onWindowLeaveFullScreen() { + return _onWindowLeaveFullScreen?.call(); + } + + @override + void onWindowMaximize() { + return _onWindowMaximize?.call(); + } + + @override + void onWindowMinimize() { + return _onWindowMinimize?.call(); + } + + @override + void onWindowMove() { + return _onWindowMove?.call(); + } + + @override + void onWindowMoved() { + return _onWindowMoved?.call(); + } + + @override + void onWindowResize() { + return _onWindowResize?.call(); + } + + @override + void onWindowResized() { + return _onWindowResized?.call(); + } + + @override + void onWindowRestore() { + return _onWindowRestore?.call(); + } + + @override + void onWindowUndocked() { + return _onWindowUndocked?.call(); + } + + @override + void onWindowUnmaximize() { + return _onWindowUnmaximize?.call(); + } +} + +void useWindowListener({ + VoidCallback? onWindowClose, + VoidCallback? onWindowFocus, + VoidCallback? onWindowBlur, + VoidCallback? onWindowMaximize, + VoidCallback? onWindowUnmaximize, + VoidCallback? onWindowMinimize, + VoidCallback? onWindowRestore, + VoidCallback? onWindowResize, + VoidCallback? onWindowResized, + VoidCallback? onWindowMove, + VoidCallback? onWindowMoved, + VoidCallback? onWindowEnterFullScreen, + VoidCallback? onWindowLeaveFullScreen, + VoidCallback? onWindowDocked, + VoidCallback? onWindowUndocked, + VoidCallback? onWindowEvent, +}) { + useEffect(() { + final listener = CallbackWindowListener( + onWindowClose: onWindowClose, + onWindowFocus: onWindowFocus, + onWindowBlur: onWindowBlur, + onWindowMaximize: onWindowMaximize, + onWindowUnmaximize: onWindowUnmaximize, + onWindowMinimize: onWindowMinimize, + onWindowRestore: onWindowRestore, + onWindowResize: onWindowResize, + onWindowResized: onWindowResized, + onWindowMove: onWindowMove, + onWindowMoved: onWindowMoved, + onWindowEnterFullScreen: onWindowEnterFullScreen, + onWindowLeaveFullScreen: onWindowLeaveFullScreen, + onWindowDocked: onWindowDocked, + onWindowUndocked: onWindowUndocked, + onWindowEvent: onWindowEvent, + ); + DesktopTools.window.addListener(listener); + return () { + DesktopTools.window.removeListener(listener); + }; + }, [ + onWindowClose, + onWindowFocus, + onWindowBlur, + onWindowMaximize, + onWindowUnmaximize, + onWindowMinimize, + onWindowRestore, + onWindowResize, + onWindowResized, + onWindowMove, + onWindowMoved, + onWindowEnterFullScreen, + onWindowLeaveFullScreen, + onWindowDocked, + onWindowUndocked, + onWindowEvent, + ]); +} diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index f587710c..b74564a4 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -251,7 +251,7 @@ "developers": "المطورون", "not_logged_in": "لم تقم بتسجيل الدخول", "search_mode": "وضع البحث", - "youtube_api_type": "نوع الـAPI", + "audio_source": "مصدر الصوت", "ok": "حسسناً", "failed_to_encrypt": "فشل في التشفير", "encryption_failed_warning": "يستخدم Spotube التشفير لتخزين بياناتك بشكل آمن. لكنها فشلت في القيام بذلك. لذلك سيعود الأمر إلى التخزين غير الآمن\nإذا كنت تستخدم Linux، فيرجى التأكد من تثبيت أي خدمة سرية (gnome-keyring، kde-wallet، keepassxc، إلخ)", diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 02402179..59985b24 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -249,7 +249,7 @@ "developers": "ডেভেলপার", "not_logged_in": "আপনি লগইন করা নেই", "search_mode": "অনুসন্ধান মোড", - "youtube_api_type": "API প্রকার", + "audio_source": "অডিও উৎস", "ok": "ঠিক আছে", "failed_to_encrypt": "এনক্রিপ্ট করা ব্যর্থ হয়েছে", "encryption_failed_warning": "Spotube আপনার তথ্যগুলি নিরাপদভাবে স্টোর করতে এনক্রিপশন ব্যবহার করে। কিন্তু এটি ব্যর্থ হয়েছে। তাই এটি অনিরাপদ স্টোরে ফলফল হবে\nযদি আপনি Linux ব্যবহার করেন, তবে দয়া করে নিশ্চিত হউন যে আপনার কোনও সিক্রেট-সার্ভিস gnome-keyring, kde-wallet, keepassxc ইত্যাদি ইনস্টল করা আছে", diff --git a/lib/l10n/app_ca.arb b/lib/l10n/app_ca.arb index 81a11082..1d1bb5e4 100644 --- a/lib/l10n/app_ca.arb +++ b/lib/l10n/app_ca.arb @@ -249,7 +249,7 @@ "developers": "Desenvolupadors", "not_logged_in": "No ha iniciat sesió", "search_mode": "Mode de cerca", - "youtube_api_type": "Tipus d'API de YouTube", + "audio_source": "Font d'àudio", "ok": "OK", "failed_to_encrypt": "Error al xifrar", "encryption_failed_warning": "Spotube utilitza el xifrado per emmagatzemar les seves dades de forma segura. Però ha fallat. Per tant, tornarà a un emmagatzament no segur\nSi estè utilizant Linux, asseguri's de tenir instal·lats els serveis secrets com gnome-keyring, kde-wallet i keepassxc", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 339a8d65..8b7b2593 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -249,7 +249,7 @@ "developers": "Entwickler", "not_logged_in": "Sie sind nicht angemeldet", "search_mode": "Suchmodus", - "youtube_api_type": "API-Typ", + "audio_source": "Audioquelle", "ok": "OK", "failed_to_encrypt": "Verschlüsselung fehlgeschlagen", "encryption_failed_warning": "Spotube verwendet Verschlüsselung, um Ihre Daten sicher zu speichern. Dies ist jedoch fehlgeschlagen. Daher wird es auf unsichere Speicherung zurückgreifen\nWenn Sie Linux verwenden, stellen Sie bitte sicher, dass Sie Secret-Services wie gnome-keyring, kde-wallet und keepassxc installiert haben", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 730f51ea..5aded7d5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -251,7 +251,7 @@ "developers": "Developers", "not_logged_in": "You're not logged in", "search_mode": "Search Mode", - "youtube_api_type": "API Type", + "audio_source": "Audio Source", "ok": "Ok", "failed_to_encrypt": "Failed to encrypt", "encryption_failed_warning": "Spotube uses encryption to securely store your data. But failed to do so. So it'll fallback to insecure storage\nIf you're using linux, please make sure you've any secret-service (gnome-keyring, kde-wallet, keepassxc etc) installed", @@ -279,5 +279,6 @@ "password": "Password", "login": "Login", "login_with_your_lastfm": "Login with your Last.fm account", - "scrobble_to_lastfm": "Scrobble to Last.fm" + "scrobble_to_lastfm": "Scrobble to Last.fm", + "go_to_album": "Go to Album" } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index f617705e..d90ec972 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -249,7 +249,7 @@ "developers": "Desarrolladores", "not_logged_in": "No has iniciado sesión", "search_mode": "Modo de búsqueda", - "youtube_api_type": "Tipo de API de YouTube", + "audio_source": "Fuente de audio", "ok": "OK", "failed_to_encrypt": "Error al cifrar", "encryption_failed_warning": "Spotube utiliza el cifrado para almacenar sus datos de forma segura. Pero ha fallado. Por lo tanto, volverá a un almacenamiento no seguro\nSi está utilizando Linux, asegúrese de tener instalados servicios secretos como gnome-keyring, kde-wallet y keepassxc", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 5454b13b..a8bb9f8c 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -251,7 +251,7 @@ "developers": "توسعه دهنده ها", "not_logged_in": "شما وارد نشده اید ", "search_mode": "حالت جستجو", - "youtube_api_type": "API نوع", + "audio_source": "منبع صدا", "ok": "باشد", "failed_to_encrypt": "رمز گذاری نشده", "encryption_failed_warning": "Spotube از رمزگذاری برای ذخیره ایمن داده های شما استفاده می کند. اما موفق به انجام این کار نشد. بنابراین به فضای ذخیره‌سازی ناامن تبدیل می‌شود\nاگر از لینوکس استفاده می‌کنید، لطفاً مطمئن شوید که سرویس مخفی (gnome-keyring، kde-wallet، keepassxc و غیره) را نصب کرده‌اید.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index fbe5c335..06015964 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -249,7 +249,7 @@ "developers": "Développeurs", "not_logged_in": "Vous n'êtes pas connecté(e)", "search_mode": "Mode de recherche", - "youtube_api_type": "Type d'API", + "audio_source": "Source audio", "ok": "OK", "failed_to_encrypt": "Échec de la cryptage", "encryption_failed_warning": "Spotube utilise le cryptage pour stocker vos données en toute sécurité. Mais cela a échoué. Il basculera donc vers un stockage non sécurisé\nSi vous utilisez Linux, assurez-vous d'avoir installé des services secrets tels que gnome-keyring, kde-wallet et keepassxc", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index d33f41dc..adbc6c06 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -249,7 +249,7 @@ "developers": "डेवलपर्स", "not_logged_in": "आप लॉग इन नहीं हैं", "search_mode": "खोज मोड", - "youtube_api_type": "API प्रकार", + "audio_source": "ऑडियो स्रोत", "ok": "ठीक है", "failed_to_encrypt": "एन्क्रिप्ट करने में विफल रहा", "encryption_failed_warning": "Spotube आपके डेटा को सुरक्षित रूप से स्टोर करने के लिए एन्क्रिप्शन का उपयोग करता है। लेकिन इसमें विफल रहा। इसलिए, यह असुरक्षित स्टोरेज पर फॉलबैक करेगा\nयदि आप Linux का उपयोग कर रहे हैं, तो कृपया सुनिश्चित करें कि आपके पास gnome-keyring, kde-wallet, keepassxc आदि जैसी कोई सीक्रेट-सर्विस इंस्टॉल की गई है", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 50c9369f..a288cf0e 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -249,7 +249,7 @@ "developers": "開発", "not_logged_in": "ログインしていません", "search_mode": "検索モード", - "youtube_api_type": "APIの種類", + "audio_source": "音声ソース", "ok": "分かりました", "failed_to_encrypt": "暗号化に失敗しました", "encryption_failed_warning": "Spotubeはデータを安全に保存するために暗号化を使用しています。しかし、失敗しました。したがって、安全でないストレージにフォールバックします\nLinuxを使用している場合は、gnome-keyring、kde-wallet、keepassxcなどのシークレットサービスがインストールされていることを確認してください", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 1a946615..8c6147ad 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -249,7 +249,7 @@ "developers": "Developerzy", "not_logged_in": "Nie jesteś zalogowany", "search_mode": "Tryb szukania", - "youtube_api_type": "Typ API", + "audio_source": "Źródło dźwięku", "ok": "Ok", "failed_to_encrypt": "Nie można zaszyfrować :(", "encryption_failed_warning": "Spotube używa szyfrowania do bezpiecznego przechowywania danych. Ale nie udało się tego zrobić. Więc powróci do niezabezpieczonego przechowywania\nJeśli używasz Linuksa, upewnij się, że masz zainstalowane jakieś usługi do szyfrowania (gnome-keyring, kde-wallet, keepassxc itp.)", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 97df3db3..46c0a88e 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -249,7 +249,7 @@ "developers": "Desenvolvedores", "not_logged_in": "Você não está logado", "search_mode": "Modo de Busca", - "youtube_api_type": "Tipo de API", + "audio_source": "Fonte de Áudio", "ok": "Ok", "failed_to_encrypt": "Falha ao criptografar", "encryption_failed_warning": "O Spotube usa criptografia para armazenar seus dados com segurança, mas falhou em fazê-lo. Portanto, ele voltará para o armazenamento não seguro.\nSe você estiver usando o Linux, certifique-se de ter algum serviço secreto (gnome-keyring, kde-wallet, keepassxc, etc.) instalado", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 098e73c7..b1964f5f 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -249,7 +249,7 @@ "developers": "Разработчики", "not_logged_in": "Вы не выполнили вход", "search_mode": "Режим поиска", - "youtube_api_type": "Тип API", + "audio_source": "Источник аудио", "ok": "Ок", "failed_to_encrypt": "Не удалось зашифровать", "encryption_failed_warning": "Spotube использует шифрование для безопасного хранения ваших данных. Однако в этом случае произошла ошибка. Поэтому будет использовано небезопасное хранилище.\nЕсли вы используете Linux, убедитесь, что у вас установлен какой-либо инструмент для работы с секретами (gnome-keyring, kde-wallet, keepassxc и т.д.)", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 1d72ec85..fa0ea587 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -251,7 +251,7 @@ "developers": "Geliştiriciler", "not_logged_in": "Giriş yapmadınız", "search_mode": "Arama Modu", - "youtube_api_type": "API Türü", + "audio_source": "Ses Kaynağı", "ok": "Tamam", "failed_to_encrypt": "Şifreleme başarısız oldu", "encryption_failed_warning": "Spotube, verilerinizi güvenli bir şekilde depolamak için şifreleme kullanır. Ancak bunu başaramadı. Bu nedenle güvensiz bir depolamaya geri dönecektir. Linux kullanıyorsanız, lütfen gnome-keyring, kde-wallet, keepassxc vb. gibi bir güvenlik hizmetinizin kurulu olduğundan emin olun.", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index fa0877d1..22fc341e 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -251,7 +251,7 @@ "developers": "Розробники", "not_logged_in": "Ви не ввійшли в обліковий запис", "search_mode": "Режим пошуку", - "youtube_api_type": "Тип API", + "audio_source": "Джерело аудіо", "ok": "Гаразд", "failed_to_encrypt": "Не вдалося зашифрувати", "encryption_failed_warning": "Spotube використовує шифрування для безпечного зберігання ваших даних. Але не вдалося цього зробити. Тому він перейде до небезпечного зберігання\nЯкщо ви використовуєте Linux, переконайтеся, що у вас встановлено будь-який секретний сервіс (gnome-keyring, kde-wallet, keepassxc тощо)", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 9936c812..325216fa 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -249,7 +249,7 @@ "developers": "开发者", "not_logged_in": "你尚未登录", "search_mode": "搜索模式", - "youtube_api_type": "API 类型", + "audio_source": "音频源", "ok": "确定", "failed_to_encrypt": "加密失败", "encryption_failed_warning": "Spotube使用加密来安全地存储您的数据。但是失败了。因此,它将回退到不安全的存储\n如果您使用Linux,请确保已安装gnome-keyring、kde-wallet和keepassxc等秘密服务", diff --git a/lib/main.dart b/lib/main.dart index 943b5a52..b78b12f5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'package:catcher_2/catcher_2.dart'; import 'package:device_info_plus/device_info_plus.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'; @@ -15,12 +16,13 @@ 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/configurators/use_close_behavior.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/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -49,6 +51,10 @@ Future main(List rawArgs) async { await AndroidUtils.setHighRefreshRate(); } + if (DesktopTools.platform.isDesktop) { + await DesktopTools.window.setPreventClose(true); + } + await DesktopTools.ensureInitialized( DesktopWindowOptions( hideTitleBar: true, @@ -64,6 +70,10 @@ Future main(List rawArgs) async { MetadataGod.initialize(); } + if (DesktopTools.platform.isWindows || DesktopTools.platform.isLinux) { + DiscordRPC.initialize(); + } + final hiveCacheDir = kIsWeb ? null : (await getApplicationSupportDirectory()).path; @@ -72,16 +82,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( @@ -171,6 +183,7 @@ class SpotubeState extends ConsumerState { ref.watch(paletteProvider.select((s) => s?.dominantColor?.color)); useInitSysTray(ref); + useCloseBehavior(ref); useEffect(() { FlutterNativeSplash.remove(); @@ -178,7 +191,6 @@ class SpotubeState extends ConsumerState { /// For enabling hot reload for audio player if (!kDebugMode) return; audioPlayer.dispose(); - // youtube.close(); }; }, []); 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/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 67b09ad8..00000000 --- a/lib/models/spotube_track.dart +++ /dev/null @@ -1,274 +0,0 @@ -import 'dart:async'; - -import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; -import 'package:spotube/models/matched_track.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.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 - ...TrackJson.trackToJson(this), - // 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 299bf9f5..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/utils/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,427 +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), - 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 b3904e2e..54fb6786 100644 --- a/lib/pages/home/genres.dart +++ b/lib/pages/home/genres.dart @@ -70,7 +70,8 @@ class GenrePage extends HookConsumerWidget { child: Column( children: [ ExpandableSearchField( - isFiltering: isFiltering, + isFiltering: isFiltering.value, + onChangeFiltering: (value) => isFiltering.value = value, searchController: searchController, searchFocus: searchFocus, ), @@ -103,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 16cfc3a8..7fbd27ae 100644 --- a/lib/pages/home/personalized.dart +++ b/lib/pages/home/personalized.dart @@ -46,47 +46,64 @@ class PersonalizedPage extends HookConsumerWidget { [newReleases.pages], ); - return ListView( + return CustomScrollView( controller: controller, - children: [ - if (!featuredPlaylistsQuery.hasPageData && - !featuredPlaylistsQuery.isLoadingNextPage) - const ShimmerCategories() - else - HorizontalPlaybuttonCardView( - items: playlists.toList(), - title: Text(context.l10n.featured), - isLoadingNextPage: featuredPlaylistsQuery.isLoadingNextPage, - 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) + 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: () {}, + ); + }, ), - if (auth != null && - newReleases.hasPageData && - userArtistsQuery.hasData && - !newReleases.isLoadingNextPage) - HorizontalPlaybuttonCardView( - items: albums, - title: Text(context.l10n.new_releases), - isLoadingNextPage: newReleases.isLoadingNextPage, - 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 HorizontalPlaybuttonCardView( - items: playlists, - title: Text(item["name"] ?? ""), - hasNextPage: false, - isLoadingNextPage: false, - onFetchMore: () {}, - ); - }) + ), ], ); } diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 4b8dddaf..802b28d3 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.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/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index be32dbc9..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'; @@ -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 8147915f..36a9f316 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -112,7 +112,7 @@ class SyncedLyrics extends HookConsumerWidget { final lyricSlice = lyricValue.lyrics[index]; final isActive = lyricSlice.time.inSeconds == currentTime; - if (isActive && isUnSyncLyric == true) { + if (isActive) { controller.scrollToIndex( index, preferPosition: AutoScrollPosition.middle, diff --git a/lib/pages/playlist/liked_playlist.dart b/lib/pages/playlist/liked_playlist.dart new file mode 100644 index 00000000..5972a303 --- /dev/null +++ b/lib/pages/playlist/liked_playlist.dart @@ -0,0 +1,48 @@ +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(); + }, + onRefresh: () async { + await likedTracks.refresh(); + }, + ), + title: playlist.name!, + description: playlist.description, + tracks: tracks, + routePath: '/playlist/${playlist.id}', + isLiked: false, + shareUrl: "", + onHeart: null, + child: const TrackView(), + ); + } +} diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index 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/search/search.dart b/lib/pages/search/search.dart index b19162fa..f4a78d4f 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -114,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/artists.dart b/lib/pages/search/sections/artists.dart index b736bf13..fe4459d6 100644 --- a/lib/pages/search/sections/artists.dart +++ b/lib/pages/search/sections/artists.dart @@ -32,7 +32,7 @@ class SearchArtistsSection extends HookConsumerWidget { hasNextPage: query.hasNextPage, items: artists, onFetchMore: query.fetchNext, - title: Text(context.l10n.albums), + title: Text(context.l10n.artists), ); } } diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart index 59c6a4e1..e77cd8f2 100644 --- a/lib/pages/search/sections/tracks.dart +++ b/lib/pages/search/sections/tracks.dart @@ -5,7 +5,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/dialogs/prompt_dialog.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/provider/proxy_playlist/proxy_playlist_provider.dart'; diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart index 5e000231..d36e0713 100644 --- a/lib/pages/settings/sections/playback.dart +++ b/lib/pages/settings/sections/playback.dart @@ -8,10 +8,10 @@ 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/models/matched_track.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); @@ -25,17 +25,21 @@ class SettingsPlaybackSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.playback, children: [ - AdaptiveSelectTile( + AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.audioQuality), title: Text(context.l10n.audio_quality), value: preferences.audioQuality, options: [ DropdownMenuItem( - value: AudioQuality.high, + value: SourceQualities.high, child: Text(context.l10n.high), ), DropdownMenuItem( - value: AudioQuality.low, + value: SourceQualities.medium, + child: Text(context.l10n.medium), + ), + DropdownMenuItem( + value: SourceQualities.low, child: Text(context.l10n.low), ), ], @@ -45,11 +49,11 @@ class SettingsPlaybackSection extends HookConsumerWidget { } }, ), - AdaptiveSelectTile( + AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.api), - title: Text(context.l10n.youtube_api_type), - value: preferences.youtubeApiType, - options: YoutubeApiType.values + title: Text(context.l10n.audio_source), + value: preferences.audioSource, + options: AudioSource.values .map((e) => DropdownMenuItem( value: e, child: Text(e.label), @@ -57,12 +61,12 @@ class SettingsPlaybackSection extends HookConsumerWidget { .toList(), onChanged: (value) { if (value == null) return; - preferencesNotifier.setYoutubeApiType(value); + preferencesNotifier.setAudioSource(value); }, ), AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: preferences.youtubeApiType == YoutubeApiType.youtube + child: preferences.audioSource != AudioSource.piped ? const SizedBox.shrink() : Consumer(builder: (context, ref, child) { final instanceList = ref.watch(pipedInstancesFutureProvider); @@ -129,7 +133,7 @@ class SettingsPlaybackSection extends HookConsumerWidget { ), AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: preferences.youtubeApiType == YoutubeApiType.youtube + child: preferences.audioSource != AudioSource.piped ? const SizedBox.shrink() : AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.search), @@ -149,17 +153,18 @@ class SettingsPlaybackSection extends HookConsumerWidget { ), AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: preferences.searchMode == SearchMode.youtubeMusic && - preferences.youtubeApiType == YoutubeApiType.piped - ? const SizedBox.shrink() - : SwitchListTile( + 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), @@ -176,44 +181,46 @@ class SettingsPlaybackSection extends HookConsumerWidget { value: preferences.normalizeAudio, onChanged: preferencesNotifier.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; - preferencesNotifier.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; - preferencesNotifier.setDownloadMusicCodec(value); - }, - ), + 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 842d5240..f773b809 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -30,12 +30,13 @@ class SettingsPage extends HookConsumerWidget { title: Text(context.l10n.settings), centerTitle: true, ), - body: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: Container( - constraints: const BoxConstraints(maxWidth: 1366), + body: Scrollbar( + controller: controller, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1366), + child: ScrollConfiguration( + behavior: const ScrollBehavior().copyWith(scrollbars: false), child: ListView( controller: controller, children: [ @@ -59,7 +60,7 @@ class SettingsPage extends HookConsumerWidget { ), ), ), - ], + ), ), ), ); diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index 889641f4..dc538938 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -9,25 +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/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; -import 'package:spotube/provider/youtube_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; @@ -45,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); @@ -91,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 @@ -107,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; @@ -144,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() @@ -157,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 @@ -173,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); } } @@ -196,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) { @@ -216,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() { @@ -244,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 b447f1ef..1d2cfde8 100644 --- a/lib/provider/proxy_playlist/next_fetcher_mixin.dart +++ b/lib/provider/proxy_playlist/next_fetcher_mixin.dart @@ -3,36 +3,30 @@ 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/user_preferences_state.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; @@ -47,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) { @@ -80,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 { @@ -103,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; @@ -111,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 e5dfa7e8..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 { @@ -59,7 +63,7 @@ class ProxyPlaylist { static Map _makeAppropriateTrackJson(Track track) { return switch (track.runtimeType) { LocalTrack => track.toJson(), - SpotubeTrack => track.toJson(), + SourcedTrack => track.toJson(), _ => track.toJson(), }; } diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index 50024661..84943993 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -12,9 +12,9 @@ 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'; @@ -22,17 +22,22 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; -import 'package:spotube/provider/youtube_provider.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/services/sourced_track/sources/piped.dart'; +import 'package:spotube/services/sourced_track/sources/youtube.dart'; + import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; /// Things implemented: /// * [x] Sponsor-Block skip -/// * [x] Prefetch next track as [SpotubeTrack] on 80% of current track -/// * [x] Mixed Queue containing both [SpotubeTrack] and [LocalTrack] +/// * [x] Prefetch next track as [SourcedTrack] on 80% of current track +/// * [x] Mixed Queue containing both [SourcedTrack] and [LocalTrack] /// * [x] Modification of the Queue /// * [x] Add track at the end /// * [x] Add track at the beginning @@ -56,7 +61,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); @@ -91,6 +95,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } notificationService.addTrack(newActiveTrack); + discord.updatePresence(newActiveTrack); state = state.copyWith( active: state.tracks .toList() @@ -131,21 +136,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 @@ -168,11 +163,11 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier return; } try { - final isYTMusicMode = - preferences.youtubeApiType == YoutubeApiType.piped && - preferences.searchMode == SearchMode.youtubeMusic; + final isNotYTMode = state.activeTrack is! YoutubeSourcedTrack && + (state.activeTrack is PipedSourcedTrack && + preferences.searchMode == SearchMode.youtubeMusic); - if (isYTMusicMode || !preferences.skipNonMusic) return; + if (isNotYTMode || !preferences.skipNonMusic) return; final isNotSameSegmentId = currentSegments.value?.source != audioPlayer.currentSource; @@ -184,7 +179,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) { @@ -237,7 +232,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier }(); } - Future ensureSourcePlayable(String source) async { + Future ensureSourcePlayable(String source) async { if (isPlayable(source)) return null; final track = mapSourcesToTracks([source]).firstOrNull; @@ -247,17 +242,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; @@ -334,16 +325,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, ); }); @@ -353,10 +343,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier collections: {}, ); await notificationService.addTrack(addableTrack); - await storeTrack( - tracks.elementAt(initialIndex), - addableTrack, - ); + discord.updatePresence(addableTrack); } await audioPlayer.openPlaylist( @@ -385,13 +372,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!); } } @@ -437,9 +418,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), @@ -449,11 +430,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), @@ -494,12 +475,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!); } } @@ -525,18 +501,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 { @@ -564,7 +536,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 { @@ -652,7 +624,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 index db4b73dc..88a0df2e 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -6,11 +6,11 @@ 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/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'; @@ -26,11 +26,11 @@ class UserPreferencesNotifier extends PersistedStateNotifier { state = UserPreferences.withDefaults(); } - void setStreamMusicCodec(MusicCodec codec) { + void setStreamMusicCodec(SourceCodecs codec) { state = state.copyWith(streamMusicCodec: codec); } - void setDownloadMusicCodec(MusicCodec codec) { + void setDownloadMusicCodec(SourceCodecs codec) { state = state.copyWith(downloadMusicCodec: codec); } @@ -60,7 +60,7 @@ class UserPreferencesNotifier extends PersistedStateNotifier { state = state.copyWith(checkUpdate: check); } - void setAudioQuality(AudioQuality quality) { + void setAudioQuality(SourceQualities quality) { state = state.copyWith(audioQuality: quality); } @@ -97,8 +97,8 @@ class UserPreferencesNotifier extends PersistedStateNotifier { state = state.copyWith(skipNonMusic: skip); } - void setYoutubeApiType(YoutubeApiType type) { - state = state.copyWith(youtubeApiType: type); + void setAudioSource(AudioSource type) { + state = state.copyWith(audioSource: type); } void setSystemTitleBar(bool isSystemTitleBar) { diff --git a/lib/provider/user_preferences/user_preferences_state.dart b/lib/provider/user_preferences/user_preferences_state.dart index ff98fa8e..b3d7fe8a 100644 --- a/lib/provider/user_preferences/user_preferences_state.dart +++ b/lib/provider/user_preferences/user_preferences_state.dart @@ -4,7 +4,7 @@ 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/models/matched_track.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; part 'user_preferences_state.g.dart'; @@ -15,12 +15,6 @@ enum LayoutMode { adaptive, } -@JsonEnum() -enum AudioQuality { - high, - low, -} - @JsonEnum() enum CloseBehavior { minimizeToTray, @@ -28,9 +22,10 @@ enum CloseBehavior { } @JsonEnum() -enum YoutubeApiType { +enum AudioSource { youtube, - piped; + piped, + jiosaavn; String get label => name[0].toUpperCase() + name.substring(1); } @@ -44,13 +39,27 @@ enum MusicCodec { 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: AudioQuality.high, - unknownEnumValue: AudioQuality.high, + defaultValue: SourceQualities.high, + unknownEnumValue: SourceQualities.high, ) - final AudioQuality audioQuality; + final SourceQualities audioQuality; @JsonKey(defaultValue: true) final bool albumColorSync; @@ -172,22 +181,22 @@ final class UserPreferences { final ThemeMode themeMode; @JsonKey( - defaultValue: YoutubeApiType.youtube, - unknownEnumValue: YoutubeApiType.youtube, + defaultValue: AudioSource.youtube, + unknownEnumValue: AudioSource.youtube, ) - final YoutubeApiType youtubeApiType; + final AudioSource audioSource; @JsonKey( - defaultValue: MusicCodec.weba, - unknownEnumValue: MusicCodec.weba, + defaultValue: SourceCodecs.weba, + unknownEnumValue: SourceCodecs.weba, ) - final MusicCodec streamMusicCodec; + final SourceCodecs streamMusicCodec; @JsonKey( - defaultValue: MusicCodec.m4a, - unknownEnumValue: MusicCodec.m4a, + defaultValue: SourceCodecs.m4a, + unknownEnumValue: SourceCodecs.m4a, ) - final MusicCodec downloadMusicCodec; + final SourceCodecs downloadMusicCodec; UserPreferences({ required this.audioQuality, @@ -207,7 +216,7 @@ final class UserPreferences { required this.downloadLocation, required this.pipedInstance, required this.themeMode, - required this.youtubeApiType, + required this.audioSource, required this.streamMusicCodec, required this.downloadMusicCodec, }); @@ -229,7 +238,7 @@ final class UserPreferences { SpotubeColor? accentColorScheme, bool? albumColorSync, bool? checkUpdate, - AudioQuality? audioQuality, + SourceQualities? audioQuality, String? downloadLocation, LayoutMode? layoutMode, CloseBehavior? closeBehavior, @@ -238,13 +247,13 @@ final class UserPreferences { String? pipedInstance, SearchMode? searchMode, bool? skipNonMusic, - YoutubeApiType? youtubeApiType, + AudioSource? audioSource, Market? recommendationMarket, bool? saveTrackLyrics, bool? amoledDarkTheme, bool? normalizeAudio, - MusicCodec? downloadMusicCodec, - MusicCodec? streamMusicCodec, + SourceCodecs? downloadMusicCodec, + SourceCodecs? streamMusicCodec, bool? systemTitleBar, }) { return UserPreferences( @@ -261,7 +270,7 @@ final class UserPreferences { pipedInstance: pipedInstance ?? this.pipedInstance, searchMode: searchMode ?? this.searchMode, skipNonMusic: skipNonMusic ?? this.skipNonMusic, - youtubeApiType: youtubeApiType ?? this.youtubeApiType, + audioSource: audioSource ?? this.audioSource, recommendationMarket: recommendationMarket ?? this.recommendationMarket, amoledDarkTheme: amoledDarkTheme ?? this.amoledDarkTheme, downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, diff --git a/lib/provider/user_preferences/user_preferences_state.g.dart b/lib/provider/user_preferences/user_preferences_state.g.dart index 9e3eeee9..54cd3aa2 100644 --- a/lib/provider/user_preferences/user_preferences_state.g.dart +++ b/lib/provider/user_preferences/user_preferences_state.g.dart @@ -9,9 +9,9 @@ part of 'user_preferences_state.dart'; UserPreferences _$UserPreferencesFromJson(Map json) => UserPreferences( audioQuality: $enumDecodeNullable( - _$AudioQualityEnumMap, json['audioQuality'], - unknownValue: AudioQuality.high) ?? - AudioQuality.high, + _$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, @@ -51,23 +51,23 @@ UserPreferences _$UserPreferencesFromJson(Map json) => themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode'], unknownValue: ThemeMode.system) ?? ThemeMode.system, - youtubeApiType: $enumDecodeNullable( - _$YoutubeApiTypeEnumMap, json['youtubeApiType'], - unknownValue: YoutubeApiType.youtube) ?? - YoutubeApiType.youtube, + audioSource: $enumDecodeNullable( + _$AudioSourceEnumMap, json['audioSource'], + unknownValue: AudioSource.youtube) ?? + AudioSource.youtube, streamMusicCodec: $enumDecodeNullable( - _$MusicCodecEnumMap, json['streamMusicCodec'], - unknownValue: MusicCodec.weba) ?? - MusicCodec.weba, + _$SourceCodecsEnumMap, json['streamMusicCodec'], + unknownValue: SourceCodecs.weba) ?? + SourceCodecs.weba, downloadMusicCodec: $enumDecodeNullable( - _$MusicCodecEnumMap, json['downloadMusicCodec'], - unknownValue: MusicCodec.m4a) ?? - MusicCodec.m4a, + _$SourceCodecsEnumMap, json['downloadMusicCodec'], + unknownValue: SourceCodecs.m4a) ?? + SourceCodecs.m4a, ); Map _$UserPreferencesToJson(UserPreferences instance) => { - 'audioQuality': _$AudioQualityEnumMap[instance.audioQuality]!, + 'audioQuality': _$SourceQualitiesEnumMap[instance.audioQuality]!, 'albumColorSync': instance.albumColorSync, 'amoledDarkTheme': instance.amoledDarkTheme, 'checkUpdate': instance.checkUpdate, @@ -85,14 +85,15 @@ Map _$UserPreferencesToJson(UserPreferences instance) => 'downloadLocation': instance.downloadLocation, 'pipedInstance': instance.pipedInstance, 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, - 'youtubeApiType': _$YoutubeApiTypeEnumMap[instance.youtubeApiType]!, - 'streamMusicCodec': _$MusicCodecEnumMap[instance.streamMusicCodec]!, - 'downloadMusicCodec': _$MusicCodecEnumMap[instance.downloadMusicCodec]!, + 'audioSource': _$AudioSourceEnumMap[instance.audioSource]!, + 'streamMusicCodec': _$SourceCodecsEnumMap[instance.streamMusicCodec]!, + 'downloadMusicCodec': _$SourceCodecsEnumMap[instance.downloadMusicCodec]!, }; -const _$AudioQualityEnumMap = { - AudioQuality.high: 'high', - AudioQuality.low: 'low', +const _$SourceQualitiesEnumMap = { + SourceQualities.high: 'high', + SourceQualities.medium: 'medium', + SourceQualities.low: 'low', }; const _$CloseBehaviorEnumMap = { @@ -370,12 +371,13 @@ const _$ThemeModeEnumMap = { ThemeMode.dark: 'dark', }; -const _$YoutubeApiTypeEnumMap = { - YoutubeApiType.youtube: 'youtube', - YoutubeApiType.piped: 'piped', +const _$AudioSourceEnumMap = { + AudioSource.youtube: 'youtube', + AudioSource.piped: 'piped', + AudioSource.jiosaavn: 'jiosaavn', }; -const _$MusicCodecEnumMap = { - MusicCodec.m4a: 'm4a', - MusicCodec.weba: 'weba', +const _$SourceCodecsEnumMap = { + SourceCodecs.m4a: 'm4a', + SourceCodecs.weba: 'weba', }; diff --git a/lib/provider/youtube_provider.dart b/lib/provider/youtube_provider.dart deleted file mode 100644 index 33e0496f..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/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_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 bfe022d6..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(); @@ -321,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/playlist.dart b/lib/services/mutations/playlist.dart index a88e8512..077fff06 100644 --- a/lib/services/mutations/playlist.dart +++ b/lib/services/mutations/playlist.dart @@ -131,10 +131,8 @@ class PlaylistMutations { ); } }, - refreshQueries: [ - "playlist/$playlistId", - ], refreshInfiniteQueries: [ + "playlist/$playlistId", "current-user-playlists", ], ref: ref, diff --git a/lib/services/queries/album.dart b/lib/services/queries/album.dart index 546b3d15..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/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 7501d619..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/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/lyrics.dart b/lib/services/queries/lyrics.dart index b51016b4..faa5bdec 100644 --- a/lib/services/queries/lyrics.dart +++ b/lib/services/queries/lyrics.dart @@ -8,7 +8,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/map.dart'; import 'package:spotube/hooks/spotify/use_spotify_query.dart'; import 'package:spotube/models/lyrics.dart'; -import 'package:spotube/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 2c6c38be..836f9d72 100644 --- a/lib/services/queries/playlist.dart +++ b/lib/services/queries/playlist.dart @@ -166,17 +166,14 @@ class PlaylistQueries { ); } - Future> likedTracks( - SpotifyApi spotify, - WidgetRef ref, - ) async { + 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>( @@ -201,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", @@ -233,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/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 2b52864b..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/user_preferences_state.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/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 39e92028..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: @@ -945,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: @@ -961,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: @@ -985,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: @@ -1049,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: @@ -1174,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: @@ -1206,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: @@ -1338,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: @@ -1554,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: @@ -1590,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: @@ -1662,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: @@ -1844,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: @@ -1904,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: @@ -1916,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: @@ -1956,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: @@ -2016,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: @@ -2200,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: @@ -2216,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: @@ -2236,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: @@ -2293,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: @@ -2310,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 590aaae4..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 @@ -89,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 @@ -106,11 +105,19 @@ dependencies: 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 diff --git a/untranslated_messages.json b/untranslated_messages.json index 9e26dfee..0130c162 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1 +1,61 @@ -{} \ No newline at end of file +{ + "ar": [ + "go_to_album" + ], + + "bn": [ + "go_to_album" + ], + + "ca": [ + "go_to_album" + ], + + "de": [ + "go_to_album" + ], + + "es": [ + "go_to_album" + ], + + "fa": [ + "go_to_album" + ], + + "fr": [ + "go_to_album" + ], + + "hi": [ + "go_to_album" + ], + + "ja": [ + "go_to_album" + ], + + "pl": [ + "go_to_album" + ], + + "pt": [ + "go_to_album" + ], + + "ru": [ + "go_to_album" + ], + + "tr": [ + "go_to_album" + ], + + "uk": [ + "go_to_album" + ], + + "zh": [ + "go_to_album" + ] +} 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